mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
fix ui
This commit is contained in:
@@ -1,107 +1,107 @@
|
||||
import { auditHttp } from './audit.http'
|
||||
|
||||
export type AuditChange = {
|
||||
field: string
|
||||
from: unknown
|
||||
to: unknown
|
||||
}
|
||||
|
||||
export type AuditLogCommandItem = {
|
||||
id: string
|
||||
createdAt: string
|
||||
deletedAt: string | null
|
||||
entityName: string
|
||||
payload: unknown | null
|
||||
changes: AuditChange[]
|
||||
queryMethod: 'INSERT' | 'UPDATE' | 'DELETE' | string
|
||||
auditLog: unknown | null
|
||||
}
|
||||
|
||||
export type AuditLogExtensionItem = {
|
||||
id?: string
|
||||
createdAt: string
|
||||
entityName: string
|
||||
queryMethod: 'INSERT' | 'UPDATE' | 'DELETE' | string
|
||||
user: {
|
||||
id: string
|
||||
name: {
|
||||
am: string
|
||||
en: string
|
||||
}
|
||||
email: string
|
||||
userId?: string
|
||||
username?: string
|
||||
sessionId?: string
|
||||
employeeId?: string
|
||||
positionId?: string
|
||||
employeePositionId?: string
|
||||
}
|
||||
changes?: AuditChange[]
|
||||
}
|
||||
|
||||
export type Paginated<T> = {
|
||||
count: number
|
||||
items: T[]
|
||||
}
|
||||
|
||||
export type AuditListQuery = {
|
||||
skip?: number
|
||||
take?: number
|
||||
orderBy?: string
|
||||
}
|
||||
|
||||
export type AuditContext = {
|
||||
unitId?: string
|
||||
organizationId?: string
|
||||
}
|
||||
|
||||
type ParamValue = string | number | boolean | null | undefined
|
||||
|
||||
function toParams<T extends Record<string, ParamValue>>(q?: T) {
|
||||
if (!q) return undefined
|
||||
|
||||
const params = new URLSearchParams()
|
||||
|
||||
Object.entries(q).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null) return
|
||||
if (typeof v === 'string' && v.trim() === '') return
|
||||
params.set(k, String(v))
|
||||
})
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
export async function listAuditLogCommandsByModule(
|
||||
moduleKey: string,
|
||||
query?: AuditListQuery,
|
||||
context?: AuditContext,
|
||||
) {
|
||||
const { data } = await auditHttp.get<Paginated<AuditLogCommandItem>>(
|
||||
`/audit-log-commands/all/${encodeURIComponent(moduleKey)}`,
|
||||
{
|
||||
params: toParams(query),
|
||||
headers: {
|
||||
...(context?.unitId
|
||||
? { 'x-current-unit-id': context.unitId }
|
||||
: {}),
|
||||
...(context?.organizationId
|
||||
? { 'x-current-organization-id': context.organizationId }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listAuditLogExtensions(
|
||||
endpoint: string = '/audit-log-extensions/audit/unitAdmin',
|
||||
query?: AuditListQuery,
|
||||
) {
|
||||
const { data } = await auditHttp.get<Paginated<AuditLogExtensionItem>>(
|
||||
endpoint,
|
||||
{
|
||||
params: toParams(query),
|
||||
},
|
||||
)
|
||||
return data
|
||||
}
|
||||
import { auditHttp } from './audit.http'
|
||||
|
||||
export type AuditChange = {
|
||||
field: string
|
||||
from: unknown
|
||||
to: unknown
|
||||
}
|
||||
|
||||
export type AuditLogCommandItem = {
|
||||
id: string
|
||||
createdAt: string
|
||||
deletedAt: string | null
|
||||
entityName: string
|
||||
payload: unknown | null
|
||||
changes: AuditChange[]
|
||||
queryMethod: 'INSERT' | 'UPDATE' | 'DELETE' | string
|
||||
auditLog: unknown | null
|
||||
}
|
||||
|
||||
export type AuditLogExtensionItem = {
|
||||
id?: string
|
||||
createdAt: string
|
||||
entityName: string
|
||||
queryMethod: 'INSERT' | 'UPDATE' | 'DELETE' | string
|
||||
user: {
|
||||
id: string
|
||||
name: {
|
||||
am: string
|
||||
en: string
|
||||
}
|
||||
email: string
|
||||
userId?: string
|
||||
username?: string
|
||||
sessionId?: string
|
||||
employeeId?: string
|
||||
positionId?: string
|
||||
employeePositionId?: string
|
||||
}
|
||||
changes?: AuditChange[]
|
||||
}
|
||||
|
||||
export type Paginated<T> = {
|
||||
count: number
|
||||
items: T[]
|
||||
}
|
||||
|
||||
export type AuditListQuery = {
|
||||
skip?: number
|
||||
take?: number
|
||||
orderBy?: string
|
||||
}
|
||||
|
||||
export type AuditContext = {
|
||||
unitId?: string
|
||||
organizationId?: string
|
||||
}
|
||||
|
||||
type ParamValue = string | number | boolean | null | undefined
|
||||
|
||||
function toParams<T extends Record<string, ParamValue>>(q?: T) {
|
||||
if (!q) return undefined
|
||||
|
||||
const params = new URLSearchParams()
|
||||
|
||||
Object.entries(q).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null) return
|
||||
if (typeof v === 'string' && v.trim() === '') return
|
||||
params.set(k, String(v))
|
||||
})
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
export async function listAuditLogCommandsByModule(
|
||||
moduleKey: string,
|
||||
query?: AuditListQuery,
|
||||
context?: AuditContext,
|
||||
) {
|
||||
const { data } = await auditHttp.get<Paginated<AuditLogCommandItem>>(
|
||||
`/audit-log-commands/all/${encodeURIComponent(moduleKey)}`,
|
||||
{
|
||||
params: toParams(query),
|
||||
headers: {
|
||||
...(context?.unitId
|
||||
? { 'x-current-unit-id': context.unitId }
|
||||
: {}),
|
||||
...(context?.organizationId
|
||||
? { 'x-current-organization-id': context.organizationId }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listAuditLogExtensions(
|
||||
endpoint: string = '/audit-log-extensions/audit/unitAdmin',
|
||||
query?: AuditListQuery,
|
||||
) {
|
||||
const { data } = await auditHttp.get<Paginated<AuditLogExtensionItem>>(
|
||||
endpoint,
|
||||
{
|
||||
params: toParams(query),
|
||||
},
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { toast } from 'sonner'
|
||||
import { AUDITLOG_API_URL } from '@/shared/config/app.config'
|
||||
import { createHttpClient, type TokenPair } from '../http/http-client-factory'
|
||||
import { getRefreshToken } from '@/shared/utils/refreshTokenHandler'
|
||||
|
||||
async function refreshFn(refreshToken: string): Promise<TokenPair> {
|
||||
const res = await getRefreshToken(refreshToken)
|
||||
return {
|
||||
accessToken: res.data.token,
|
||||
refreshToken: res.data.refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
export const auditHttp = createHttpClient({
|
||||
baseURL: AUDITLOG_API_URL,
|
||||
cookieKeys: {
|
||||
access: 'auth-token',
|
||||
refresh: 'refresh-token',
|
||||
user: 'auth-user',
|
||||
},
|
||||
refreshFn,
|
||||
skipRefreshPaths: ['/auth/login', '/auth/refresh', '/auth/me'],
|
||||
onAuthFailure: () => {
|
||||
toast.error('Session expired', {
|
||||
description: 'Please log in again.',
|
||||
duration: 4000,
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '/'
|
||||
}, 1200)
|
||||
},
|
||||
})
|
||||
import { toast } from 'sonner'
|
||||
import { AUDITLOG_API_URL } from '@/shared/config/app.config'
|
||||
import { createHttpClient, type TokenPair } from '../http/http-client-factory'
|
||||
import { getRefreshToken } from '@/shared/utils/refreshTokenHandler'
|
||||
|
||||
async function refreshFn(refreshToken: string): Promise<TokenPair> {
|
||||
const res = await getRefreshToken(refreshToken)
|
||||
return {
|
||||
accessToken: res.data.token,
|
||||
refreshToken: res.data.refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
export const auditHttp = createHttpClient({
|
||||
baseURL: AUDITLOG_API_URL,
|
||||
cookieKeys: {
|
||||
access: 'auth-token',
|
||||
refresh: 'refresh-token',
|
||||
user: 'auth-user',
|
||||
},
|
||||
refreshFn,
|
||||
skipRefreshPaths: ['/auth/login', '/auth/refresh', '/auth/me'],
|
||||
onAuthFailure: () => {
|
||||
toast.error('Session expired', {
|
||||
description: 'Please log in again.',
|
||||
duration: 4000,
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '/'
|
||||
}, 1200)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,346 +1,346 @@
|
||||
// src/services/api/authService.ts
|
||||
import { VerifyUserPayload } from "@/record-management/services/api/authService";
|
||||
import axiosInstance from "./axiosInstance";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axios from "axios";
|
||||
|
||||
export interface SignupPayload {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
userType: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
userName?: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthHeaders {
|
||||
tenantKey: string;
|
||||
unitId?: string;
|
||||
}
|
||||
|
||||
export interface SetPasswordPayload {
|
||||
userId?: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
verificationCode: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
export interface SetFayidaPasswordPayload {
|
||||
userId: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
export interface ChangePasswordPayload {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
export interface UpdateProfilePayload {
|
||||
email: string;
|
||||
username: string;
|
||||
phoneNumber: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
}
|
||||
export interface OtpVerificationPayload {
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
export interface GenerateVerifcationCodePayload {
|
||||
phoneNumber: string;
|
||||
email?: string;
|
||||
verificationCode?: string;
|
||||
}
|
||||
|
||||
export enum EOtpType {
|
||||
SET_PASSWORD = "set-password",
|
||||
RESET_PASSWORD = "reset-password",
|
||||
VERIFY_PHONE_NUMBER = "verify-phone-number",
|
||||
}
|
||||
|
||||
export const signup = async (payload: SignupPayload, headers: AuthHeaders) => {
|
||||
return axiosInstance.post("/auth/signup", payload, {
|
||||
headers: {
|
||||
"x-organization-tenant-key": headers.tenantKey,
|
||||
...(headers.unitId && { "x-organization-unit-id": headers.unitId }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const login = async (payload: LoginPayload) => {
|
||||
// Validate that at least one identifier is provided
|
||||
if (!payload.email && !payload.phoneNumber && !payload.userName) {
|
||||
throw new Error(
|
||||
"At least one login identifier (email, phoneNumber, or userName) is required",
|
||||
);
|
||||
}
|
||||
|
||||
return axiosInstance.post("/auth/login", payload);
|
||||
};
|
||||
|
||||
export const getProfile = async (
|
||||
config?: Parameters<typeof axiosInstance.get>[1],
|
||||
) => {
|
||||
return axiosInstance.get("/auth/me", {
|
||||
...config,
|
||||
headers: {
|
||||
...withHeaders(),
|
||||
...(config?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
export const updateProfile = async (payload: UpdateProfilePayload) => {
|
||||
return axiosInstance.patch("/auth/update-profile", payload);
|
||||
};
|
||||
|
||||
export const setPassword = async (payload: SetPasswordPayload) => {
|
||||
return axiosInstance.patch("/auth/set-password", payload);
|
||||
};
|
||||
export const setFayidaPass = async (payload: SetFayidaPasswordPayload) => {
|
||||
return axiosInstance.patch("/auth/set-fayda-password", payload);
|
||||
};
|
||||
export const changePassword = async (payload: ChangePasswordPayload) => {
|
||||
return axiosInstance.patch("/auth/change-password", payload);
|
||||
};
|
||||
export const resendVerificationCode = async (
|
||||
payload: GenerateVerifcationCodePayload,
|
||||
) => {
|
||||
const payloadObj = {
|
||||
...payload,
|
||||
type: EOtpType.SET_PASSWORD,
|
||||
};
|
||||
return axiosInstance.patch("/auth/generate-verification-code", payloadObj);
|
||||
};
|
||||
export const verifyOTPCode = async (
|
||||
payload: GenerateVerifcationCodePayload,
|
||||
) => {
|
||||
return axiosInstance.patch("/auth/verify-phone-number", payload);
|
||||
};
|
||||
export const resendOtpCode = async (
|
||||
payload: GenerateVerifcationCodePayload,
|
||||
) => {
|
||||
const payloadObj = {
|
||||
...payload,
|
||||
type: EOtpType.VERIFY_PHONE_NUMBER,
|
||||
};
|
||||
return axiosInstance.patch("/auth/generate-verification-code", payloadObj);
|
||||
};
|
||||
|
||||
export const refreshAuthToken = async (payload: { refreshToken: string }) => {
|
||||
return axiosInstance.post("/auth/refresh-token", payload);
|
||||
};
|
||||
|
||||
export const requestForgotPassword = async (identifier: string) => {
|
||||
try {
|
||||
// The API expects the identifier in the email field regardless of whether it's an email or phone
|
||||
const payload = { email: identifier };
|
||||
|
||||
const response = await axiosInstance.post("/auth/forgot-password", payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
return axiosInstance.patch("/auth/logout");
|
||||
};
|
||||
|
||||
export const verifyMFAUser = async (payload: VerifyUserPayload) => {
|
||||
return axiosInstance.post("/auth/mfa-verify", payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export interface RegisterWithFaydaPayload {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface RegisterWithFaydaResponse {
|
||||
userId?: string;
|
||||
token?: string;
|
||||
refreshToken?: string;
|
||||
fullName?: string;
|
||||
faydaId?: string;
|
||||
user?: {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
fullName?: string;
|
||||
faydaId?: string;
|
||||
name?: string | { am?: string; en?: string };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
citizen?: {
|
||||
fullName?: string;
|
||||
faydaId?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const signinWithFayda = async (payload: RegisterWithFaydaPayload) => {
|
||||
return axiosInstance.post<RegisterWithFaydaResponse>(
|
||||
"/auth/signin-with-fayda",
|
||||
payload,
|
||||
{ headers: withHeaders() },
|
||||
);
|
||||
};
|
||||
|
||||
export const signupWithFayda = async (payload: RegisterWithFaydaPayload) => {
|
||||
return axiosInstance.post<RegisterWithFaydaResponse>(
|
||||
"/auth/signup-with-fayda",
|
||||
payload,
|
||||
{ headers: withHeaders() },
|
||||
);
|
||||
};
|
||||
|
||||
export const signinWithEtrade = async (payload: EtradeSigninPayload) => {
|
||||
return axiosInstance.post<RegisterWithEtradeResponse>(
|
||||
"/auth/signin-with-etrade",
|
||||
payload,
|
||||
{ headers: withHeaders() },
|
||||
);
|
||||
};
|
||||
|
||||
export const signupWithEtrade = async (payload: EtradeSignupPayload) => {
|
||||
return axiosInstance.post<RegisterWithEtradeResponse>(
|
||||
"/auth/signup-with-etrade",
|
||||
payload,
|
||||
{ headers: withHeaders() },
|
||||
);
|
||||
};
|
||||
|
||||
export const verifyEtradeOtp = async (payload: VerifyEtradeOtpPayload) => {
|
||||
return axiosInstance.post("/auth/verify-etrade-otp", payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const registerWithFayda = async (payload: RegisterWithFaydaPayload) => {
|
||||
const trySignin = () => signinWithFayda(payload);
|
||||
|
||||
const trySignup = () => signupWithFayda(payload);
|
||||
|
||||
const isUserNotFound = (err: unknown) => {
|
||||
if (!axios.isAxiosError(err)) return false;
|
||||
const status = err.response?.status;
|
||||
const data = err.response?.data as any;
|
||||
const code =
|
||||
typeof data?.code === "string"
|
||||
? data.code
|
||||
: typeof data?.errorCode === "string"
|
||||
? data.errorCode
|
||||
: typeof data?.message === "string"
|
||||
? data.message
|
||||
: "";
|
||||
const codeLower = code.toLowerCase();
|
||||
return (
|
||||
status === 404 ||
|
||||
codeLower.includes("not_found") ||
|
||||
codeLower.includes("user_not_found") ||
|
||||
codeLower.includes("does_not_exist") ||
|
||||
codeLower.includes("not exist")
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
return await trySignin();
|
||||
} catch (err) {
|
||||
if (!isUserNotFound(err)) throw err;
|
||||
return await trySignup();
|
||||
}
|
||||
};
|
||||
|
||||
export interface EtradeSignupPayload {
|
||||
tin: string;
|
||||
licenseNumber: string;
|
||||
}
|
||||
|
||||
export interface EtradeSigninPayload {
|
||||
licenseNumber: string;
|
||||
}
|
||||
|
||||
/** @deprecated Use EtradeSigninPayload */
|
||||
export interface RegisterWithEtradePayload {
|
||||
licenseNumber: string;
|
||||
}
|
||||
|
||||
export type RegisterWithEtradeResponse = RegisterWithFaydaResponse;
|
||||
|
||||
export interface VerifyEtradeOtpPayload {
|
||||
licenseNo: string;
|
||||
otp: string;
|
||||
}
|
||||
|
||||
export const registerWithEtrade = async (
|
||||
payload:
|
||||
| EtradeSigninPayload
|
||||
| EtradeSignupPayload
|
||||
| RegisterWithEtradePayload
|
||||
| { tin: string; licenseNo: string }
|
||||
| { licenseNo: string },
|
||||
) => {
|
||||
const resolveLicenseNumber = () => {
|
||||
if ("licenseNumber" in payload) return payload.licenseNumber;
|
||||
if ("licenseNo" in payload) return payload.licenseNo;
|
||||
return payload.licenseNumber;
|
||||
};
|
||||
|
||||
const trySignin = () =>
|
||||
signinWithEtrade({ licenseNumber: resolveLicenseNumber() });
|
||||
|
||||
const trySignup = () => {
|
||||
if ("tin" in payload && ("licenseNumber" in payload || "licenseNo" in payload)) {
|
||||
return signupWithEtrade({
|
||||
tin: payload.tin,
|
||||
licenseNumber: resolveLicenseNumber(),
|
||||
});
|
||||
}
|
||||
return signupWithEtrade({
|
||||
tin: "",
|
||||
licenseNumber: resolveLicenseNumber(),
|
||||
});
|
||||
};
|
||||
|
||||
const isUserNotFound = (err: unknown) => {
|
||||
if (!axios.isAxiosError(err)) return false;
|
||||
const status = err.response?.status;
|
||||
const data = err.response?.data as any;
|
||||
const code =
|
||||
typeof data?.code === "string"
|
||||
? data.code
|
||||
: typeof data?.errorCode === "string"
|
||||
? data.errorCode
|
||||
: typeof data?.message === "string"
|
||||
? data.message
|
||||
: "";
|
||||
const codeLower = code.toLowerCase();
|
||||
return (
|
||||
status === 404 ||
|
||||
codeLower.includes("not_found") ||
|
||||
codeLower.includes("user_not_found") ||
|
||||
codeLower.includes("does_not_exist") ||
|
||||
codeLower.includes("not exist")
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
return await trySignin();
|
||||
} catch (err) {
|
||||
if (!isUserNotFound(err)) throw err;
|
||||
return await trySignup();
|
||||
}
|
||||
};
|
||||
// src/services/api/authService.ts
|
||||
import { VerifyUserPayload } from "@/record-management/services/api/authService";
|
||||
import axiosInstance from "./axiosInstance";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axios from "axios";
|
||||
|
||||
export interface SignupPayload {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
userType: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
userName?: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthHeaders {
|
||||
tenantKey: string;
|
||||
unitId?: string;
|
||||
}
|
||||
|
||||
export interface SetPasswordPayload {
|
||||
userId?: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
verificationCode: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
export interface SetFayidaPasswordPayload {
|
||||
userId: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
export interface ChangePasswordPayload {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
export interface UpdateProfilePayload {
|
||||
email: string;
|
||||
username: string;
|
||||
phoneNumber: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
}
|
||||
export interface OtpVerificationPayload {
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
export interface GenerateVerifcationCodePayload {
|
||||
phoneNumber: string;
|
||||
email?: string;
|
||||
verificationCode?: string;
|
||||
}
|
||||
|
||||
export enum EOtpType {
|
||||
SET_PASSWORD = "set-password",
|
||||
RESET_PASSWORD = "reset-password",
|
||||
VERIFY_PHONE_NUMBER = "verify-phone-number",
|
||||
}
|
||||
|
||||
export const signup = async (payload: SignupPayload, headers: AuthHeaders) => {
|
||||
return axiosInstance.post("/auth/signup", payload, {
|
||||
headers: {
|
||||
"x-organization-tenant-key": headers.tenantKey,
|
||||
...(headers.unitId && { "x-organization-unit-id": headers.unitId }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const login = async (payload: LoginPayload) => {
|
||||
// Validate that at least one identifier is provided
|
||||
if (!payload.email && !payload.phoneNumber && !payload.userName) {
|
||||
throw new Error(
|
||||
"At least one login identifier (email, phoneNumber, or userName) is required",
|
||||
);
|
||||
}
|
||||
|
||||
return axiosInstance.post("/auth/login", payload);
|
||||
};
|
||||
|
||||
export const getProfile = async (
|
||||
config?: Parameters<typeof axiosInstance.get>[1],
|
||||
) => {
|
||||
return axiosInstance.get("/auth/me", {
|
||||
...config,
|
||||
headers: {
|
||||
...withHeaders(),
|
||||
...(config?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
export const updateProfile = async (payload: UpdateProfilePayload) => {
|
||||
return axiosInstance.patch("/auth/update-profile", payload);
|
||||
};
|
||||
|
||||
export const setPassword = async (payload: SetPasswordPayload) => {
|
||||
return axiosInstance.patch("/auth/set-password", payload);
|
||||
};
|
||||
export const setFayidaPass = async (payload: SetFayidaPasswordPayload) => {
|
||||
return axiosInstance.patch("/auth/set-fayda-password", payload);
|
||||
};
|
||||
export const changePassword = async (payload: ChangePasswordPayload) => {
|
||||
return axiosInstance.patch("/auth/change-password", payload);
|
||||
};
|
||||
export const resendVerificationCode = async (
|
||||
payload: GenerateVerifcationCodePayload,
|
||||
) => {
|
||||
const payloadObj = {
|
||||
...payload,
|
||||
type: EOtpType.SET_PASSWORD,
|
||||
};
|
||||
return axiosInstance.patch("/auth/generate-verification-code", payloadObj);
|
||||
};
|
||||
export const verifyOTPCode = async (
|
||||
payload: GenerateVerifcationCodePayload,
|
||||
) => {
|
||||
return axiosInstance.patch("/auth/verify-phone-number", payload);
|
||||
};
|
||||
export const resendOtpCode = async (
|
||||
payload: GenerateVerifcationCodePayload,
|
||||
) => {
|
||||
const payloadObj = {
|
||||
...payload,
|
||||
type: EOtpType.VERIFY_PHONE_NUMBER,
|
||||
};
|
||||
return axiosInstance.patch("/auth/generate-verification-code", payloadObj);
|
||||
};
|
||||
|
||||
export const refreshAuthToken = async (payload: { refreshToken: string }) => {
|
||||
return axiosInstance.post("/auth/refresh-token", payload);
|
||||
};
|
||||
|
||||
export const requestForgotPassword = async (identifier: string) => {
|
||||
try {
|
||||
// The API expects the identifier in the email field regardless of whether it's an email or phone
|
||||
const payload = { email: identifier };
|
||||
|
||||
const response = await axiosInstance.post("/auth/forgot-password", payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
return axiosInstance.patch("/auth/logout");
|
||||
};
|
||||
|
||||
export const verifyMFAUser = async (payload: VerifyUserPayload) => {
|
||||
return axiosInstance.post("/auth/mfa-verify", payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export interface RegisterWithFaydaPayload {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface RegisterWithFaydaResponse {
|
||||
userId?: string;
|
||||
token?: string;
|
||||
refreshToken?: string;
|
||||
fullName?: string;
|
||||
faydaId?: string;
|
||||
user?: {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
fullName?: string;
|
||||
faydaId?: string;
|
||||
name?: string | { am?: string; en?: string };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
citizen?: {
|
||||
fullName?: string;
|
||||
faydaId?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const signinWithFayda = async (payload: RegisterWithFaydaPayload) => {
|
||||
return axiosInstance.post<RegisterWithFaydaResponse>(
|
||||
"/auth/signin-with-fayda",
|
||||
payload,
|
||||
{ headers: withHeaders() },
|
||||
);
|
||||
};
|
||||
|
||||
export const signupWithFayda = async (payload: RegisterWithFaydaPayload) => {
|
||||
return axiosInstance.post<RegisterWithFaydaResponse>(
|
||||
"/auth/signup-with-fayda",
|
||||
payload,
|
||||
{ headers: withHeaders() },
|
||||
);
|
||||
};
|
||||
|
||||
export const signinWithEtrade = async (payload: EtradeSigninPayload) => {
|
||||
return axiosInstance.post<RegisterWithEtradeResponse>(
|
||||
"/auth/signin-with-etrade",
|
||||
payload,
|
||||
{ headers: withHeaders() },
|
||||
);
|
||||
};
|
||||
|
||||
export const signupWithEtrade = async (payload: EtradeSignupPayload) => {
|
||||
return axiosInstance.post<RegisterWithEtradeResponse>(
|
||||
"/auth/signup-with-etrade",
|
||||
payload,
|
||||
{ headers: withHeaders() },
|
||||
);
|
||||
};
|
||||
|
||||
export const verifyEtradeOtp = async (payload: VerifyEtradeOtpPayload) => {
|
||||
return axiosInstance.post("/auth/verify-etrade-otp", payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const registerWithFayda = async (payload: RegisterWithFaydaPayload) => {
|
||||
const trySignin = () => signinWithFayda(payload);
|
||||
|
||||
const trySignup = () => signupWithFayda(payload);
|
||||
|
||||
const isUserNotFound = (err: unknown) => {
|
||||
if (!axios.isAxiosError(err)) return false;
|
||||
const status = err.response?.status;
|
||||
const data = err.response?.data as any;
|
||||
const code =
|
||||
typeof data?.code === "string"
|
||||
? data.code
|
||||
: typeof data?.errorCode === "string"
|
||||
? data.errorCode
|
||||
: typeof data?.message === "string"
|
||||
? data.message
|
||||
: "";
|
||||
const codeLower = code.toLowerCase();
|
||||
return (
|
||||
status === 404 ||
|
||||
codeLower.includes("not_found") ||
|
||||
codeLower.includes("user_not_found") ||
|
||||
codeLower.includes("does_not_exist") ||
|
||||
codeLower.includes("not exist")
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
return await trySignin();
|
||||
} catch (err) {
|
||||
if (!isUserNotFound(err)) throw err;
|
||||
return await trySignup();
|
||||
}
|
||||
};
|
||||
|
||||
export interface EtradeSignupPayload {
|
||||
tin: string;
|
||||
licenseNumber: string;
|
||||
}
|
||||
|
||||
export interface EtradeSigninPayload {
|
||||
licenseNumber: string;
|
||||
}
|
||||
|
||||
/** @deprecated Use EtradeSigninPayload */
|
||||
export interface RegisterWithEtradePayload {
|
||||
licenseNumber: string;
|
||||
}
|
||||
|
||||
export type RegisterWithEtradeResponse = RegisterWithFaydaResponse;
|
||||
|
||||
export interface VerifyEtradeOtpPayload {
|
||||
licenseNo: string;
|
||||
otp: string;
|
||||
}
|
||||
|
||||
export const registerWithEtrade = async (
|
||||
payload:
|
||||
| EtradeSigninPayload
|
||||
| EtradeSignupPayload
|
||||
| RegisterWithEtradePayload
|
||||
| { tin: string; licenseNo: string }
|
||||
| { licenseNo: string },
|
||||
) => {
|
||||
const resolveLicenseNumber = () => {
|
||||
if ("licenseNumber" in payload) return payload.licenseNumber;
|
||||
if ("licenseNo" in payload) return payload.licenseNo;
|
||||
return payload.licenseNumber;
|
||||
};
|
||||
|
||||
const trySignin = () =>
|
||||
signinWithEtrade({ licenseNumber: resolveLicenseNumber() });
|
||||
|
||||
const trySignup = () => {
|
||||
if ("tin" in payload && ("licenseNumber" in payload || "licenseNo" in payload)) {
|
||||
return signupWithEtrade({
|
||||
tin: payload.tin,
|
||||
licenseNumber: resolveLicenseNumber(),
|
||||
});
|
||||
}
|
||||
return signupWithEtrade({
|
||||
tin: "",
|
||||
licenseNumber: resolveLicenseNumber(),
|
||||
});
|
||||
};
|
||||
|
||||
const isUserNotFound = (err: unknown) => {
|
||||
if (!axios.isAxiosError(err)) return false;
|
||||
const status = err.response?.status;
|
||||
const data = err.response?.data as any;
|
||||
const code =
|
||||
typeof data?.code === "string"
|
||||
? data.code
|
||||
: typeof data?.errorCode === "string"
|
||||
? data.errorCode
|
||||
: typeof data?.message === "string"
|
||||
? data.message
|
||||
: "";
|
||||
const codeLower = code.toLowerCase();
|
||||
return (
|
||||
status === 404 ||
|
||||
codeLower.includes("not_found") ||
|
||||
codeLower.includes("user_not_found") ||
|
||||
codeLower.includes("does_not_exist") ||
|
||||
codeLower.includes("not exist")
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
return await trySignin();
|
||||
} catch (err) {
|
||||
if (!isUserNotFound(err)) throw err;
|
||||
return await trySignup();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
// src/services/api/axiosInstance.ts
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
import { getRefreshToken } from "../utils/refreshTokenHandler";
|
||||
import {
|
||||
getRememberMePreference,
|
||||
setAuthCookies,
|
||||
} from "../utils/authPersistence";
|
||||
import { handleSessionExpiry } from "./sessionExpiry";
|
||||
|
||||
if (!import.meta.env.VITE_API_URL) {
|
||||
console.warn("Missing VITE_API_URL — IAM axios instance has no base URL");
|
||||
}
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: getEnvUrl("VITE_API_URL"),
|
||||
});
|
||||
|
||||
// Attach auth token and CSRF defence header to every request
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const token = Cookies.get("auth-token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
// X-Requested-With prevents CSRF via browser-native form/fetch without custom headers
|
||||
config.headers["X-Requested-With"] = "XMLHttpRequest";
|
||||
return config;
|
||||
});
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) prom.reject(error);
|
||||
else prom.resolve(token);
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
const skipRefreshUrls = [
|
||||
"/auth/login",
|
||||
"/auth/refresh",
|
||||
"/auth/refresh-token",
|
||||
"auth/me",
|
||||
];
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// 🔒 Skip refresh logic for specific URLs
|
||||
const shouldSkip = skipRefreshUrls.some((url) =>
|
||||
originalRequest.url?.includes(url),
|
||||
);
|
||||
|
||||
if (shouldSkip) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const rememberMe = getRememberMePreference();
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers["Authorization"] = "Bearer " + token;
|
||||
return axiosInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const refreshToken = Cookies.get("refresh-token");
|
||||
if (!refreshToken) {
|
||||
throw new Error("missing-refresh-token");
|
||||
}
|
||||
|
||||
const newToken = await getRefreshToken(refreshToken);
|
||||
const updatedToken = newToken.data.token;
|
||||
const updatedRefreshToken = newToken.data.refreshToken;
|
||||
processQueue(null, newToken.data.token);
|
||||
setAuthCookies({
|
||||
token: updatedToken,
|
||||
refreshToken: updatedRefreshToken,
|
||||
rememberMe,
|
||||
});
|
||||
originalRequest.headers["Authorization"] = `Bearer ${updatedToken}`;
|
||||
return axiosInstance(originalRequest);
|
||||
} catch (err) {
|
||||
processQueue(err, null);
|
||||
handleSessionExpiry();
|
||||
return Promise.reject(err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default axiosInstance;
|
||||
// src/services/api/axiosInstance.ts
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
import { getRefreshToken } from "../utils/refreshTokenHandler";
|
||||
import {
|
||||
getRememberMePreference,
|
||||
setAuthCookies,
|
||||
} from "../utils/authPersistence";
|
||||
import { handleSessionExpiry } from "./sessionExpiry";
|
||||
|
||||
if (!import.meta.env.VITE_API_URL) {
|
||||
console.warn("Missing VITE_API_URL — IAM axios instance has no base URL");
|
||||
}
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: getEnvUrl("VITE_API_URL"),
|
||||
});
|
||||
|
||||
// Attach auth token and CSRF defence header to every request
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const token = Cookies.get("auth-token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
// X-Requested-With prevents CSRF via browser-native form/fetch without custom headers
|
||||
config.headers["X-Requested-With"] = "XMLHttpRequest";
|
||||
return config;
|
||||
});
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) prom.reject(error);
|
||||
else prom.resolve(token);
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
const skipRefreshUrls = [
|
||||
"/auth/login",
|
||||
"/auth/refresh",
|
||||
"/auth/refresh-token",
|
||||
"auth/me",
|
||||
];
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// 🔒 Skip refresh logic for specific URLs
|
||||
const shouldSkip = skipRefreshUrls.some((url) =>
|
||||
originalRequest.url?.includes(url),
|
||||
);
|
||||
|
||||
if (shouldSkip) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const rememberMe = getRememberMePreference();
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers["Authorization"] = "Bearer " + token;
|
||||
return axiosInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const refreshToken = Cookies.get("refresh-token");
|
||||
if (!refreshToken) {
|
||||
throw new Error("missing-refresh-token");
|
||||
}
|
||||
|
||||
const newToken = await getRefreshToken(refreshToken);
|
||||
const updatedToken = newToken.data.token;
|
||||
const updatedRefreshToken = newToken.data.refreshToken;
|
||||
processQueue(null, newToken.data.token);
|
||||
setAuthCookies({
|
||||
token: updatedToken,
|
||||
refreshToken: updatedRefreshToken,
|
||||
rememberMe,
|
||||
});
|
||||
originalRequest.headers["Authorization"] = `Bearer ${updatedToken}`;
|
||||
return axiosInstance(originalRequest);
|
||||
} catch (err) {
|
||||
processQueue(err, null);
|
||||
handleSessionExpiry();
|
||||
return Promise.reject(err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default axiosInstance;
|
||||
|
||||
@@ -1,110 +1,110 @@
|
||||
// src/services/api/axiosInstance.ts
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
import { getRefreshToken } from "../utils/refreshTokenHandler";
|
||||
import {
|
||||
getRememberMePreference,
|
||||
setAuthCookies,
|
||||
} from "../utils/authPersistence";
|
||||
import { handleSessionExpiry } from "./sessionExpiry";
|
||||
|
||||
if (!import.meta.env.VITE_CHRONICLE_URL) {
|
||||
console.warn("Missing VITE_CHRONICLE_URL — chronicle axios instance has no base URL");
|
||||
}
|
||||
|
||||
const chronicleBaseUrl =
|
||||
getEnvUrl("VITE_CHRONICLE_URL", false) ||
|
||||
getEnvUrl("VITE_AUDITLOG_API_URL", false);
|
||||
|
||||
if (!chronicleBaseUrl) {
|
||||
console.warn("Missing VITE_CHRONICLE_URL and VITE_AUDITLOG_API_URL");
|
||||
}
|
||||
|
||||
const chronicleInstance = axios.create({
|
||||
baseURL: chronicleBaseUrl,
|
||||
});
|
||||
|
||||
// Attach auth token and CSRF defence header to every request
|
||||
chronicleInstance.interceptors.request.use((config) => {
|
||||
const token = Cookies.get("auth-token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
config.headers["X-Requested-With"] = "XMLHttpRequest";
|
||||
return config;
|
||||
});
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) prom.reject(error);
|
||||
else prom.resolve(token);
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
const skipRefreshUrls = ["/auth/login", "/auth/refresh", "/auth/me"]; // Add all URLs to skip
|
||||
|
||||
chronicleInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// 🔒 Skip refresh logic for specific URLs
|
||||
const shouldSkip = skipRefreshUrls.some((url) =>
|
||||
originalRequest.url?.includes(url),
|
||||
);
|
||||
|
||||
if (shouldSkip) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers["Authorization"] = "Bearer " + token;
|
||||
return chronicleInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const refreshToken = Cookies.get("refresh-token");
|
||||
if (!refreshToken) {
|
||||
throw new Error("missing-refresh-token");
|
||||
}
|
||||
const rememberMe = getRememberMePreference();
|
||||
|
||||
const newToken = await getRefreshToken(refreshToken);
|
||||
const updatedToken = newToken.data.token;
|
||||
const updatedRefreshToken = newToken.data.refreshToken;
|
||||
processQueue(null, newToken.data.token);
|
||||
setAuthCookies({
|
||||
token: updatedToken,
|
||||
refreshToken: updatedRefreshToken,
|
||||
rememberMe,
|
||||
});
|
||||
originalRequest.headers["Authorization"] = `Bearer ${updatedToken}`;
|
||||
return chronicleInstance(originalRequest);
|
||||
} catch (err) {
|
||||
processQueue(err, null);
|
||||
handleSessionExpiry();
|
||||
return Promise.reject(err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default chronicleInstance;
|
||||
// src/services/api/axiosInstance.ts
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
import { getRefreshToken } from "../utils/refreshTokenHandler";
|
||||
import {
|
||||
getRememberMePreference,
|
||||
setAuthCookies,
|
||||
} from "../utils/authPersistence";
|
||||
import { handleSessionExpiry } from "./sessionExpiry";
|
||||
|
||||
if (!import.meta.env.VITE_CHRONICLE_URL) {
|
||||
console.warn("Missing VITE_CHRONICLE_URL — chronicle axios instance has no base URL");
|
||||
}
|
||||
|
||||
const chronicleBaseUrl =
|
||||
getEnvUrl("VITE_CHRONICLE_URL", false) ||
|
||||
getEnvUrl("VITE_AUDITLOG_API_URL", false);
|
||||
|
||||
if (!chronicleBaseUrl) {
|
||||
console.warn("Missing VITE_CHRONICLE_URL and VITE_AUDITLOG_API_URL");
|
||||
}
|
||||
|
||||
const chronicleInstance = axios.create({
|
||||
baseURL: chronicleBaseUrl,
|
||||
});
|
||||
|
||||
// Attach auth token and CSRF defence header to every request
|
||||
chronicleInstance.interceptors.request.use((config) => {
|
||||
const token = Cookies.get("auth-token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
config.headers["X-Requested-With"] = "XMLHttpRequest";
|
||||
return config;
|
||||
});
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) prom.reject(error);
|
||||
else prom.resolve(token);
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
const skipRefreshUrls = ["/auth/login", "/auth/refresh", "/auth/me"]; // Add all URLs to skip
|
||||
|
||||
chronicleInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// 🔒 Skip refresh logic for specific URLs
|
||||
const shouldSkip = skipRefreshUrls.some((url) =>
|
||||
originalRequest.url?.includes(url),
|
||||
);
|
||||
|
||||
if (shouldSkip) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers["Authorization"] = "Bearer " + token;
|
||||
return chronicleInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const refreshToken = Cookies.get("refresh-token");
|
||||
if (!refreshToken) {
|
||||
throw new Error("missing-refresh-token");
|
||||
}
|
||||
const rememberMe = getRememberMePreference();
|
||||
|
||||
const newToken = await getRefreshToken(refreshToken);
|
||||
const updatedToken = newToken.data.token;
|
||||
const updatedRefreshToken = newToken.data.refreshToken;
|
||||
processQueue(null, newToken.data.token);
|
||||
setAuthCookies({
|
||||
token: updatedToken,
|
||||
refreshToken: updatedRefreshToken,
|
||||
rememberMe,
|
||||
});
|
||||
originalRequest.headers["Authorization"] = `Bearer ${updatedToken}`;
|
||||
return chronicleInstance(originalRequest);
|
||||
} catch (err) {
|
||||
processQueue(err, null);
|
||||
handleSessionExpiry();
|
||||
return Promise.reject(err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default chronicleInstance;
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import recordAxiosInstance from "./recordAxiosInstance";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export interface FileInfo {
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
originalname: string;
|
||||
}
|
||||
|
||||
export interface ComplaintPayload {
|
||||
fullName: string;
|
||||
phoneNumber: string;
|
||||
subCity: string;
|
||||
woreda: string;
|
||||
houseNumber: string;
|
||||
institution: string;
|
||||
complaintPlace: string;
|
||||
complaintDetail: string;
|
||||
desiredResolution: string;
|
||||
fileInfo?: FileInfo;
|
||||
}
|
||||
|
||||
export interface ComplaintResponse {
|
||||
id: string;
|
||||
complaintNumber: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Submit customer complaint
|
||||
export const submitComplaint = async (
|
||||
payload: ComplaintPayload
|
||||
): Promise<AxiosResponse<ComplaintResponse>> => {
|
||||
return recordAxiosInstance.post("/customer-complaints", payload);
|
||||
};
|
||||
|
||||
// Get complaint by ID (for tracking)
|
||||
export const getComplaintById = async (
|
||||
id: string
|
||||
): Promise<AxiosResponse<ComplaintResponse>> => {
|
||||
return recordAxiosInstance.get(`/customer-complaints/${id}`);
|
||||
};
|
||||
import recordAxiosInstance from "./recordAxiosInstance";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export interface FileInfo {
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
originalname: string;
|
||||
}
|
||||
|
||||
export interface ComplaintPayload {
|
||||
fullName: string;
|
||||
phoneNumber: string;
|
||||
subCity: string;
|
||||
woreda: string;
|
||||
houseNumber: string;
|
||||
institution: string;
|
||||
complaintPlace: string;
|
||||
complaintDetail: string;
|
||||
desiredResolution: string;
|
||||
fileInfo?: FileInfo;
|
||||
}
|
||||
|
||||
export interface ComplaintResponse {
|
||||
id: string;
|
||||
complaintNumber: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Submit customer complaint
|
||||
export const submitComplaint = async (
|
||||
payload: ComplaintPayload
|
||||
): Promise<AxiosResponse<ComplaintResponse>> => {
|
||||
return recordAxiosInstance.post("/customer-complaints", payload);
|
||||
};
|
||||
|
||||
// Get complaint by ID (for tracking)
|
||||
export const getComplaintById = async (
|
||||
id: string
|
||||
): Promise<AxiosResponse<ComplaintResponse>> => {
|
||||
return recordAxiosInstance.get(`/customer-complaints/${id}`);
|
||||
};
|
||||
|
||||
@@ -1,245 +1,245 @@
|
||||
import axiosInstance from "./axiosInstance";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
export interface ExportUserDataResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export const exportService = {
|
||||
/**
|
||||
* Get units list for a specific organization, including child units
|
||||
* @param organizationId - The organization ID to get units for
|
||||
* @returns Promise with units list (flattened, including all children)
|
||||
*/
|
||||
getUnitsList: async (organizationId: string) => {
|
||||
try {
|
||||
// Recursive function to flatten unit hierarchy
|
||||
const flattenUnits = async (units: any[]): Promise<any[]> => {
|
||||
const result: any[] = [];
|
||||
|
||||
for (const unit of units) {
|
||||
result.push(unit);
|
||||
|
||||
// Try to fetch child units for this unit
|
||||
try {
|
||||
const childResponse = await axiosInstance.get(
|
||||
`/units/child-units/${unit.id}`,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
params: {
|
||||
take: 1000,
|
||||
skip: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (childResponse.data?.items && childResponse.data.items.length > 0) {
|
||||
const flattenedChildren = await flattenUnits(childResponse.data.items);
|
||||
result.push(...flattenedChildren);
|
||||
}
|
||||
} catch (error) {
|
||||
// If child units fail to load, continue with next unit
|
||||
console.warn(`Failed to fetch child units for ${unit.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// First, get the top-level units for the organization
|
||||
const response = await axiosInstance.get(
|
||||
`/units/list/${organizationId}`,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
params: {
|
||||
take: 1000,
|
||||
skip: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Then flatten the hierarchy to include all child units
|
||||
const allUnits = await flattenUnits(response.data?.items || []);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...response.data,
|
||||
items: allUnits,
|
||||
},
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to fetch units:", error);
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
(error as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message || "Failed to fetch units",
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Export user data for a specific unit
|
||||
* @param unitId - The unit ID to export data for
|
||||
* @param format - Export format ('xlsx' or 'csv')
|
||||
* @returns Promise with export response
|
||||
*/
|
||||
exportUserData: async (
|
||||
unitId: string,
|
||||
format: "xlsx" | "csv" = "xlsx",
|
||||
): Promise<ExportUserDataResponse> => {
|
||||
try {
|
||||
const response = await axiosInstance.get(
|
||||
`/employees/export-user-data/${unitId}`,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
responseType: "json", // Changed from blob to json
|
||||
},
|
||||
);
|
||||
|
||||
// Convert JSON data to XLSX
|
||||
const jsonData = response.data;
|
||||
|
||||
// Create a new workbook
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
// Handle different data structures
|
||||
if (Array.isArray(jsonData)) {
|
||||
// If it's an array, convert it to a worksheet
|
||||
const worksheet = XLSX.utils.json_to_sheet(jsonData);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
|
||||
} else if (jsonData && typeof jsonData === "object") {
|
||||
// If it's an object, check for positions and users arrays
|
||||
if (jsonData.positions && Array.isArray(jsonData.positions)) {
|
||||
const positionsWorksheet = XLSX.utils.json_to_sheet(
|
||||
jsonData.positions,
|
||||
);
|
||||
XLSX.utils.book_append_sheet(
|
||||
workbook,
|
||||
positionsWorksheet,
|
||||
"Positions",
|
||||
);
|
||||
}
|
||||
|
||||
if (jsonData.users && Array.isArray(jsonData.users)) {
|
||||
const usersWorksheet = XLSX.utils.json_to_sheet(jsonData.users);
|
||||
XLSX.utils.book_append_sheet(workbook, usersWorksheet, "Users");
|
||||
}
|
||||
|
||||
// If no positions or users found, try to find any array property
|
||||
if (!jsonData.positions && !jsonData.users) {
|
||||
const dataArray = Object.values(jsonData).find((value) =>
|
||||
Array.isArray(value),
|
||||
);
|
||||
if (dataArray) {
|
||||
const worksheet = XLSX.utils.json_to_sheet(dataArray);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
|
||||
} else {
|
||||
// If no array found, convert the object itself
|
||||
const worksheet = XLSX.utils.json_to_sheet([jsonData]);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If it's not an array or object, create a simple worksheet
|
||||
const worksheet = XLSX.utils.json_to_sheet([{ data: jsonData }]);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
|
||||
}
|
||||
|
||||
// Generate file as buffer based on format
|
||||
let fileBuffer: ArrayBuffer;
|
||||
let mimeType: string;
|
||||
let fileExtension: string;
|
||||
|
||||
if (format === "csv") {
|
||||
// For CSV, we need to combine all data into a single sheet
|
||||
let csvString = "";
|
||||
|
||||
if (
|
||||
jsonData &&
|
||||
typeof jsonData === "object" &&
|
||||
(jsonData.positions || jsonData.users)
|
||||
) {
|
||||
// Combine positions and users data for CSV
|
||||
const allData: Array<Record<string, unknown> & { dataType: string }> =
|
||||
[];
|
||||
|
||||
if (jsonData.positions && Array.isArray(jsonData.positions)) {
|
||||
jsonData.positions.forEach((item: Record<string, unknown>) => {
|
||||
allData.push({ ...item, dataType: "Position" });
|
||||
});
|
||||
}
|
||||
|
||||
if (jsonData.users && Array.isArray(jsonData.users)) {
|
||||
jsonData.users.forEach((item: Record<string, unknown>) => {
|
||||
allData.push({ ...item, dataType: "User" });
|
||||
});
|
||||
}
|
||||
|
||||
if (allData.length > 0) {
|
||||
const combinedWorksheet = XLSX.utils.json_to_sheet(allData);
|
||||
csvString = XLSX.utils.sheet_to_csv(combinedWorksheet);
|
||||
}
|
||||
} else {
|
||||
// Fallback to first worksheet
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
csvString = XLSX.utils.sheet_to_csv(worksheet);
|
||||
}
|
||||
|
||||
fileBuffer = new TextEncoder().encode(csvString);
|
||||
mimeType = "text/csv";
|
||||
fileExtension = "csv";
|
||||
} else {
|
||||
// For XLSX
|
||||
fileBuffer = XLSX.write(workbook, {
|
||||
bookType: "xlsx",
|
||||
type: "array",
|
||||
});
|
||||
mimeType =
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
fileExtension = "xlsx";
|
||||
}
|
||||
|
||||
// Create a blob from the file buffer
|
||||
const blob = new Blob([fileBuffer], {
|
||||
type: mimeType,
|
||||
});
|
||||
|
||||
// Create a download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
|
||||
// Set filename with timestamp
|
||||
const timestamp = new Date().toISOString().split("T")[0];
|
||||
const filename = `user-data-export-${timestamp}.${fileExtension}`;
|
||||
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Export completed successfully",
|
||||
data: jsonData,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("Export failed:", error);
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
(error as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message || "Export failed. Please try again.",
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
import axiosInstance from "./axiosInstance";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
export interface ExportUserDataResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export const exportService = {
|
||||
/**
|
||||
* Get units list for a specific organization, including child units
|
||||
* @param organizationId - The organization ID to get units for
|
||||
* @returns Promise with units list (flattened, including all children)
|
||||
*/
|
||||
getUnitsList: async (organizationId: string) => {
|
||||
try {
|
||||
// Recursive function to flatten unit hierarchy
|
||||
const flattenUnits = async (units: any[]): Promise<any[]> => {
|
||||
const result: any[] = [];
|
||||
|
||||
for (const unit of units) {
|
||||
result.push(unit);
|
||||
|
||||
// Try to fetch child units for this unit
|
||||
try {
|
||||
const childResponse = await axiosInstance.get(
|
||||
`/units/child-units/${unit.id}`,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
params: {
|
||||
take: 1000,
|
||||
skip: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (childResponse.data?.items && childResponse.data.items.length > 0) {
|
||||
const flattenedChildren = await flattenUnits(childResponse.data.items);
|
||||
result.push(...flattenedChildren);
|
||||
}
|
||||
} catch (error) {
|
||||
// If child units fail to load, continue with next unit
|
||||
console.warn(`Failed to fetch child units for ${unit.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// First, get the top-level units for the organization
|
||||
const response = await axiosInstance.get(
|
||||
`/units/list/${organizationId}`,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
params: {
|
||||
take: 1000,
|
||||
skip: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Then flatten the hierarchy to include all child units
|
||||
const allUnits = await flattenUnits(response.data?.items || []);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...response.data,
|
||||
items: allUnits,
|
||||
},
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to fetch units:", error);
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
(error as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message || "Failed to fetch units",
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Export user data for a specific unit
|
||||
* @param unitId - The unit ID to export data for
|
||||
* @param format - Export format ('xlsx' or 'csv')
|
||||
* @returns Promise with export response
|
||||
*/
|
||||
exportUserData: async (
|
||||
unitId: string,
|
||||
format: "xlsx" | "csv" = "xlsx",
|
||||
): Promise<ExportUserDataResponse> => {
|
||||
try {
|
||||
const response = await axiosInstance.get(
|
||||
`/employees/export-user-data/${unitId}`,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
responseType: "json", // Changed from blob to json
|
||||
},
|
||||
);
|
||||
|
||||
// Convert JSON data to XLSX
|
||||
const jsonData = response.data;
|
||||
|
||||
// Create a new workbook
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
// Handle different data structures
|
||||
if (Array.isArray(jsonData)) {
|
||||
// If it's an array, convert it to a worksheet
|
||||
const worksheet = XLSX.utils.json_to_sheet(jsonData);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
|
||||
} else if (jsonData && typeof jsonData === "object") {
|
||||
// If it's an object, check for positions and users arrays
|
||||
if (jsonData.positions && Array.isArray(jsonData.positions)) {
|
||||
const positionsWorksheet = XLSX.utils.json_to_sheet(
|
||||
jsonData.positions,
|
||||
);
|
||||
XLSX.utils.book_append_sheet(
|
||||
workbook,
|
||||
positionsWorksheet,
|
||||
"Positions",
|
||||
);
|
||||
}
|
||||
|
||||
if (jsonData.users && Array.isArray(jsonData.users)) {
|
||||
const usersWorksheet = XLSX.utils.json_to_sheet(jsonData.users);
|
||||
XLSX.utils.book_append_sheet(workbook, usersWorksheet, "Users");
|
||||
}
|
||||
|
||||
// If no positions or users found, try to find any array property
|
||||
if (!jsonData.positions && !jsonData.users) {
|
||||
const dataArray = Object.values(jsonData).find((value) =>
|
||||
Array.isArray(value),
|
||||
);
|
||||
if (dataArray) {
|
||||
const worksheet = XLSX.utils.json_to_sheet(dataArray);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
|
||||
} else {
|
||||
// If no array found, convert the object itself
|
||||
const worksheet = XLSX.utils.json_to_sheet([jsonData]);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If it's not an array or object, create a simple worksheet
|
||||
const worksheet = XLSX.utils.json_to_sheet([{ data: jsonData }]);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
|
||||
}
|
||||
|
||||
// Generate file as buffer based on format
|
||||
let fileBuffer: ArrayBuffer;
|
||||
let mimeType: string;
|
||||
let fileExtension: string;
|
||||
|
||||
if (format === "csv") {
|
||||
// For CSV, we need to combine all data into a single sheet
|
||||
let csvString = "";
|
||||
|
||||
if (
|
||||
jsonData &&
|
||||
typeof jsonData === "object" &&
|
||||
(jsonData.positions || jsonData.users)
|
||||
) {
|
||||
// Combine positions and users data for CSV
|
||||
const allData: Array<Record<string, unknown> & { dataType: string }> =
|
||||
[];
|
||||
|
||||
if (jsonData.positions && Array.isArray(jsonData.positions)) {
|
||||
jsonData.positions.forEach((item: Record<string, unknown>) => {
|
||||
allData.push({ ...item, dataType: "Position" });
|
||||
});
|
||||
}
|
||||
|
||||
if (jsonData.users && Array.isArray(jsonData.users)) {
|
||||
jsonData.users.forEach((item: Record<string, unknown>) => {
|
||||
allData.push({ ...item, dataType: "User" });
|
||||
});
|
||||
}
|
||||
|
||||
if (allData.length > 0) {
|
||||
const combinedWorksheet = XLSX.utils.json_to_sheet(allData);
|
||||
csvString = XLSX.utils.sheet_to_csv(combinedWorksheet);
|
||||
}
|
||||
} else {
|
||||
// Fallback to first worksheet
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
csvString = XLSX.utils.sheet_to_csv(worksheet);
|
||||
}
|
||||
|
||||
fileBuffer = new TextEncoder().encode(csvString);
|
||||
mimeType = "text/csv";
|
||||
fileExtension = "csv";
|
||||
} else {
|
||||
// For XLSX
|
||||
fileBuffer = XLSX.write(workbook, {
|
||||
bookType: "xlsx",
|
||||
type: "array",
|
||||
});
|
||||
mimeType =
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
fileExtension = "xlsx";
|
||||
}
|
||||
|
||||
// Create a blob from the file buffer
|
||||
const blob = new Blob([fileBuffer], {
|
||||
type: mimeType,
|
||||
});
|
||||
|
||||
// Create a download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
|
||||
// Set filename with timestamp
|
||||
const timestamp = new Date().toISOString().split("T")[0];
|
||||
const filename = `user-data-export-${timestamp}.${fileExtension}`;
|
||||
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Export completed successfully",
|
||||
data: jsonData,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("Export failed:", error);
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
(error as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message || "Export failed. Please try again.",
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,190 +1,190 @@
|
||||
import { AxiosInstance } from "axios";
|
||||
import { presignedAxios } from "./presignedAxios";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import { XSSUploadValidator, XSSUploadValidationContext } from "./validation";
|
||||
|
||||
/**
|
||||
* NOTE: File validation happens on the server-side.
|
||||
* The browser uses XSSUploadValidator for early feedback to users.
|
||||
* The server uses BufferContentValidator with file-type library for actual validation.
|
||||
*/
|
||||
|
||||
export const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
const isValidUUID = (id: string): boolean => UUID_RE.test(id);
|
||||
type UUID = string;
|
||||
|
||||
// ---- UploadContext ----
|
||||
export interface UploadContext<TBody> {
|
||||
endpoint: string;
|
||||
buildBody: (file: File, fileName: string, parentId: UUID) => TBody;
|
||||
allowedMimeTypes?: string[];
|
||||
allowedExtensions?: string[];
|
||||
maxSizeMB?: number;
|
||||
uploadContext?:
|
||||
| "record"
|
||||
| "external-portal"
|
||||
| "incoming"
|
||||
| "outgoing"
|
||||
| "signature";
|
||||
module?: "record-management" | "user-management" | "external-portal";
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file metadata and get presigned URL
|
||||
* Runs client-side XSS validation before requesting presigned URL
|
||||
* Server-side validation happens after file is uploaded
|
||||
*/
|
||||
export async function uploadMeta<TBody>(
|
||||
ctx: UploadContext<TBody>,
|
||||
file: File,
|
||||
fileName: string,
|
||||
parentId: UUID,
|
||||
axiosInstance: AxiosInstance,
|
||||
) {
|
||||
if (!isValidUUID(parentId)) throw new Error("Invalid UUID provided");
|
||||
|
||||
// Run client-side XSS validation before requesting presigned URL
|
||||
const validationContext: XSSUploadValidationContext = {
|
||||
allowedMimeTypes: ctx.allowedMimeTypes,
|
||||
allowedExtensions: ctx.allowedExtensions,
|
||||
maxSizeMB: ctx.maxSizeMB,
|
||||
uploadContext: ctx.uploadContext || "record",
|
||||
};
|
||||
|
||||
const validationResult = await XSSUploadValidator.validateFileForUpload(
|
||||
file,
|
||||
validationContext,
|
||||
);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(
|
||||
validationResult.clientValidation.fileName?.error ||
|
||||
validationResult.clientValidation.extension?.error ||
|
||||
validationResult.clientValidation.mimeType?.error ||
|
||||
validationResult.clientValidation.size?.error ||
|
||||
"File validation failed",
|
||||
);
|
||||
}
|
||||
|
||||
const body = ctx.buildBody(file, fileName, parentId);
|
||||
const { data } = await axiosInstance.post(ctx.endpoint, body, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export const getFile = async (
|
||||
endpoint: string,
|
||||
id: string,
|
||||
axiosInstance: AxiosInstance,
|
||||
) => {
|
||||
try {
|
||||
const response = await axiosInstance.get(`${endpoint}/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch file:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload file to presigned URL
|
||||
* Server-side validation happens after upload
|
||||
*/
|
||||
export const uploadFile = async (file: File, uploadUrl: string) => {
|
||||
if (!uploadUrl) throw new Error("Upload URL is undefined.");
|
||||
|
||||
const uploadRes = await presignedAxios.put(uploadUrl, file, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
|
||||
// Validate response status for presigned uploads
|
||||
if (uploadRes.status < 200 || uploadRes.status >= 300) {
|
||||
throw new Error(
|
||||
`Upload failed with status ${uploadRes.status}: ${uploadRes.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return uploadRes;
|
||||
};
|
||||
|
||||
export async function updateStatus(
|
||||
endpoint: string,
|
||||
fileId: UUID,
|
||||
parentId: UUID,
|
||||
uploadedSuccessfully: boolean,
|
||||
axiosInstance: AxiosInstance,
|
||||
validationContext?: XSSUploadValidationContext,
|
||||
) {
|
||||
if (!isValidUUID(fileId) || !isValidUUID(parentId))
|
||||
throw new Error("Invalid UUID provided");
|
||||
|
||||
// Prepare status update payload
|
||||
const statusPayload: any = {
|
||||
id: fileId,
|
||||
parentId,
|
||||
uploadedSuccessfully,
|
||||
};
|
||||
|
||||
// Include validation context if provided (for server-side validation)
|
||||
if (validationContext) {
|
||||
statusPayload.validationContext = validationContext;
|
||||
}
|
||||
|
||||
// Temporarily disabled upload-status request.
|
||||
// Re-enable when backend upload-status sync is needed again.
|
||||
// const { data } = await axiosInstance.patch(
|
||||
// `${endpoint}/${fileId}/upload-status`,
|
||||
// statusPayload,
|
||||
// { headers: withHeaders() }
|
||||
// );
|
||||
// return data;
|
||||
return statusPayload;
|
||||
}
|
||||
|
||||
export async function updateIncomingRecordStatus(
|
||||
endpoint: string,
|
||||
fileId: UUID,
|
||||
uploadedSuccessfully: boolean,
|
||||
axiosInstance: AxiosInstance,
|
||||
) {
|
||||
if (!isValidUUID(fileId)) throw new Error("Invalid UUID provided");
|
||||
|
||||
// Temporarily disabled upload-status request.
|
||||
// Re-enable when backend upload-status sync is needed again.
|
||||
// const { data } = await axiosInstance.patch(
|
||||
// `${endpoint}/upload-status`,
|
||||
// { recordId: fileId, uploadedSuccessfully },
|
||||
// { headers: withHeaders() }
|
||||
// );
|
||||
// return data;
|
||||
return { recordId: fileId, uploadedSuccessfully };
|
||||
}
|
||||
|
||||
export async function deleteFile(
|
||||
endpoint: string,
|
||||
fileId: UUID,
|
||||
axiosInstance: AxiosInstance,
|
||||
parentId?: UUID, // optional, in case backend needs it
|
||||
) {
|
||||
if (!isValidUUID(fileId)) throw new Error("Invalid fileId provided");
|
||||
if (parentId && !isValidUUID(parentId))
|
||||
throw new Error("Invalid parentId provided");
|
||||
|
||||
// Construct URL (add parentId query if needed)
|
||||
const url =
|
||||
parentId !== undefined
|
||||
? `${endpoint}/${fileId}?parentId=${parentId}`
|
||||
: `${endpoint}/${fileId}`;
|
||||
|
||||
const { data } = await axiosInstance.delete(url, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
import { AxiosInstance } from "axios";
|
||||
import { presignedAxios } from "./presignedAxios";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import { XSSUploadValidator, XSSUploadValidationContext } from "./validation";
|
||||
|
||||
/**
|
||||
* NOTE: File validation happens on the server-side.
|
||||
* The browser uses XSSUploadValidator for early feedback to users.
|
||||
* The server uses BufferContentValidator with file-type library for actual validation.
|
||||
*/
|
||||
|
||||
export const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
const isValidUUID = (id: string): boolean => UUID_RE.test(id);
|
||||
type UUID = string;
|
||||
|
||||
// ---- UploadContext ----
|
||||
export interface UploadContext<TBody> {
|
||||
endpoint: string;
|
||||
buildBody: (file: File, fileName: string, parentId: UUID) => TBody;
|
||||
allowedMimeTypes?: string[];
|
||||
allowedExtensions?: string[];
|
||||
maxSizeMB?: number;
|
||||
uploadContext?:
|
||||
| "record"
|
||||
| "external-portal"
|
||||
| "incoming"
|
||||
| "outgoing"
|
||||
| "signature";
|
||||
module?: "record-management" | "user-management" | "external-portal";
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file metadata and get presigned URL
|
||||
* Runs client-side XSS validation before requesting presigned URL
|
||||
* Server-side validation happens after file is uploaded
|
||||
*/
|
||||
export async function uploadMeta<TBody>(
|
||||
ctx: UploadContext<TBody>,
|
||||
file: File,
|
||||
fileName: string,
|
||||
parentId: UUID,
|
||||
axiosInstance: AxiosInstance,
|
||||
) {
|
||||
if (!isValidUUID(parentId)) throw new Error("Invalid UUID provided");
|
||||
|
||||
// Run client-side XSS validation before requesting presigned URL
|
||||
const validationContext: XSSUploadValidationContext = {
|
||||
allowedMimeTypes: ctx.allowedMimeTypes,
|
||||
allowedExtensions: ctx.allowedExtensions,
|
||||
maxSizeMB: ctx.maxSizeMB,
|
||||
uploadContext: ctx.uploadContext || "record",
|
||||
};
|
||||
|
||||
const validationResult = await XSSUploadValidator.validateFileForUpload(
|
||||
file,
|
||||
validationContext,
|
||||
);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(
|
||||
validationResult.clientValidation.fileName?.error ||
|
||||
validationResult.clientValidation.extension?.error ||
|
||||
validationResult.clientValidation.mimeType?.error ||
|
||||
validationResult.clientValidation.size?.error ||
|
||||
"File validation failed",
|
||||
);
|
||||
}
|
||||
|
||||
const body = ctx.buildBody(file, fileName, parentId);
|
||||
const { data } = await axiosInstance.post(ctx.endpoint, body, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export const getFile = async (
|
||||
endpoint: string,
|
||||
id: string,
|
||||
axiosInstance: AxiosInstance,
|
||||
) => {
|
||||
try {
|
||||
const response = await axiosInstance.get(`${endpoint}/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch file:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload file to presigned URL
|
||||
* Server-side validation happens after upload
|
||||
*/
|
||||
export const uploadFile = async (file: File, uploadUrl: string) => {
|
||||
if (!uploadUrl) throw new Error("Upload URL is undefined.");
|
||||
|
||||
const uploadRes = await presignedAxios.put(uploadUrl, file, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
|
||||
// Validate response status for presigned uploads
|
||||
if (uploadRes.status < 200 || uploadRes.status >= 300) {
|
||||
throw new Error(
|
||||
`Upload failed with status ${uploadRes.status}: ${uploadRes.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return uploadRes;
|
||||
};
|
||||
|
||||
export async function updateStatus(
|
||||
endpoint: string,
|
||||
fileId: UUID,
|
||||
parentId: UUID,
|
||||
uploadedSuccessfully: boolean,
|
||||
axiosInstance: AxiosInstance,
|
||||
validationContext?: XSSUploadValidationContext,
|
||||
) {
|
||||
if (!isValidUUID(fileId) || !isValidUUID(parentId))
|
||||
throw new Error("Invalid UUID provided");
|
||||
|
||||
// Prepare status update payload
|
||||
const statusPayload: any = {
|
||||
id: fileId,
|
||||
parentId,
|
||||
uploadedSuccessfully,
|
||||
};
|
||||
|
||||
// Include validation context if provided (for server-side validation)
|
||||
if (validationContext) {
|
||||
statusPayload.validationContext = validationContext;
|
||||
}
|
||||
|
||||
// Temporarily disabled upload-status request.
|
||||
// Re-enable when backend upload-status sync is needed again.
|
||||
// const { data } = await axiosInstance.patch(
|
||||
// `${endpoint}/${fileId}/upload-status`,
|
||||
// statusPayload,
|
||||
// { headers: withHeaders() }
|
||||
// );
|
||||
// return data;
|
||||
return statusPayload;
|
||||
}
|
||||
|
||||
export async function updateIncomingRecordStatus(
|
||||
endpoint: string,
|
||||
fileId: UUID,
|
||||
uploadedSuccessfully: boolean,
|
||||
axiosInstance: AxiosInstance,
|
||||
) {
|
||||
if (!isValidUUID(fileId)) throw new Error("Invalid UUID provided");
|
||||
|
||||
// Temporarily disabled upload-status request.
|
||||
// Re-enable when backend upload-status sync is needed again.
|
||||
// const { data } = await axiosInstance.patch(
|
||||
// `${endpoint}/upload-status`,
|
||||
// { recordId: fileId, uploadedSuccessfully },
|
||||
// { headers: withHeaders() }
|
||||
// );
|
||||
// return data;
|
||||
return { recordId: fileId, uploadedSuccessfully };
|
||||
}
|
||||
|
||||
export async function deleteFile(
|
||||
endpoint: string,
|
||||
fileId: UUID,
|
||||
axiosInstance: AxiosInstance,
|
||||
parentId?: UUID, // optional, in case backend needs it
|
||||
) {
|
||||
if (!isValidUUID(fileId)) throw new Error("Invalid fileId provided");
|
||||
if (parentId && !isValidUUID(parentId))
|
||||
throw new Error("Invalid parentId provided");
|
||||
|
||||
// Construct URL (add parentId query if needed)
|
||||
const url =
|
||||
parentId !== undefined
|
||||
? `${endpoint}/${fileId}?parentId=${parentId}`
|
||||
: `${endpoint}/${fileId}`;
|
||||
|
||||
const { data } = await axiosInstance.delete(url, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
export type TokenPair = { accessToken: string; refreshToken?: string }
|
||||
|
||||
export type RefreshFn = (refreshToken: string) => Promise<TokenPair>
|
||||
|
||||
export type CreateHttpClientOptions = {
|
||||
baseURL: string
|
||||
cookieKeys: any
|
||||
refreshFn: RefreshFn
|
||||
|
||||
skipRefreshPaths?: string[]
|
||||
|
||||
onAuthFailure?: (reason: unknown) => void
|
||||
}
|
||||
|
||||
type RetryableRequestConfig = InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
function isAxiosError(e: unknown): e is AxiosError {
|
||||
return axios.isAxiosError(e)
|
||||
}
|
||||
|
||||
export function createHttpClient(opts: CreateHttpClientOptions): AxiosInstance {
|
||||
const client = axios.create({ baseURL: opts.baseURL })
|
||||
|
||||
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = Cookies.get(opts.cookieKeys.access)
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
let refreshing: Promise<string> | null = null
|
||||
|
||||
const shouldSkipRefresh = (url?: string) => {
|
||||
if (!url) return false
|
||||
const list = opts.skipRefreshPaths ?? []
|
||||
return list.some((p) => url.includes(p))
|
||||
}
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (err: unknown) => {
|
||||
if (!isAxiosError(err)) return Promise.reject(err)
|
||||
|
||||
const status = err.response?.status
|
||||
const original = err.config as RetryableRequestConfig | undefined
|
||||
|
||||
if (!original) return Promise.reject(err)
|
||||
|
||||
if (shouldSkipRefresh(original.url)) return Promise.reject(err)
|
||||
|
||||
if (status !== 401 || original._retry) return Promise.reject(err)
|
||||
|
||||
original._retry = true
|
||||
|
||||
const refreshToken = Cookies.get(opts.cookieKeys.refresh)
|
||||
if (!refreshToken) {
|
||||
opts.onAuthFailure?.(err)
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
if (!refreshing) {
|
||||
refreshing = (async () => {
|
||||
const pair = await opts.refreshFn(refreshToken)
|
||||
Cookies.set(opts.cookieKeys.access, pair.accessToken)
|
||||
if (pair.refreshToken)
|
||||
Cookies.set(opts.cookieKeys.refresh, pair.refreshToken)
|
||||
return pair.accessToken
|
||||
})().finally(() => {
|
||||
refreshing = null
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const newAccess = await refreshing
|
||||
original.headers = original.headers ?? {}
|
||||
original.headers.Authorization = `Bearer ${newAccess}`
|
||||
return client(original as AxiosRequestConfig)
|
||||
} catch (refreshErr) {
|
||||
Cookies.remove(opts.cookieKeys.access)
|
||||
Cookies.remove(opts.cookieKeys.refresh)
|
||||
if (opts.cookieKeys.user) Cookies.remove(opts.cookieKeys.user)
|
||||
|
||||
opts.onAuthFailure?.(refreshErr)
|
||||
return Promise.reject(refreshErr)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return client
|
||||
}
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
export type TokenPair = { accessToken: string; refreshToken?: string }
|
||||
|
||||
export type RefreshFn = (refreshToken: string) => Promise<TokenPair>
|
||||
|
||||
export type CreateHttpClientOptions = {
|
||||
baseURL: string
|
||||
cookieKeys: any
|
||||
refreshFn: RefreshFn
|
||||
|
||||
skipRefreshPaths?: string[]
|
||||
|
||||
onAuthFailure?: (reason: unknown) => void
|
||||
}
|
||||
|
||||
type RetryableRequestConfig = InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
function isAxiosError(e: unknown): e is AxiosError {
|
||||
return axios.isAxiosError(e)
|
||||
}
|
||||
|
||||
export function createHttpClient(opts: CreateHttpClientOptions): AxiosInstance {
|
||||
const client = axios.create({ baseURL: opts.baseURL })
|
||||
|
||||
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = Cookies.get(opts.cookieKeys.access)
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
let refreshing: Promise<string> | null = null
|
||||
|
||||
const shouldSkipRefresh = (url?: string) => {
|
||||
if (!url) return false
|
||||
const list = opts.skipRefreshPaths ?? []
|
||||
return list.some((p) => url.includes(p))
|
||||
}
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (err: unknown) => {
|
||||
if (!isAxiosError(err)) return Promise.reject(err)
|
||||
|
||||
const status = err.response?.status
|
||||
const original = err.config as RetryableRequestConfig | undefined
|
||||
|
||||
if (!original) return Promise.reject(err)
|
||||
|
||||
if (shouldSkipRefresh(original.url)) return Promise.reject(err)
|
||||
|
||||
if (status !== 401 || original._retry) return Promise.reject(err)
|
||||
|
||||
original._retry = true
|
||||
|
||||
const refreshToken = Cookies.get(opts.cookieKeys.refresh)
|
||||
if (!refreshToken) {
|
||||
opts.onAuthFailure?.(err)
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
if (!refreshing) {
|
||||
refreshing = (async () => {
|
||||
const pair = await opts.refreshFn(refreshToken)
|
||||
Cookies.set(opts.cookieKeys.access, pair.accessToken)
|
||||
if (pair.refreshToken)
|
||||
Cookies.set(opts.cookieKeys.refresh, pair.refreshToken)
|
||||
return pair.accessToken
|
||||
})().finally(() => {
|
||||
refreshing = null
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const newAccess = await refreshing
|
||||
original.headers = original.headers ?? {}
|
||||
original.headers.Authorization = `Bearer ${newAccess}`
|
||||
return client(original as AxiosRequestConfig)
|
||||
} catch (refreshErr) {
|
||||
Cookies.remove(opts.cookieKeys.access)
|
||||
Cookies.remove(opts.cookieKeys.refresh)
|
||||
if (opts.cookieKeys.user) Cookies.remove(opts.cookieKeys.user)
|
||||
|
||||
opts.onAuthFailure?.(refreshErr)
|
||||
return Promise.reject(refreshErr)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import { AxiosResponse } from "axios";
|
||||
import recordAxiosInstance from "./recordAxiosInstance";
|
||||
|
||||
interface params {
|
||||
take: number;
|
||||
skip: number;
|
||||
orderBy?:string;
|
||||
}
|
||||
|
||||
export const getAllNotification =async(
|
||||
params: params
|
||||
):Promise<AxiosResponse>=>{
|
||||
return recordAxiosInstance.get("/notifications", {
|
||||
params: params,
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
export const getUnSeenNotification = async(
|
||||
params: params
|
||||
):Promise<AxiosResponse>=>{
|
||||
return recordAxiosInstance.get("/notifications/unseen", {
|
||||
params: params,
|
||||
headers: withHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
export const readNotification = (
|
||||
id:string
|
||||
):Promise<AxiosResponse>=>{
|
||||
return recordAxiosInstance.post(
|
||||
`/notifications/${id}/read`,
|
||||
{},
|
||||
{ headers: withHeaders() }
|
||||
);
|
||||
}
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import { AxiosResponse } from "axios";
|
||||
import recordAxiosInstance from "./recordAxiosInstance";
|
||||
|
||||
interface params {
|
||||
take: number;
|
||||
skip: number;
|
||||
orderBy?:string;
|
||||
}
|
||||
|
||||
export const getAllNotification =async(
|
||||
params: params
|
||||
):Promise<AxiosResponse>=>{
|
||||
return recordAxiosInstance.get("/notifications", {
|
||||
params: params,
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
export const getUnSeenNotification = async(
|
||||
params: params
|
||||
):Promise<AxiosResponse>=>{
|
||||
return recordAxiosInstance.get("/notifications/unseen", {
|
||||
params: params,
|
||||
headers: withHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
export const readNotification = (
|
||||
id:string
|
||||
):Promise<AxiosResponse>=>{
|
||||
return recordAxiosInstance.post(
|
||||
`/notifications/${id}/read`,
|
||||
{},
|
||||
{ headers: withHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,114 +1,114 @@
|
||||
// src/services/api/.ts
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "sonner";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
import { getRefreshToken } from "../utils/refreshTokenHandler";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
|
||||
const objectiveInstance = axios.create({
|
||||
baseURL: getEnvUrl("VITE_OBJECTIVE_API_URL"),
|
||||
});
|
||||
|
||||
// Add auth token and acting-as position context to every OKR request
|
||||
objectiveInstance.interceptors.request.use((config) => {
|
||||
const token = Cookies.get("auth-token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const contextHeaders = withHeaders();
|
||||
Object.entries(contextHeaders).forEach(([key, value]) => {
|
||||
config.headers[key] = value;
|
||||
});
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) prom.reject(error);
|
||||
else prom.resolve(token);
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
const skipRefreshUrls = ["/auth/login", "/auth/refresh", "auth/me"]; // Add all URLs to skip
|
||||
|
||||
objectiveInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// 🔒 Skip refresh logic for specific URLs
|
||||
const shouldSkip = skipRefreshUrls.some((url) =>
|
||||
originalRequest.url?.includes(url),
|
||||
);
|
||||
|
||||
if (shouldSkip) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const rememberMe = JSON.parse(
|
||||
localStorage.getItem("rememberMe") || "false",
|
||||
);
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers["Authorization"] = "Bearer " + token;
|
||||
return objectiveInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const refreshToken = Cookies.get("refresh-token") as string;
|
||||
|
||||
const newToken = await getRefreshToken(refreshToken);
|
||||
const updatedToken = newToken.data.token;
|
||||
const updatedRefreshToken = newToken.data.refreshToken;
|
||||
processQueue(null, newToken.data.token);
|
||||
Cookies.set("auth-token", updatedToken);
|
||||
Cookies.set("refresh-token", updatedRefreshToken);
|
||||
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
|
||||
return objectiveInstance(originalRequest);
|
||||
} catch (err) {
|
||||
processQueue(err, null);
|
||||
if (rememberMe) {
|
||||
toast.error("Session expired", {
|
||||
description: "Please log in again.",
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
Cookies.remove("auth-token");
|
||||
Cookies.remove("refresh-token");
|
||||
Cookies.remove("auth-user");
|
||||
|
||||
toast.error("Session expired", {
|
||||
description: "Please log in again.",
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = "/";
|
||||
}, 3000);
|
||||
|
||||
return Promise.reject(err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default objectiveInstance;
|
||||
// src/services/api/.ts
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "sonner";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
import { getRefreshToken } from "../utils/refreshTokenHandler";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
|
||||
const objectiveInstance = axios.create({
|
||||
baseURL: getEnvUrl("VITE_OBJECTIVE_API_URL"),
|
||||
});
|
||||
|
||||
// Add auth token and acting-as position context to every OKR request
|
||||
objectiveInstance.interceptors.request.use((config) => {
|
||||
const token = Cookies.get("auth-token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const contextHeaders = withHeaders();
|
||||
Object.entries(contextHeaders).forEach(([key, value]) => {
|
||||
config.headers[key] = value;
|
||||
});
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) prom.reject(error);
|
||||
else prom.resolve(token);
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
const skipRefreshUrls = ["/auth/login", "/auth/refresh", "auth/me"]; // Add all URLs to skip
|
||||
|
||||
objectiveInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// 🔒 Skip refresh logic for specific URLs
|
||||
const shouldSkip = skipRefreshUrls.some((url) =>
|
||||
originalRequest.url?.includes(url),
|
||||
);
|
||||
|
||||
if (shouldSkip) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const rememberMe = JSON.parse(
|
||||
localStorage.getItem("rememberMe") || "false",
|
||||
);
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers["Authorization"] = "Bearer " + token;
|
||||
return objectiveInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const refreshToken = Cookies.get("refresh-token") as string;
|
||||
|
||||
const newToken = await getRefreshToken(refreshToken);
|
||||
const updatedToken = newToken.data.token;
|
||||
const updatedRefreshToken = newToken.data.refreshToken;
|
||||
processQueue(null, newToken.data.token);
|
||||
Cookies.set("auth-token", updatedToken);
|
||||
Cookies.set("refresh-token", updatedRefreshToken);
|
||||
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
|
||||
return objectiveInstance(originalRequest);
|
||||
} catch (err) {
|
||||
processQueue(err, null);
|
||||
if (rememberMe) {
|
||||
toast.error("Session expired", {
|
||||
description: "Please log in again.",
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
Cookies.remove("auth-token");
|
||||
Cookies.remove("refresh-token");
|
||||
Cookies.remove("auth-user");
|
||||
|
||||
toast.error("Session expired", {
|
||||
description: "Please log in again.",
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = "/";
|
||||
}, 3000);
|
||||
|
||||
return Promise.reject(err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default objectiveInstance;
|
||||
|
||||
@@ -1,160 +1,160 @@
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axiosInstance from "./axiosInstance";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export interface OrganizationConfig {
|
||||
id: string;
|
||||
canStartReceivingRecord: boolean;
|
||||
canCreateBranchByItself?: boolean;
|
||||
maximumNumberOfUnits?: number;
|
||||
organizationId: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface OrganizationConfigPayload {
|
||||
canStartReceivingRecord?: boolean;
|
||||
canCreateBranchByItself?: boolean;
|
||||
maximumNumberOfUnits?: number;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export interface OrganizationConfigListResponse {
|
||||
count: number;
|
||||
items: OrganizationConfig[];
|
||||
}
|
||||
|
||||
export interface GlobalOrgConfig {
|
||||
numberOfSubOrganizations: number;
|
||||
numberOfEmployeesPerOrganization: number;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export interface GlobalUnitConfig {
|
||||
numberOfSubUnits: number;
|
||||
numberOfEmployeesPerUnit: number;
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
// Create organization configuration
|
||||
export const createOrganizationConfig = async (
|
||||
data: OrganizationConfigPayload,
|
||||
): Promise<AxiosResponse<OrganizationConfig>> => {
|
||||
return axiosInstance.post("/organization-configurations", data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Update organization configuration
|
||||
export const updateOrganizationConfig = async (
|
||||
id: string,
|
||||
data: OrganizationConfigPayload,
|
||||
): Promise<AxiosResponse<OrganizationConfig>> => {
|
||||
return axiosInstance.put(`/organization-configurations/${id}`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Get organization configuration by ID
|
||||
export const getOrganizationConfigById = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse<OrganizationConfig>> => {
|
||||
return axiosInstance.get(`/organization-configurations/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Get organization configuration by organization ID
|
||||
export const getOrganizationConfig = async (
|
||||
organizationId: string,
|
||||
): Promise<AxiosResponse<OrganizationConfigListResponse>> => {
|
||||
return axiosInstance.get(
|
||||
`/organization-configurations/list/${organizationId}`,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Delete organization configuration
|
||||
export const deleteOrganizationConfig = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.delete(`/organization-configurations/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const createGlobalOrgConfig = async (
|
||||
data: GlobalOrgConfig,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post(`/organization-global-configurations`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateGlobalOrgConfig = async (
|
||||
id: string,
|
||||
data: GlobalOrgConfig,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.put(`/organization-global-configurations/${id}`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getGlobalOrgConfig = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organization-global-configurations/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getListOfGlobalOrgConfig = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organization-global-configurations/list/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const createGlobalUnitConfig = async (
|
||||
data: GlobalUnitConfig,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post(`/global-unit-configurations`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateGlobalUnitConfig = async (
|
||||
id: string,
|
||||
data: GlobalUnitConfig,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.put(`/global-unit-configurations/${id}`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getGlobalUnitConfig = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/global-unit-configurations/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getListOfGlobalUnitConfig = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/global-unit-configurations/list/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteGlobalUnitConfig = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.delete(`/global-unit-configurations/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axiosInstance from "./axiosInstance";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export interface OrganizationConfig {
|
||||
id: string;
|
||||
canStartReceivingRecord: boolean;
|
||||
canCreateBranchByItself?: boolean;
|
||||
maximumNumberOfUnits?: number;
|
||||
organizationId: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface OrganizationConfigPayload {
|
||||
canStartReceivingRecord?: boolean;
|
||||
canCreateBranchByItself?: boolean;
|
||||
maximumNumberOfUnits?: number;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export interface OrganizationConfigListResponse {
|
||||
count: number;
|
||||
items: OrganizationConfig[];
|
||||
}
|
||||
|
||||
export interface GlobalOrgConfig {
|
||||
numberOfSubOrganizations: number;
|
||||
numberOfEmployeesPerOrganization: number;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export interface GlobalUnitConfig {
|
||||
numberOfSubUnits: number;
|
||||
numberOfEmployeesPerUnit: number;
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
// Create organization configuration
|
||||
export const createOrganizationConfig = async (
|
||||
data: OrganizationConfigPayload,
|
||||
): Promise<AxiosResponse<OrganizationConfig>> => {
|
||||
return axiosInstance.post("/organization-configurations", data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Update organization configuration
|
||||
export const updateOrganizationConfig = async (
|
||||
id: string,
|
||||
data: OrganizationConfigPayload,
|
||||
): Promise<AxiosResponse<OrganizationConfig>> => {
|
||||
return axiosInstance.put(`/organization-configurations/${id}`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Get organization configuration by ID
|
||||
export const getOrganizationConfigById = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse<OrganizationConfig>> => {
|
||||
return axiosInstance.get(`/organization-configurations/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Get organization configuration by organization ID
|
||||
export const getOrganizationConfig = async (
|
||||
organizationId: string,
|
||||
): Promise<AxiosResponse<OrganizationConfigListResponse>> => {
|
||||
return axiosInstance.get(
|
||||
`/organization-configurations/list/${organizationId}`,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Delete organization configuration
|
||||
export const deleteOrganizationConfig = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.delete(`/organization-configurations/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const createGlobalOrgConfig = async (
|
||||
data: GlobalOrgConfig,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post(`/organization-global-configurations`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateGlobalOrgConfig = async (
|
||||
id: string,
|
||||
data: GlobalOrgConfig,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.put(`/organization-global-configurations/${id}`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getGlobalOrgConfig = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organization-global-configurations/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getListOfGlobalOrgConfig = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organization-global-configurations/list/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const createGlobalUnitConfig = async (
|
||||
data: GlobalUnitConfig,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post(`/global-unit-configurations`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateGlobalUnitConfig = async (
|
||||
id: string,
|
||||
data: GlobalUnitConfig,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.put(`/global-unit-configurations/${id}`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getGlobalUnitConfig = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/global-unit-configurations/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getListOfGlobalUnitConfig = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/global-unit-configurations/list/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteGlobalUnitConfig = async (
|
||||
id: string,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.delete(`/global-unit-configurations/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,210 +1,210 @@
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axiosInstance from "./axiosInstance";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
DocumentRequirementDto,
|
||||
ResponseActionDto,
|
||||
} from "../dto/External-Portal/External-PortalDto";
|
||||
|
||||
export interface OrgHeaders {
|
||||
tenantKey: string;
|
||||
unitId?: string;
|
||||
}
|
||||
|
||||
export interface OrganizationPayload {
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
key: string;
|
||||
organizationTypeId: string;
|
||||
parentId?: string;
|
||||
isGovernmentOrganization: boolean;
|
||||
}
|
||||
|
||||
export interface OrgQueryParams {
|
||||
orderBy?: string;
|
||||
take?: number;
|
||||
skip?: number;
|
||||
order?: string;
|
||||
organizationTypeKey?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export enum FilterEnum {
|
||||
EXTERNAL = "organization",
|
||||
INDIVIDUAL = "individual",
|
||||
}
|
||||
|
||||
export const getOrganizations = async (
|
||||
params?: OrgQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get("/organizations/filter", {
|
||||
...{ headers: withHeaders() },
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
export const getMyAdminOrganizations = async (): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get("/organizations/my-admin-organizations", {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
export const getOrganizationsWithAdminFlag = async (
|
||||
params?: OrgQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get("/organizations/with-admin-flag", {
|
||||
...{ headers: withHeaders() },
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
export const getOrganizationById = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/${id}`, { headers: withHeaders() });
|
||||
};
|
||||
|
||||
export const getChildren = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/${id}/children`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getEmployeesUnderOrg = async (
|
||||
id: string | number,
|
||||
params?: OrgQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/current/${id}/employees`, {
|
||||
headers: withHeaders(),
|
||||
params: {
|
||||
take: 1000, // Get a large number of employees by default
|
||||
skip: 0,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getEmployeeCountByOrgId = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
// Try the endpoint from your curl example
|
||||
return axiosInstance.get(`/organizations/${id}/employees/count`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const createOrganization = async (
|
||||
data: OrganizationPayload
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post("/organizations", data, { headers: withHeaders() });
|
||||
};
|
||||
|
||||
export const activateOrganization = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.patch(`/organizations/${id}/activate`, null, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const deActivateOrganization = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.patch(`/organizations/${id}/debar`, null, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateOrganization = async (
|
||||
id: string | number,
|
||||
data: OrganizationPayload
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.put(`/organizations/${id}`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteOrganization = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.delete(`/organizations/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const softDeleteOrganization = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.delete(`/organizations/${id}/soft`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// NOTE: Speculative endpoints — backend not yet implemented. Will 404 until
|
||||
// the API team adds:
|
||||
// GET /organizations/archived
|
||||
// PATCH /organizations/{id}/restore
|
||||
export const getArchivedOrganizations = async (
|
||||
params?: OrgQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/archived`, {
|
||||
headers: withHeaders(),
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreOrganization = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.patch(`/organizations/${id}/restore`, null, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getDocumentRequirements = async (): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/documentary-requirements`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
export const getDocumentRequirementsById = async (
|
||||
id: string
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/documentary-requirements/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
export const getDocumentRequirementsByFilter = async (
|
||||
filter: FilterEnum
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/documentary-requirements/${filter}/type`);
|
||||
};
|
||||
|
||||
export const postDocumentRequirements = async (
|
||||
data: DocumentRequirementDto
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post(`/documentary-requirements`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const giveResponse = async (
|
||||
id: string,
|
||||
data: ResponseActionDto
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post(`user-documents/${id}/response`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getArchivedUserId = async (
|
||||
unitId: string,
|
||||
params?: OrgQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/employees/archived/${unitId}/with-unit`, {
|
||||
headers: withHeaders(),
|
||||
params,
|
||||
});
|
||||
};
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axiosInstance from "./axiosInstance";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
DocumentRequirementDto,
|
||||
ResponseActionDto,
|
||||
} from "../dto/External-Portal/External-PortalDto";
|
||||
|
||||
export interface OrgHeaders {
|
||||
tenantKey: string;
|
||||
unitId?: string;
|
||||
}
|
||||
|
||||
export interface OrganizationPayload {
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
key: string;
|
||||
organizationTypeId: string;
|
||||
parentId?: string;
|
||||
isGovernmentOrganization: boolean;
|
||||
}
|
||||
|
||||
export interface OrgQueryParams {
|
||||
orderBy?: string;
|
||||
take?: number;
|
||||
skip?: number;
|
||||
order?: string;
|
||||
organizationTypeKey?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export enum FilterEnum {
|
||||
EXTERNAL = "organization",
|
||||
INDIVIDUAL = "individual",
|
||||
}
|
||||
|
||||
export const getOrganizations = async (
|
||||
params?: OrgQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get("/organizations/filter", {
|
||||
...{ headers: withHeaders() },
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
export const getMyAdminOrganizations = async (): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get("/organizations/my-admin-organizations", {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
export const getOrganizationsWithAdminFlag = async (
|
||||
params?: OrgQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get("/organizations/with-admin-flag", {
|
||||
...{ headers: withHeaders() },
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
export const getOrganizationById = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/${id}`, { headers: withHeaders() });
|
||||
};
|
||||
|
||||
export const getChildren = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/${id}/children`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getEmployeesUnderOrg = async (
|
||||
id: string | number,
|
||||
params?: OrgQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/current/${id}/employees`, {
|
||||
headers: withHeaders(),
|
||||
params: {
|
||||
take: 1000, // Get a large number of employees by default
|
||||
skip: 0,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getEmployeeCountByOrgId = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
// Try the endpoint from your curl example
|
||||
return axiosInstance.get(`/organizations/${id}/employees/count`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const createOrganization = async (
|
||||
data: OrganizationPayload
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post("/organizations", data, { headers: withHeaders() });
|
||||
};
|
||||
|
||||
export const activateOrganization = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.patch(`/organizations/${id}/activate`, null, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const deActivateOrganization = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.patch(`/organizations/${id}/debar`, null, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateOrganization = async (
|
||||
id: string | number,
|
||||
data: OrganizationPayload
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.put(`/organizations/${id}`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteOrganization = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.delete(`/organizations/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const softDeleteOrganization = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.delete(`/organizations/${id}/soft`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// NOTE: Speculative endpoints — backend not yet implemented. Will 404 until
|
||||
// the API team adds:
|
||||
// GET /organizations/archived
|
||||
// PATCH /organizations/{id}/restore
|
||||
export const getArchivedOrganizations = async (
|
||||
params?: OrgQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/archived`, {
|
||||
headers: withHeaders(),
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreOrganization = async (
|
||||
id: string | number
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.patch(`/organizations/${id}/restore`, null, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getDocumentRequirements = async (): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/documentary-requirements`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
export const getDocumentRequirementsById = async (
|
||||
id: string
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/documentary-requirements/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
export const getDocumentRequirementsByFilter = async (
|
||||
filter: FilterEnum
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/documentary-requirements/${filter}/type`);
|
||||
};
|
||||
|
||||
export const postDocumentRequirements = async (
|
||||
data: DocumentRequirementDto
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post(`/documentary-requirements`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const giveResponse = async (
|
||||
id: string,
|
||||
data: ResponseActionDto
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post(`user-documents/${id}/response`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getArchivedUserId = async (
|
||||
unitId: string,
|
||||
params?: OrgQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/employees/archived/${unitId}/with-unit`, {
|
||||
headers: withHeaders(),
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
// src/services/api/performanceInstance.ts
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "sonner";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
import { getRefreshToken } from "../utils/refreshTokenHandler";
|
||||
|
||||
const performanceInstance = axios.create({
|
||||
baseURL: getEnvUrl("VITE_PERFORMANCE_API_URL"),
|
||||
});
|
||||
|
||||
// Add auth token from localStorage (or context later)
|
||||
performanceInstance.interceptors.request.use((config) => {
|
||||
const token = Cookies.get("auth-token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) prom.reject(error);
|
||||
else prom.resolve(token);
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
const skipRefreshUrls = ["/auth/login", "/auth/refresh", "/auth/me"]; // Add all URLs to skip
|
||||
|
||||
performanceInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// 🔒 Skip refresh logic for specific URLs
|
||||
const shouldSkip = skipRefreshUrls.some((url) =>
|
||||
originalRequest.url?.includes(url),
|
||||
);
|
||||
|
||||
if (shouldSkip) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const rememberMe = JSON.parse(
|
||||
localStorage.getItem("rememberMe") || "false",
|
||||
);
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers["Authorization"] = "Bearer " + token;
|
||||
return performanceInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const refreshToken = Cookies.get("refresh-token") as string;
|
||||
|
||||
const newToken = await getRefreshToken(refreshToken);
|
||||
const updatedToken = newToken.data.token;
|
||||
const updatedRefreshToken = newToken.data.refreshToken;
|
||||
processQueue(null, newToken.data.token);
|
||||
Cookies.set("auth-token", updatedToken);
|
||||
Cookies.set("refresh-token", updatedRefreshToken);
|
||||
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
|
||||
return performanceInstance(originalRequest);
|
||||
} catch (err) {
|
||||
processQueue(err, null);
|
||||
Cookies.remove("auth-token");
|
||||
Cookies.remove("refresh-token");
|
||||
Cookies.remove("auth-user");
|
||||
|
||||
toast.error("Session expired", {
|
||||
description: "Please log in again.",
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = "/";
|
||||
}, 3000);
|
||||
|
||||
return Promise.reject(err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default performanceInstance;
|
||||
// src/services/api/performanceInstance.ts
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "sonner";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
import { getRefreshToken } from "../utils/refreshTokenHandler";
|
||||
|
||||
const performanceInstance = axios.create({
|
||||
baseURL: getEnvUrl("VITE_PERFORMANCE_API_URL"),
|
||||
});
|
||||
|
||||
// Add auth token from localStorage (or context later)
|
||||
performanceInstance.interceptors.request.use((config) => {
|
||||
const token = Cookies.get("auth-token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) prom.reject(error);
|
||||
else prom.resolve(token);
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
const skipRefreshUrls = ["/auth/login", "/auth/refresh", "/auth/me"]; // Add all URLs to skip
|
||||
|
||||
performanceInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// 🔒 Skip refresh logic for specific URLs
|
||||
const shouldSkip = skipRefreshUrls.some((url) =>
|
||||
originalRequest.url?.includes(url),
|
||||
);
|
||||
|
||||
if (shouldSkip) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const rememberMe = JSON.parse(
|
||||
localStorage.getItem("rememberMe") || "false",
|
||||
);
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers["Authorization"] = "Bearer " + token;
|
||||
return performanceInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const refreshToken = Cookies.get("refresh-token") as string;
|
||||
|
||||
const newToken = await getRefreshToken(refreshToken);
|
||||
const updatedToken = newToken.data.token;
|
||||
const updatedRefreshToken = newToken.data.refreshToken;
|
||||
processQueue(null, newToken.data.token);
|
||||
Cookies.set("auth-token", updatedToken);
|
||||
Cookies.set("refresh-token", updatedRefreshToken);
|
||||
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
|
||||
return performanceInstance(originalRequest);
|
||||
} catch (err) {
|
||||
processQueue(err, null);
|
||||
Cookies.remove("auth-token");
|
||||
Cookies.remove("refresh-token");
|
||||
Cookies.remove("auth-user");
|
||||
|
||||
toast.error("Session expired", {
|
||||
description: "Please log in again.",
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = "/";
|
||||
}, 3000);
|
||||
|
||||
return Promise.reject(err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default performanceInstance;
|
||||
|
||||
@@ -1,209 +1,209 @@
|
||||
import axiosInstance from "./axiosInstance";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export interface PositionConfiguration {
|
||||
id?: string;
|
||||
positionId: string;
|
||||
smsNotificationWhenRecordSubmitted: boolean;
|
||||
emailNotificationWhenRecordSubmitted: boolean;
|
||||
inboxNotificationWhenRecordSubmitted: boolean;
|
||||
skipWorkflowIfNotAssigned: boolean;
|
||||
positionScopeToFetch?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
/** Legacy read-only shape; new saves use top-level positionScopeToFetch. */
|
||||
items?: {
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PositionConfigurationPayload {
|
||||
positionId: string;
|
||||
smsNotificationWhenRecordSubmitted: boolean;
|
||||
emailNotificationWhenRecordSubmitted: boolean;
|
||||
inboxNotificationWhenRecordSubmitted: boolean;
|
||||
skipWorkflowIfNotAssigned: boolean;
|
||||
positionScopeToFetch: string;
|
||||
}
|
||||
|
||||
export type CreatePositionConfigurationPayload = PositionConfigurationPayload;
|
||||
export type UpdatePositionConfigurationPayload = PositionConfigurationPayload;
|
||||
|
||||
export const normalizePositionConfigurationResponse = (
|
||||
response: AxiosResponse<unknown>,
|
||||
): PositionConfiguration | null => {
|
||||
const data = response?.data;
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return (data[0] as PositionConfiguration | undefined) ?? null;
|
||||
}
|
||||
|
||||
if (typeof data === "object" && data !== null) {
|
||||
if ("items" in data && Array.isArray((data as { items: unknown }).items)) {
|
||||
const items = (data as { items: PositionConfiguration[] }).items;
|
||||
const match =
|
||||
items.find((item) => item.positionId) ?? items[0];
|
||||
return (match as PositionConfiguration | undefined) ?? null;
|
||||
}
|
||||
|
||||
if ("data" in data && (data as { data: unknown }).data) {
|
||||
const nested = (data as { data: unknown }).data;
|
||||
if (Array.isArray(nested)) {
|
||||
return (nested[0] as PositionConfiguration | undefined) ?? null;
|
||||
}
|
||||
if (typeof nested === "object" && nested !== null && "positionId" in nested) {
|
||||
return nested as PositionConfiguration;
|
||||
}
|
||||
}
|
||||
|
||||
if ("positionId" in data) {
|
||||
return data as PositionConfiguration;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isNotFoundError = (error: unknown) => {
|
||||
const err = error as {
|
||||
response?: { status?: number; data?: { message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const message = err?.response?.data?.message || err?.message || "";
|
||||
return (
|
||||
err?.response?.status === 404 ||
|
||||
(err?.response?.status === 400 && /could not be found|not found/i.test(message))
|
||||
);
|
||||
};
|
||||
|
||||
const extractConfigurationId = (
|
||||
configuration: PositionConfiguration | null,
|
||||
): string | undefined => configuration?.id;
|
||||
|
||||
const extractConfigurationFromList = (
|
||||
response: AxiosResponse<unknown>,
|
||||
positionId: string,
|
||||
): PositionConfiguration | null => {
|
||||
const data = response?.data;
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates: PositionConfiguration[] = [];
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
candidates.push(...(data as PositionConfiguration[]));
|
||||
} else if ("items" in data && Array.isArray((data as { items: unknown }).items)) {
|
||||
candidates.push(...((data as { items: PositionConfiguration[] }).items ?? []));
|
||||
} else if ("data" in data) {
|
||||
const nested = (data as { data: unknown }).data;
|
||||
if (Array.isArray(nested)) {
|
||||
candidates.push(...(nested as PositionConfiguration[]));
|
||||
} else if (nested && typeof nested === "object" && "positionId" in nested) {
|
||||
candidates.push(nested as PositionConfiguration);
|
||||
}
|
||||
} else if ("positionId" in data) {
|
||||
candidates.push(data as PositionConfiguration);
|
||||
}
|
||||
|
||||
return (
|
||||
candidates.find((item) => item.positionId === positionId) ??
|
||||
candidates[0] ??
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
// List configurations for a position.
|
||||
export const getPositionConfigurationList = async (
|
||||
positionId: string,
|
||||
): Promise<AxiosResponse<unknown>> => {
|
||||
return axiosInstance.get(`/position-configurations/list/${positionId}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Get a single configuration by its configuration record id.
|
||||
export const getPositionConfigurationById = async (
|
||||
configurationId: string,
|
||||
): Promise<AxiosResponse<PositionConfiguration>> => {
|
||||
return axiosInstance.get(`/position-configurations/${configurationId}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Load configuration for a position: list first, then fetch the full record by id.
|
||||
export const fetchPositionConfigurationByPositionId = async (
|
||||
positionId: string,
|
||||
): Promise<PositionConfiguration | null> => {
|
||||
try {
|
||||
const listResponse = await getPositionConfigurationList(positionId);
|
||||
const listedConfiguration = extractConfigurationFromList(
|
||||
listResponse,
|
||||
positionId,
|
||||
);
|
||||
|
||||
const configurationId = extractConfigurationId(listedConfiguration);
|
||||
if (!configurationId) {
|
||||
return listedConfiguration;
|
||||
}
|
||||
|
||||
const detailResponse = await getPositionConfigurationById(configurationId);
|
||||
return (
|
||||
normalizePositionConfigurationResponse(detailResponse) ??
|
||||
listedConfiguration
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createPositionConfiguration = async (
|
||||
payload: CreatePositionConfigurationPayload,
|
||||
): Promise<AxiosResponse<PositionConfiguration>> => {
|
||||
return axiosInstance.post("/position-configurations", payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const updatePositionConfiguration = async (
|
||||
configurationId: string,
|
||||
payload: UpdatePositionConfigurationPayload,
|
||||
): Promise<AxiosResponse<PositionConfiguration>> => {
|
||||
return axiosInstance.put(
|
||||
`/position-configurations/${configurationId}`,
|
||||
payload,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Create when no record exists; update by configuration id when it does.
|
||||
export const savePositionConfiguration = async (
|
||||
payload: PositionConfigurationPayload,
|
||||
existingConfigurationId?: string,
|
||||
): Promise<AxiosResponse<PositionConfiguration>> => {
|
||||
if (existingConfigurationId) {
|
||||
return updatePositionConfiguration(existingConfigurationId, payload);
|
||||
}
|
||||
|
||||
return createPositionConfiguration(payload);
|
||||
};
|
||||
|
||||
export const getPositionConfigurationsList = getPositionConfigurationList;
|
||||
|
||||
export const deletePositionConfiguration = async (
|
||||
configurationId: string,
|
||||
): Promise<AxiosResponse<void>> => {
|
||||
return axiosInstance.delete(`/position-configurations/${configurationId}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
import axiosInstance from "./axiosInstance";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export interface PositionConfiguration {
|
||||
id?: string;
|
||||
positionId: string;
|
||||
smsNotificationWhenRecordSubmitted: boolean;
|
||||
emailNotificationWhenRecordSubmitted: boolean;
|
||||
inboxNotificationWhenRecordSubmitted: boolean;
|
||||
skipWorkflowIfNotAssigned: boolean;
|
||||
positionScopeToFetch?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
/** Legacy read-only shape; new saves use top-level positionScopeToFetch. */
|
||||
items?: {
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PositionConfigurationPayload {
|
||||
positionId: string;
|
||||
smsNotificationWhenRecordSubmitted: boolean;
|
||||
emailNotificationWhenRecordSubmitted: boolean;
|
||||
inboxNotificationWhenRecordSubmitted: boolean;
|
||||
skipWorkflowIfNotAssigned: boolean;
|
||||
positionScopeToFetch: string;
|
||||
}
|
||||
|
||||
export type CreatePositionConfigurationPayload = PositionConfigurationPayload;
|
||||
export type UpdatePositionConfigurationPayload = PositionConfigurationPayload;
|
||||
|
||||
export const normalizePositionConfigurationResponse = (
|
||||
response: AxiosResponse<unknown>,
|
||||
): PositionConfiguration | null => {
|
||||
const data = response?.data;
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return (data[0] as PositionConfiguration | undefined) ?? null;
|
||||
}
|
||||
|
||||
if (typeof data === "object" && data !== null) {
|
||||
if ("items" in data && Array.isArray((data as { items: unknown }).items)) {
|
||||
const items = (data as { items: PositionConfiguration[] }).items;
|
||||
const match =
|
||||
items.find((item) => item.positionId) ?? items[0];
|
||||
return (match as PositionConfiguration | undefined) ?? null;
|
||||
}
|
||||
|
||||
if ("data" in data && (data as { data: unknown }).data) {
|
||||
const nested = (data as { data: unknown }).data;
|
||||
if (Array.isArray(nested)) {
|
||||
return (nested[0] as PositionConfiguration | undefined) ?? null;
|
||||
}
|
||||
if (typeof nested === "object" && nested !== null && "positionId" in nested) {
|
||||
return nested as PositionConfiguration;
|
||||
}
|
||||
}
|
||||
|
||||
if ("positionId" in data) {
|
||||
return data as PositionConfiguration;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isNotFoundError = (error: unknown) => {
|
||||
const err = error as {
|
||||
response?: { status?: number; data?: { message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const message = err?.response?.data?.message || err?.message || "";
|
||||
return (
|
||||
err?.response?.status === 404 ||
|
||||
(err?.response?.status === 400 && /could not be found|not found/i.test(message))
|
||||
);
|
||||
};
|
||||
|
||||
const extractConfigurationId = (
|
||||
configuration: PositionConfiguration | null,
|
||||
): string | undefined => configuration?.id;
|
||||
|
||||
const extractConfigurationFromList = (
|
||||
response: AxiosResponse<unknown>,
|
||||
positionId: string,
|
||||
): PositionConfiguration | null => {
|
||||
const data = response?.data;
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates: PositionConfiguration[] = [];
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
candidates.push(...(data as PositionConfiguration[]));
|
||||
} else if ("items" in data && Array.isArray((data as { items: unknown }).items)) {
|
||||
candidates.push(...((data as { items: PositionConfiguration[] }).items ?? []));
|
||||
} else if ("data" in data) {
|
||||
const nested = (data as { data: unknown }).data;
|
||||
if (Array.isArray(nested)) {
|
||||
candidates.push(...(nested as PositionConfiguration[]));
|
||||
} else if (nested && typeof nested === "object" && "positionId" in nested) {
|
||||
candidates.push(nested as PositionConfiguration);
|
||||
}
|
||||
} else if ("positionId" in data) {
|
||||
candidates.push(data as PositionConfiguration);
|
||||
}
|
||||
|
||||
return (
|
||||
candidates.find((item) => item.positionId === positionId) ??
|
||||
candidates[0] ??
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
// List configurations for a position.
|
||||
export const getPositionConfigurationList = async (
|
||||
positionId: string,
|
||||
): Promise<AxiosResponse<unknown>> => {
|
||||
return axiosInstance.get(`/position-configurations/list/${positionId}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Get a single configuration by its configuration record id.
|
||||
export const getPositionConfigurationById = async (
|
||||
configurationId: string,
|
||||
): Promise<AxiosResponse<PositionConfiguration>> => {
|
||||
return axiosInstance.get(`/position-configurations/${configurationId}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Load configuration for a position: list first, then fetch the full record by id.
|
||||
export const fetchPositionConfigurationByPositionId = async (
|
||||
positionId: string,
|
||||
): Promise<PositionConfiguration | null> => {
|
||||
try {
|
||||
const listResponse = await getPositionConfigurationList(positionId);
|
||||
const listedConfiguration = extractConfigurationFromList(
|
||||
listResponse,
|
||||
positionId,
|
||||
);
|
||||
|
||||
const configurationId = extractConfigurationId(listedConfiguration);
|
||||
if (!configurationId) {
|
||||
return listedConfiguration;
|
||||
}
|
||||
|
||||
const detailResponse = await getPositionConfigurationById(configurationId);
|
||||
return (
|
||||
normalizePositionConfigurationResponse(detailResponse) ??
|
||||
listedConfiguration
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createPositionConfiguration = async (
|
||||
payload: CreatePositionConfigurationPayload,
|
||||
): Promise<AxiosResponse<PositionConfiguration>> => {
|
||||
return axiosInstance.post("/position-configurations", payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const updatePositionConfiguration = async (
|
||||
configurationId: string,
|
||||
payload: UpdatePositionConfigurationPayload,
|
||||
): Promise<AxiosResponse<PositionConfiguration>> => {
|
||||
return axiosInstance.put(
|
||||
`/position-configurations/${configurationId}`,
|
||||
payload,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Create when no record exists; update by configuration id when it does.
|
||||
export const savePositionConfiguration = async (
|
||||
payload: PositionConfigurationPayload,
|
||||
existingConfigurationId?: string,
|
||||
): Promise<AxiosResponse<PositionConfiguration>> => {
|
||||
if (existingConfigurationId) {
|
||||
return updatePositionConfiguration(existingConfigurationId, payload);
|
||||
}
|
||||
|
||||
return createPositionConfiguration(payload);
|
||||
};
|
||||
|
||||
export const getPositionConfigurationsList = getPositionConfigurationList;
|
||||
|
||||
export const deletePositionConfiguration = async (
|
||||
configurationId: string,
|
||||
): Promise<AxiosResponse<void>> => {
|
||||
return axiosInstance.delete(`/position-configurations/${configurationId}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
export const presignedAxios = axios.create({
|
||||
headers: {},
|
||||
transformRequest: [(data) => data],
|
||||
validateStatus: (s) => s < 400,
|
||||
});
|
||||
|
||||
presignedAxios.interceptors.request.use((config) => {
|
||||
const token = Cookies.get("auth-token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
export const presignedAxios = axios.create({
|
||||
headers: {},
|
||||
transformRequest: [(data) => data],
|
||||
validateStatus: (s) => s < 400,
|
||||
});
|
||||
|
||||
presignedAxios.interceptors.request.use((config) => {
|
||||
const token = Cookies.get("auth-token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
import { getRefreshToken } from "../utils/refreshTokenHandler";
|
||||
import {
|
||||
getRememberMePreference,
|
||||
setAuthCookies,
|
||||
} from "../utils/authPersistence";
|
||||
import { handleSessionExpiry } from "./sessionExpiry";
|
||||
|
||||
if (!import.meta.env.VITE_RECORD_API_URL) {
|
||||
console.warn(
|
||||
"Missing VITE_RECORD_API_URL — record axios instance has no base URL",
|
||||
);
|
||||
}
|
||||
|
||||
const recordAxiosInstance = axios.create({
|
||||
baseURL: getEnvUrl("VITE_RECORD_API_URL"),
|
||||
});
|
||||
|
||||
// Attach auth token and CSRF defence header to every request
|
||||
recordAxiosInstance.interceptors.request.use((config) => {
|
||||
const token = Cookies.get("auth-token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
config.headers["X-Requested-With"] = "XMLHttpRequest";
|
||||
return config;
|
||||
});
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) prom.reject(error);
|
||||
else prom.resolve(token);
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
const skipRefreshUrls = [
|
||||
"/auth/login",
|
||||
"/auth/refresh",
|
||||
"/auth/refresh-token",
|
||||
"auth/me",
|
||||
];
|
||||
|
||||
recordAxiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// 🔒 Skip refresh logic for specific URLs
|
||||
const shouldSkip = skipRefreshUrls.some((url) =>
|
||||
originalRequest.url?.includes(url),
|
||||
);
|
||||
if (shouldSkip) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers["Authorization"] = "Bearer " + token;
|
||||
return recordAxiosInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = Cookies.get("refresh-token");
|
||||
if (!refreshToken) {
|
||||
throw new Error("missing-refresh-token");
|
||||
}
|
||||
const rememberMe = getRememberMePreference();
|
||||
|
||||
const newToken = await getRefreshToken(refreshToken);
|
||||
const updatedToken = newToken.data.token;
|
||||
const updatedRefreshToken = newToken.data.refreshToken;
|
||||
processQueue(null, newToken.data.token);
|
||||
setAuthCookies({
|
||||
token: updatedToken,
|
||||
refreshToken: updatedRefreshToken,
|
||||
rememberMe,
|
||||
});
|
||||
originalRequest.headers["Authorization"] = `Bearer ${updatedToken}`;
|
||||
return recordAxiosInstance(originalRequest);
|
||||
} catch (err) {
|
||||
processQueue(err, null);
|
||||
handleSessionExpiry();
|
||||
return Promise.reject(err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default recordAxiosInstance;
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
import { getRefreshToken } from "../utils/refreshTokenHandler";
|
||||
import {
|
||||
getRememberMePreference,
|
||||
setAuthCookies,
|
||||
} from "../utils/authPersistence";
|
||||
import { handleSessionExpiry } from "./sessionExpiry";
|
||||
|
||||
if (!import.meta.env.VITE_RECORD_API_URL) {
|
||||
console.warn(
|
||||
"Missing VITE_RECORD_API_URL — record axios instance has no base URL",
|
||||
);
|
||||
}
|
||||
|
||||
const recordAxiosInstance = axios.create({
|
||||
baseURL: getEnvUrl("VITE_RECORD_API_URL"),
|
||||
});
|
||||
|
||||
// Attach auth token and CSRF defence header to every request
|
||||
recordAxiosInstance.interceptors.request.use((config) => {
|
||||
const token = Cookies.get("auth-token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
config.headers["X-Requested-With"] = "XMLHttpRequest";
|
||||
return config;
|
||||
});
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) prom.reject(error);
|
||||
else prom.resolve(token);
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
const skipRefreshUrls = [
|
||||
"/auth/login",
|
||||
"/auth/refresh",
|
||||
"/auth/refresh-token",
|
||||
"auth/me",
|
||||
];
|
||||
|
||||
recordAxiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// 🔒 Skip refresh logic for specific URLs
|
||||
const shouldSkip = skipRefreshUrls.some((url) =>
|
||||
originalRequest.url?.includes(url),
|
||||
);
|
||||
if (shouldSkip) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers["Authorization"] = "Bearer " + token;
|
||||
return recordAxiosInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = Cookies.get("refresh-token");
|
||||
if (!refreshToken) {
|
||||
throw new Error("missing-refresh-token");
|
||||
}
|
||||
const rememberMe = getRememberMePreference();
|
||||
|
||||
const newToken = await getRefreshToken(refreshToken);
|
||||
const updatedToken = newToken.data.token;
|
||||
const updatedRefreshToken = newToken.data.refreshToken;
|
||||
processQueue(null, newToken.data.token);
|
||||
setAuthCookies({
|
||||
token: updatedToken,
|
||||
refreshToken: updatedRefreshToken,
|
||||
rememberMe,
|
||||
});
|
||||
originalRequest.headers["Authorization"] = `Bearer ${updatedToken}`;
|
||||
return recordAxiosInstance(originalRequest);
|
||||
} catch (err) {
|
||||
processQueue(err, null);
|
||||
handleSessionExpiry();
|
||||
return Promise.reject(err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default recordAxiosInstance;
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "sonner";
|
||||
import i18n from "@/i18n";
|
||||
import { clearRememberMePreference } from "../utils/authPersistence";
|
||||
|
||||
const PUBLIC_PATHS = [
|
||||
"/login",
|
||||
"/sign-up",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/set-password",
|
||||
"/verify-otp",
|
||||
"/verification_page",
|
||||
"/callback",
|
||||
"/complaints",
|
||||
"/complaint-form",
|
||||
"/follow-complaint",
|
||||
];
|
||||
|
||||
let isHandlingExpiry = false;
|
||||
|
||||
const isOnPublicPath = (pathname: string): boolean => {
|
||||
if (pathname === "/") return true;
|
||||
return PUBLIC_PATHS.some(
|
||||
(p) => pathname === p || pathname.startsWith(`${p}/`),
|
||||
);
|
||||
};
|
||||
|
||||
export function handleSessionExpiry(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (isHandlingExpiry) return;
|
||||
isHandlingExpiry = true;
|
||||
|
||||
Object.keys(Cookies.get()).forEach((name) => Cookies.remove(name));
|
||||
clearRememberMePreference();
|
||||
|
||||
toast.error(i18n.t("msg.authError"), {
|
||||
description: i18n.t("msg.authErrorDescription"),
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
const { pathname, search } = window.location;
|
||||
if (isOnPublicPath(pathname)) {
|
||||
window.location.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const from = `${pathname}${search}`;
|
||||
window.location.replace(`/login?from=${encodeURIComponent(from)}`);
|
||||
}
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "sonner";
|
||||
import i18n from "@/i18n";
|
||||
import { clearRememberMePreference } from "../utils/authPersistence";
|
||||
|
||||
const PUBLIC_PATHS = [
|
||||
"/login",
|
||||
"/sign-up",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/set-password",
|
||||
"/verify-otp",
|
||||
"/verification_page",
|
||||
"/callback",
|
||||
"/complaints",
|
||||
"/complaint-form",
|
||||
"/follow-complaint",
|
||||
];
|
||||
|
||||
let isHandlingExpiry = false;
|
||||
|
||||
const isOnPublicPath = (pathname: string): boolean => {
|
||||
if (pathname === "/") return true;
|
||||
return PUBLIC_PATHS.some(
|
||||
(p) => pathname === p || pathname.startsWith(`${p}/`),
|
||||
);
|
||||
};
|
||||
|
||||
export function handleSessionExpiry(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (isHandlingExpiry) return;
|
||||
isHandlingExpiry = true;
|
||||
|
||||
Object.keys(Cookies.get()).forEach((name) => Cookies.remove(name));
|
||||
clearRememberMePreference();
|
||||
|
||||
toast.error(i18n.t("msg.authError"), {
|
||||
description: i18n.t("msg.authErrorDescription"),
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
const { pathname, search } = window.location;
|
||||
if (isOnPublicPath(pathname)) {
|
||||
window.location.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const from = `${pathname}${search}`;
|
||||
window.location.replace(`/login?from=${encodeURIComponent(from)}`);
|
||||
}
|
||||
|
||||
@@ -1,167 +1,167 @@
|
||||
import { AxiosInstance } from "axios";
|
||||
import { presignedAxios } from "./presignedAxios";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
|
||||
export interface UploadKeyResponse {
|
||||
uploadKey: string;
|
||||
presignedUrl?: string;
|
||||
presigned?: string;
|
||||
fileInfo: {
|
||||
fileName: string;
|
||||
originalname: string;
|
||||
contentType: string;
|
||||
fileType: string;
|
||||
size: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UploadAndCreateResourceParams {
|
||||
type: "signature" | "stamp" | "header" | "footer";
|
||||
file: File;
|
||||
name: { am: string; en: string };
|
||||
parentId: string;
|
||||
locale?: "am" | "en" | null;
|
||||
}
|
||||
|
||||
const config = {
|
||||
signature: {
|
||||
uploadKey: "/signatures/get-file-upload-key",
|
||||
create: "/signatures",
|
||||
parentField: "employeeId",
|
||||
},
|
||||
stamp: {
|
||||
uploadKey: "/employee-stamps/get-file-upload-key",
|
||||
create: "/employee-stamps",
|
||||
parentField: "employeePositionId",
|
||||
},
|
||||
header: {
|
||||
uploadKey: "/headers/get-file-upload-key",
|
||||
create: "/headers",
|
||||
parentField: "unitId",
|
||||
},
|
||||
footer: {
|
||||
uploadKey: "/footers/get-file-upload-key",
|
||||
create: "/footers",
|
||||
parentField: "unitId",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get upload key and presigned URL for file upload
|
||||
*/
|
||||
export async function getUploadKey(
|
||||
endpoint: string,
|
||||
file: File,
|
||||
axiosInstance: AxiosInstance,
|
||||
positionId?: string[],
|
||||
recordTypeKey?: string | null,
|
||||
): Promise<UploadKeyResponse> {
|
||||
const payload = {
|
||||
fileName: file.name,
|
||||
originalname: file.name,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
size: file.size,
|
||||
...(positionId?.length ? { positionId } : {}),
|
||||
recordTypeKey: recordTypeKey,
|
||||
};
|
||||
|
||||
const { data } = await axiosInstance.post<UploadKeyResponse>(
|
||||
endpoint,
|
||||
payload,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file to presigned URL
|
||||
*/
|
||||
export async function uploadToPresigned(
|
||||
file: File,
|
||||
presignedUrl: string,
|
||||
): Promise<void> {
|
||||
if (!presignedUrl) {
|
||||
throw new Error("Presigned URL is required");
|
||||
}
|
||||
|
||||
const uploadRes = await presignedAxios.put(presignedUrl, file, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (uploadRes.status < 200 || uploadRes.status >= 300) {
|
||||
throw new Error(
|
||||
`Upload failed with status ${uploadRes.status}: ${uploadRes.statusText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified upload and create resource function
|
||||
*/
|
||||
export async function uploadAndCreateResource(
|
||||
params: UploadAndCreateResourceParams,
|
||||
axiosInstance: AxiosInstance,
|
||||
positionId?: string[],
|
||||
recordTypeKey?: string | null,
|
||||
) {
|
||||
const { type, file, name, parentId, locale } = params;
|
||||
const typeConfig = config[type];
|
||||
|
||||
// 1. Get upload key and presigned URL
|
||||
const uploadKeyResponse = await getUploadKey(
|
||||
typeConfig.uploadKey,
|
||||
file,
|
||||
axiosInstance,
|
||||
positionId,
|
||||
recordTypeKey,
|
||||
);
|
||||
|
||||
const presignedUrl =
|
||||
uploadKeyResponse.presignedUrl || uploadKeyResponse.presigned;
|
||||
|
||||
// 2. Upload file to presigned URL
|
||||
await uploadToPresigned(file, presignedUrl || "");
|
||||
|
||||
// 3. For header/footer: done — no separate create POST needed
|
||||
if (type === "header" || type === "footer") {
|
||||
// 4. For signature/stamp: create resource
|
||||
const createPayload = {
|
||||
fileInfo: uploadKeyResponse.fileInfo,
|
||||
name,
|
||||
[typeConfig.parentField]: parentId,
|
||||
positionIds: positionId,
|
||||
isCurrent: true,
|
||||
recordTypeKey: recordTypeKey,
|
||||
};
|
||||
|
||||
const { data } = await axiosInstance.post(
|
||||
typeConfig.create,
|
||||
createPayload,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// 4. For signature/stamp: create resource
|
||||
const createPayload = {
|
||||
fileInfo: uploadKeyResponse.fileInfo,
|
||||
name,
|
||||
[typeConfig.parentField]: parentId,
|
||||
...(type === "signature" ? { isCurrent: true } : {}),
|
||||
...(type === "stamp" && locale !== undefined ? { locale } : {}),
|
||||
};
|
||||
|
||||
const { data } = await axiosInstance.post(typeConfig.create, createPayload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
import { AxiosInstance } from "axios";
|
||||
import { presignedAxios } from "./presignedAxios";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
|
||||
export interface UploadKeyResponse {
|
||||
uploadKey: string;
|
||||
presignedUrl?: string;
|
||||
presigned?: string;
|
||||
fileInfo: {
|
||||
fileName: string;
|
||||
originalname: string;
|
||||
contentType: string;
|
||||
fileType: string;
|
||||
size: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UploadAndCreateResourceParams {
|
||||
type: "signature" | "stamp" | "header" | "footer";
|
||||
file: File;
|
||||
name: { am: string; en: string };
|
||||
parentId: string;
|
||||
locale?: "am" | "en" | null;
|
||||
}
|
||||
|
||||
const config = {
|
||||
signature: {
|
||||
uploadKey: "/signatures/get-file-upload-key",
|
||||
create: "/signatures",
|
||||
parentField: "employeeId",
|
||||
},
|
||||
stamp: {
|
||||
uploadKey: "/employee-stamps/get-file-upload-key",
|
||||
create: "/employee-stamps",
|
||||
parentField: "employeePositionId",
|
||||
},
|
||||
header: {
|
||||
uploadKey: "/headers/get-file-upload-key",
|
||||
create: "/headers",
|
||||
parentField: "unitId",
|
||||
},
|
||||
footer: {
|
||||
uploadKey: "/footers/get-file-upload-key",
|
||||
create: "/footers",
|
||||
parentField: "unitId",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get upload key and presigned URL for file upload
|
||||
*/
|
||||
export async function getUploadKey(
|
||||
endpoint: string,
|
||||
file: File,
|
||||
axiosInstance: AxiosInstance,
|
||||
positionId?: string[],
|
||||
recordTypeKey?: string | null,
|
||||
): Promise<UploadKeyResponse> {
|
||||
const payload = {
|
||||
fileName: file.name,
|
||||
originalname: file.name,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
size: file.size,
|
||||
...(positionId?.length ? { positionId } : {}),
|
||||
recordTypeKey: recordTypeKey,
|
||||
};
|
||||
|
||||
const { data } = await axiosInstance.post<UploadKeyResponse>(
|
||||
endpoint,
|
||||
payload,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file to presigned URL
|
||||
*/
|
||||
export async function uploadToPresigned(
|
||||
file: File,
|
||||
presignedUrl: string,
|
||||
): Promise<void> {
|
||||
if (!presignedUrl) {
|
||||
throw new Error("Presigned URL is required");
|
||||
}
|
||||
|
||||
const uploadRes = await presignedAxios.put(presignedUrl, file, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (uploadRes.status < 200 || uploadRes.status >= 300) {
|
||||
throw new Error(
|
||||
`Upload failed with status ${uploadRes.status}: ${uploadRes.statusText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified upload and create resource function
|
||||
*/
|
||||
export async function uploadAndCreateResource(
|
||||
params: UploadAndCreateResourceParams,
|
||||
axiosInstance: AxiosInstance,
|
||||
positionId?: string[],
|
||||
recordTypeKey?: string | null,
|
||||
) {
|
||||
const { type, file, name, parentId, locale } = params;
|
||||
const typeConfig = config[type];
|
||||
|
||||
// 1. Get upload key and presigned URL
|
||||
const uploadKeyResponse = await getUploadKey(
|
||||
typeConfig.uploadKey,
|
||||
file,
|
||||
axiosInstance,
|
||||
positionId,
|
||||
recordTypeKey,
|
||||
);
|
||||
|
||||
const presignedUrl =
|
||||
uploadKeyResponse.presignedUrl || uploadKeyResponse.presigned;
|
||||
|
||||
// 2. Upload file to presigned URL
|
||||
await uploadToPresigned(file, presignedUrl || "");
|
||||
|
||||
// 3. For header/footer: done — no separate create POST needed
|
||||
if (type === "header" || type === "footer") {
|
||||
// 4. For signature/stamp: create resource
|
||||
const createPayload = {
|
||||
fileInfo: uploadKeyResponse.fileInfo,
|
||||
name,
|
||||
[typeConfig.parentField]: parentId,
|
||||
positionIds: positionId,
|
||||
isCurrent: true,
|
||||
recordTypeKey: recordTypeKey,
|
||||
};
|
||||
|
||||
const { data } = await axiosInstance.post(
|
||||
typeConfig.create,
|
||||
createPayload,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// 4. For signature/stamp: create resource
|
||||
const createPayload = {
|
||||
fileInfo: uploadKeyResponse.fileInfo,
|
||||
name,
|
||||
[typeConfig.parentField]: parentId,
|
||||
...(type === "signature" ? { isCurrent: true } : {}),
|
||||
...(type === "stamp" && locale !== undefined ? { locale } : {}),
|
||||
};
|
||||
|
||||
const { data } = await axiosInstance.post(typeConfig.create, createPayload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,215 +1,215 @@
|
||||
import { AxiosResponse } from "axios";
|
||||
import axiosInstance from "./axiosInstance";
|
||||
|
||||
export type AddReferenceNumberPayload = {
|
||||
referenceNumberPrefix?: { am: string; en: string };
|
||||
externalReferenceNumberPrefix?: { am: string; en: string };
|
||||
internalMemoReferenceNumberPrefix?: { am: string; en: string };
|
||||
};
|
||||
|
||||
export type UpdateInternalMemoPrefixPayload = {
|
||||
referenceNumberPrefix?: { am: string; en: string };
|
||||
externalReferenceNumberPrefix?: { am: string; en: string };
|
||||
internalMemoReferenceNumberPrefix?: { am: string; en: string };
|
||||
};
|
||||
|
||||
export type UpdateExternalReferencePrefixPayload = {
|
||||
referenceNumberPrefix?: { am: string; en: string };
|
||||
externalReferenceNumberPrefix?: { am: string; en: string };
|
||||
internalMemoReferenceNumberPrefix?: { am: string; en: string };
|
||||
};
|
||||
|
||||
export type DeleteReferenceNumberPayload = {
|
||||
recordSequenceTypes: string[];
|
||||
};
|
||||
|
||||
// sequence-count.dto.ts
|
||||
|
||||
export enum SequenceType {
|
||||
EXTERNAL_REFERENCE_NUMBER = "externalReferenceNumberPrefix",
|
||||
INTERNAL_MEMO_REFERENCE_NUMBER = "internalMemoReferenceNumberPrefix",
|
||||
REFERENCE_NUMBER = "referenceNumberPrefix",
|
||||
}
|
||||
|
||||
export interface ReferenceNumbersResponse {
|
||||
id?: string;
|
||||
name: SequenceType;
|
||||
number: {
|
||||
am: string | { am: string };
|
||||
en?: string | { am: string };
|
||||
};
|
||||
sequenceId: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface UnitConfigurationResponse extends ReferenceNumbersResponse {
|
||||
id?: string;
|
||||
unitId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CanReceiveComplaintResponse {
|
||||
canReceiveComplaint: boolean;
|
||||
unitId?: string;
|
||||
unitName?: string;
|
||||
}
|
||||
|
||||
const errorMessage = (error: unknown, fallback: string) => {
|
||||
const err = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
return err?.response?.data?.message || err?.message || fallback;
|
||||
};
|
||||
|
||||
const isUnitNotFoundError = (error: unknown) => {
|
||||
const err = error as {
|
||||
response?: { status?: number; data?: { message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const message = err?.response?.data?.message || err?.message || "";
|
||||
return err?.response?.status === 400 && /unit not found/i.test(message);
|
||||
};
|
||||
|
||||
const createAxiosResponse = <T>(data: T): AxiosResponse<T> => ({
|
||||
data,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
});
|
||||
|
||||
export const unitConfigurationService = {
|
||||
getReferenceNumbers: async (
|
||||
unitId: string,
|
||||
): Promise<AxiosResponse<ReferenceNumbersResponse[]>> => {
|
||||
try {
|
||||
return await axiosInstance.get(
|
||||
`/unit-configurations/${unitId}/reference-number`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnitNotFoundError(error)) {
|
||||
return createAxiosResponse<ReferenceNumbersResponse[]>([]);
|
||||
}
|
||||
throw new Error(errorMessage(error, "Failed to fetch reference numbers"));
|
||||
}
|
||||
},
|
||||
getTagBasedReferences: async (
|
||||
unitId: string,
|
||||
tagId?: string,
|
||||
): Promise<AxiosResponse<ReferenceNumbersResponse[]>> => {
|
||||
try {
|
||||
return await axiosInstance.get(`/record-prefixes/list/${unitId}/`);
|
||||
} catch (error) {
|
||||
if (isUnitNotFoundError(error)) {
|
||||
return createAxiosResponse<ReferenceNumbersResponse[]>([]);
|
||||
}
|
||||
throw new Error(errorMessage(error, "Failed to fetch reference numbers"));
|
||||
}
|
||||
},
|
||||
|
||||
getUnitConfiguration: async (
|
||||
unitId: string,
|
||||
): Promise<AxiosResponse<UnitConfigurationResponse>> => {
|
||||
try {
|
||||
return await axiosInstance.get(`/unit-configurations/${unitId}`);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
errorMessage(error, "Failed to fetch unit configuration"),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
addReferenceNumber: async (
|
||||
unitId: string,
|
||||
payload: AddReferenceNumberPayload,
|
||||
): Promise<AxiosResponse<ReferenceNumbersResponse>> => {
|
||||
try {
|
||||
return await axiosInstance.post(
|
||||
`/unit-configurations/${unitId}/reference-number`,
|
||||
payload,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnitNotFoundError(error)) {
|
||||
await axiosInstance.post(`/unit-configurations`, { unitId });
|
||||
return await axiosInstance.post(
|
||||
`/unit-configurations/${unitId}/reference-number`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
throw new Error(errorMessage(error, "Failed to add reference numbers"));
|
||||
}
|
||||
},
|
||||
|
||||
deleteReferenceNumber: async (
|
||||
id: string,
|
||||
payload: DeleteReferenceNumberPayload,
|
||||
): Promise<AxiosResponse<void>> => {
|
||||
try {
|
||||
return await axiosInstance.delete(`/unit-configurations/${id}/reference-number`, {
|
||||
data: payload,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(errorMessage(error, "Failed to delete reference number"));
|
||||
}
|
||||
},
|
||||
|
||||
updateInternalMemoReferencePrefix: async (
|
||||
unitId: string,
|
||||
payload: UpdateInternalMemoPrefixPayload,
|
||||
): Promise<AxiosResponse<ReferenceNumbersResponse>> => {
|
||||
try {
|
||||
return await axiosInstance.patch(
|
||||
`/unit-configurations/add-internal-memo-reference-number-prefix/${unitId}`,
|
||||
payload,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnitNotFoundError(error)) {
|
||||
await axiosInstance.post(`/unit-configurations`, { unitId });
|
||||
return await axiosInstance.patch(
|
||||
`/unit-configurations/add-internal-memo-reference-number-prefix/${unitId}`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
errorMessage(error, "Failed to update internal memo reference prefix"),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
updateExternalReferencePrefix: async (
|
||||
unitId: string,
|
||||
payload: UpdateExternalReferencePrefixPayload,
|
||||
): Promise<AxiosResponse<ReferenceNumbersResponse>> => {
|
||||
try {
|
||||
return await axiosInstance.patch(
|
||||
`/unit-configurations/add-external-reference-number-prefix/${unitId}`,
|
||||
payload,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnitNotFoundError(error)) {
|
||||
await axiosInstance.post(`/unit-configurations`, { unitId });
|
||||
return await axiosInstance.patch(
|
||||
`/unit-configurations/add-external-reference-number-prefix/${unitId}`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
errorMessage(error, "Failed to update external reference prefix"),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// Whether the given unit can receive complaints.
|
||||
getCanReceiveComplaint: async (
|
||||
unitId: string,
|
||||
): Promise<AxiosResponse<CanReceiveComplaintResponse>> => {
|
||||
if (!unitId?.trim()) {
|
||||
throw new Error("Unit ID is required to check complaint receiving.");
|
||||
}
|
||||
|
||||
return axiosInstance.get(`/unit-configurations/can-receive-complaint`, {
|
||||
params: { unitId },
|
||||
});
|
||||
},
|
||||
};
|
||||
import { AxiosResponse } from "axios";
|
||||
import axiosInstance from "./axiosInstance";
|
||||
|
||||
export type AddReferenceNumberPayload = {
|
||||
referenceNumberPrefix?: { am: string; en: string };
|
||||
externalReferenceNumberPrefix?: { am: string; en: string };
|
||||
internalMemoReferenceNumberPrefix?: { am: string; en: string };
|
||||
};
|
||||
|
||||
export type UpdateInternalMemoPrefixPayload = {
|
||||
referenceNumberPrefix?: { am: string; en: string };
|
||||
externalReferenceNumberPrefix?: { am: string; en: string };
|
||||
internalMemoReferenceNumberPrefix?: { am: string; en: string };
|
||||
};
|
||||
|
||||
export type UpdateExternalReferencePrefixPayload = {
|
||||
referenceNumberPrefix?: { am: string; en: string };
|
||||
externalReferenceNumberPrefix?: { am: string; en: string };
|
||||
internalMemoReferenceNumberPrefix?: { am: string; en: string };
|
||||
};
|
||||
|
||||
export type DeleteReferenceNumberPayload = {
|
||||
recordSequenceTypes: string[];
|
||||
};
|
||||
|
||||
// sequence-count.dto.ts
|
||||
|
||||
export enum SequenceType {
|
||||
EXTERNAL_REFERENCE_NUMBER = "externalReferenceNumberPrefix",
|
||||
INTERNAL_MEMO_REFERENCE_NUMBER = "internalMemoReferenceNumberPrefix",
|
||||
REFERENCE_NUMBER = "referenceNumberPrefix",
|
||||
}
|
||||
|
||||
export interface ReferenceNumbersResponse {
|
||||
id?: string;
|
||||
name: SequenceType;
|
||||
number: {
|
||||
am: string | { am: string };
|
||||
en?: string | { am: string };
|
||||
};
|
||||
sequenceId: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface UnitConfigurationResponse extends ReferenceNumbersResponse {
|
||||
id?: string;
|
||||
unitId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CanReceiveComplaintResponse {
|
||||
canReceiveComplaint: boolean;
|
||||
unitId?: string;
|
||||
unitName?: string;
|
||||
}
|
||||
|
||||
const errorMessage = (error: unknown, fallback: string) => {
|
||||
const err = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
return err?.response?.data?.message || err?.message || fallback;
|
||||
};
|
||||
|
||||
const isUnitNotFoundError = (error: unknown) => {
|
||||
const err = error as {
|
||||
response?: { status?: number; data?: { message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const message = err?.response?.data?.message || err?.message || "";
|
||||
return err?.response?.status === 400 && /unit not found/i.test(message);
|
||||
};
|
||||
|
||||
const createAxiosResponse = <T>(data: T): AxiosResponse<T> => ({
|
||||
data,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
});
|
||||
|
||||
export const unitConfigurationService = {
|
||||
getReferenceNumbers: async (
|
||||
unitId: string,
|
||||
): Promise<AxiosResponse<ReferenceNumbersResponse[]>> => {
|
||||
try {
|
||||
return await axiosInstance.get(
|
||||
`/unit-configurations/${unitId}/reference-number`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnitNotFoundError(error)) {
|
||||
return createAxiosResponse<ReferenceNumbersResponse[]>([]);
|
||||
}
|
||||
throw new Error(errorMessage(error, "Failed to fetch reference numbers"));
|
||||
}
|
||||
},
|
||||
getTagBasedReferences: async (
|
||||
unitId: string,
|
||||
tagId?: string,
|
||||
): Promise<AxiosResponse<ReferenceNumbersResponse[]>> => {
|
||||
try {
|
||||
return await axiosInstance.get(`/record-prefixes/list/${unitId}/`);
|
||||
} catch (error) {
|
||||
if (isUnitNotFoundError(error)) {
|
||||
return createAxiosResponse<ReferenceNumbersResponse[]>([]);
|
||||
}
|
||||
throw new Error(errorMessage(error, "Failed to fetch reference numbers"));
|
||||
}
|
||||
},
|
||||
|
||||
getUnitConfiguration: async (
|
||||
unitId: string,
|
||||
): Promise<AxiosResponse<UnitConfigurationResponse>> => {
|
||||
try {
|
||||
return await axiosInstance.get(`/unit-configurations/${unitId}`);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
errorMessage(error, "Failed to fetch unit configuration"),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
addReferenceNumber: async (
|
||||
unitId: string,
|
||||
payload: AddReferenceNumberPayload,
|
||||
): Promise<AxiosResponse<ReferenceNumbersResponse>> => {
|
||||
try {
|
||||
return await axiosInstance.post(
|
||||
`/unit-configurations/${unitId}/reference-number`,
|
||||
payload,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnitNotFoundError(error)) {
|
||||
await axiosInstance.post(`/unit-configurations`, { unitId });
|
||||
return await axiosInstance.post(
|
||||
`/unit-configurations/${unitId}/reference-number`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
throw new Error(errorMessage(error, "Failed to add reference numbers"));
|
||||
}
|
||||
},
|
||||
|
||||
deleteReferenceNumber: async (
|
||||
id: string,
|
||||
payload: DeleteReferenceNumberPayload,
|
||||
): Promise<AxiosResponse<void>> => {
|
||||
try {
|
||||
return await axiosInstance.delete(`/unit-configurations/${id}/reference-number`, {
|
||||
data: payload,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(errorMessage(error, "Failed to delete reference number"));
|
||||
}
|
||||
},
|
||||
|
||||
updateInternalMemoReferencePrefix: async (
|
||||
unitId: string,
|
||||
payload: UpdateInternalMemoPrefixPayload,
|
||||
): Promise<AxiosResponse<ReferenceNumbersResponse>> => {
|
||||
try {
|
||||
return await axiosInstance.patch(
|
||||
`/unit-configurations/add-internal-memo-reference-number-prefix/${unitId}`,
|
||||
payload,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnitNotFoundError(error)) {
|
||||
await axiosInstance.post(`/unit-configurations`, { unitId });
|
||||
return await axiosInstance.patch(
|
||||
`/unit-configurations/add-internal-memo-reference-number-prefix/${unitId}`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
errorMessage(error, "Failed to update internal memo reference prefix"),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
updateExternalReferencePrefix: async (
|
||||
unitId: string,
|
||||
payload: UpdateExternalReferencePrefixPayload,
|
||||
): Promise<AxiosResponse<ReferenceNumbersResponse>> => {
|
||||
try {
|
||||
return await axiosInstance.patch(
|
||||
`/unit-configurations/add-external-reference-number-prefix/${unitId}`,
|
||||
payload,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnitNotFoundError(error)) {
|
||||
await axiosInstance.post(`/unit-configurations`, { unitId });
|
||||
return await axiosInstance.patch(
|
||||
`/unit-configurations/add-external-reference-number-prefix/${unitId}`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
errorMessage(error, "Failed to update external reference prefix"),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// Whether the given unit can receive complaints.
|
||||
getCanReceiveComplaint: async (
|
||||
unitId: string,
|
||||
): Promise<AxiosResponse<CanReceiveComplaintResponse>> => {
|
||||
if (!unitId?.trim()) {
|
||||
throw new Error("Unit ID is required to check complaint receiving.");
|
||||
}
|
||||
|
||||
return axiosInstance.get(`/unit-configurations/can-receive-complaint`, {
|
||||
params: { unitId },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,260 +1,260 @@
|
||||
import {
|
||||
FileTypeDetectionResult,
|
||||
ValidationResult,
|
||||
UploadValidationOptions,
|
||||
} from './types';
|
||||
import { SVGContentValidator } from './SVGContentValidator';
|
||||
import { EmbeddedContentValidator } from './EmbeddedContentValidator';
|
||||
|
||||
/**
|
||||
* Central utility for validating file buffers against declared MIME types
|
||||
* Uses magic byte detection to prevent XSS attacks via file type spoofing
|
||||
*
|
||||
* NOTE: This validator is designed to run on the server/backend ONLY.
|
||||
* It requires the 'file-type' package which is Node.js-only.
|
||||
*
|
||||
* For browser usage, use ClientSideValidator instead.
|
||||
*/
|
||||
export class BufferContentValidator {
|
||||
/**
|
||||
* Detect file type from buffer using magic bytes
|
||||
* For text-based files (SVG, HTML, XML), falls back to content analysis
|
||||
* NOTE: This method requires the 'file-type' package and only works on Node.js
|
||||
* @param buffer - File buffer to analyze
|
||||
* @returns Detection result with MIME type and extension, or null if unrecognized
|
||||
*/
|
||||
async detectFileType(
|
||||
buffer: Buffer
|
||||
): Promise<FileTypeDetectionResult | null> {
|
||||
try {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dynamically import file-type only on server-side
|
||||
// This prevents it from being bundled for the browser
|
||||
const { fileTypeFromBuffer } = await import('file-type');
|
||||
|
||||
const detected = await fileTypeFromBuffer(buffer);
|
||||
if (detected) {
|
||||
return {
|
||||
mime: detected.mime,
|
||||
ext: detected.ext,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for text-based files without magic bytes (SVG, HTML, XML)
|
||||
const contentStr = buffer.toString('utf-8');
|
||||
|
||||
// Check for SVG
|
||||
if (contentStr.includes('<svg') || contentStr.includes('<?xml') && contentStr.includes('<svg')) {
|
||||
return {
|
||||
mime: 'image/svg+xml',
|
||||
ext: 'svg',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for HTML
|
||||
if (contentStr.includes('<!DOCTYPE html') || contentStr.includes('<html')) {
|
||||
return {
|
||||
mime: 'text/html',
|
||||
ext: 'html',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for XML
|
||||
if (contentStr.includes('<?xml')) {
|
||||
return {
|
||||
mime: 'application/xml',
|
||||
ext: 'xml',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error detecting file type:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate declared MIME type against buffer content
|
||||
* @param buffer - File buffer to validate
|
||||
* @param declaredMime - MIME type declared by client
|
||||
* @returns Validation result with success status and details
|
||||
*/
|
||||
async validateMimeType(
|
||||
buffer: Buffer,
|
||||
declaredMime: string
|
||||
): Promise<ValidationResult> {
|
||||
try {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: '',
|
||||
declaredMime,
|
||||
error: 'File buffer is empty or corrupted',
|
||||
};
|
||||
}
|
||||
|
||||
const detected = await this.detectFileType(buffer);
|
||||
|
||||
if (!detected) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: '',
|
||||
declaredMime,
|
||||
error: 'Unable to detect real file type from buffer',
|
||||
};
|
||||
}
|
||||
|
||||
const isValid = detected.mime === declaredMime;
|
||||
|
||||
return {
|
||||
isValid,
|
||||
detectedMime: detected.mime,
|
||||
declaredMime,
|
||||
error: isValid
|
||||
? undefined
|
||||
: `MIME mismatch. Declared: ${declaredMime}, Real: ${detected.mime}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: '',
|
||||
declaredMime,
|
||||
error: `Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MIME type is in allowed list
|
||||
* @param mimeType - MIME type to check
|
||||
* @param allowedTypes - List of allowed MIME types
|
||||
* @returns True if MIME type is allowed
|
||||
*/
|
||||
isAllowedMimeType(mimeType: string, allowedTypes: string[]): boolean {
|
||||
if (!allowedTypes || allowedTypes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return allowedTypes.includes(mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive validation: detect type, validate against declared, check whitelist, scan content
|
||||
* @param buffer - File buffer to validate
|
||||
* @param declaredMime - MIME type declared by client
|
||||
* @param options - Validation options including allowed types
|
||||
* @returns Validation result
|
||||
*/
|
||||
async validateUpload(
|
||||
buffer: Buffer,
|
||||
declaredMime: string,
|
||||
options: UploadValidationOptions
|
||||
): Promise<ValidationResult> {
|
||||
// First validate MIME type match
|
||||
const mimeValidation = await this.validateMimeType(buffer, declaredMime);
|
||||
|
||||
if (!mimeValidation.isValid) {
|
||||
return mimeValidation;
|
||||
}
|
||||
|
||||
// Then check if MIME type is allowed
|
||||
if (!this.isAllowedMimeType(declaredMime, options.allowedMimeTypes)) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: mimeValidation.detectedMime,
|
||||
declaredMime,
|
||||
error: `File type ${declaredMime} is not allowed for this endpoint`,
|
||||
};
|
||||
}
|
||||
|
||||
// Finally, scan file content for embedded XSS attacks
|
||||
const contentScan = this.scanContentForXSS(buffer, declaredMime);
|
||||
if (!contentScan.isValid) {
|
||||
return contentScan;
|
||||
}
|
||||
|
||||
return mimeValidation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SVG file content for XSS threats
|
||||
* @param buffer - SVG file buffer
|
||||
* @param fileName - File name for context
|
||||
* @returns Validation result
|
||||
*/
|
||||
validateSVGContent(
|
||||
buffer: Buffer,
|
||||
fileName: string
|
||||
): ValidationResult {
|
||||
const svgValidation = SVGContentValidator.validateSVGContent(buffer, fileName);
|
||||
|
||||
if (!svgValidation.isValid) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: 'image/svg+xml',
|
||||
declaredMime: 'image/svg+xml',
|
||||
error: svgValidation.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime: 'image/svg+xml',
|
||||
declaredMime: 'image/svg+xml',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan file content for embedded XSS attacks
|
||||
* Checks actual file content regardless of file type
|
||||
* @param buffer - File buffer to scan
|
||||
* @param declaredMime - Declared MIME type
|
||||
* @returns Validation result
|
||||
*/
|
||||
scanContentForXSS(
|
||||
buffer: Buffer,
|
||||
declaredMime: string
|
||||
): ValidationResult {
|
||||
try {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime: declaredMime,
|
||||
declaredMime,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for embedded XSS patterns in file content
|
||||
const hasXSSPatterns = EmbeddedContentValidator.containsXSSPatterns(buffer, declaredMime);
|
||||
|
||||
if (hasXSSPatterns) {
|
||||
const threatLevel = EmbeddedContentValidator.getThreatLevel(buffer, declaredMime);
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: declaredMime,
|
||||
declaredMime,
|
||||
error: `File content contains potentially malicious code (threat level: ${threatLevel}). This file may contain embedded scripts or dangerous patterns.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime: declaredMime,
|
||||
declaredMime,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: declaredMime,
|
||||
declaredMime,
|
||||
error: `Error scanning file content: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const bufferContentValidator = new BufferContentValidator();
|
||||
import {
|
||||
FileTypeDetectionResult,
|
||||
ValidationResult,
|
||||
UploadValidationOptions,
|
||||
} from './types';
|
||||
import { SVGContentValidator } from './SVGContentValidator';
|
||||
import { EmbeddedContentValidator } from './EmbeddedContentValidator';
|
||||
|
||||
/**
|
||||
* Central utility for validating file buffers against declared MIME types
|
||||
* Uses magic byte detection to prevent XSS attacks via file type spoofing
|
||||
*
|
||||
* NOTE: This validator is designed to run on the server/backend ONLY.
|
||||
* It requires the 'file-type' package which is Node.js-only.
|
||||
*
|
||||
* For browser usage, use ClientSideValidator instead.
|
||||
*/
|
||||
export class BufferContentValidator {
|
||||
/**
|
||||
* Detect file type from buffer using magic bytes
|
||||
* For text-based files (SVG, HTML, XML), falls back to content analysis
|
||||
* NOTE: This method requires the 'file-type' package and only works on Node.js
|
||||
* @param buffer - File buffer to analyze
|
||||
* @returns Detection result with MIME type and extension, or null if unrecognized
|
||||
*/
|
||||
async detectFileType(
|
||||
buffer: Buffer
|
||||
): Promise<FileTypeDetectionResult | null> {
|
||||
try {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dynamically import file-type only on server-side
|
||||
// This prevents it from being bundled for the browser
|
||||
const { fileTypeFromBuffer } = await import('file-type');
|
||||
|
||||
const detected = await fileTypeFromBuffer(buffer);
|
||||
if (detected) {
|
||||
return {
|
||||
mime: detected.mime,
|
||||
ext: detected.ext,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for text-based files without magic bytes (SVG, HTML, XML)
|
||||
const contentStr = buffer.toString('utf-8');
|
||||
|
||||
// Check for SVG
|
||||
if (contentStr.includes('<svg') || contentStr.includes('<?xml') && contentStr.includes('<svg')) {
|
||||
return {
|
||||
mime: 'image/svg+xml',
|
||||
ext: 'svg',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for HTML
|
||||
if (contentStr.includes('<!DOCTYPE html') || contentStr.includes('<html')) {
|
||||
return {
|
||||
mime: 'text/html',
|
||||
ext: 'html',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for XML
|
||||
if (contentStr.includes('<?xml')) {
|
||||
return {
|
||||
mime: 'application/xml',
|
||||
ext: 'xml',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error detecting file type:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate declared MIME type against buffer content
|
||||
* @param buffer - File buffer to validate
|
||||
* @param declaredMime - MIME type declared by client
|
||||
* @returns Validation result with success status and details
|
||||
*/
|
||||
async validateMimeType(
|
||||
buffer: Buffer,
|
||||
declaredMime: string
|
||||
): Promise<ValidationResult> {
|
||||
try {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: '',
|
||||
declaredMime,
|
||||
error: 'File buffer is empty or corrupted',
|
||||
};
|
||||
}
|
||||
|
||||
const detected = await this.detectFileType(buffer);
|
||||
|
||||
if (!detected) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: '',
|
||||
declaredMime,
|
||||
error: 'Unable to detect real file type from buffer',
|
||||
};
|
||||
}
|
||||
|
||||
const isValid = detected.mime === declaredMime;
|
||||
|
||||
return {
|
||||
isValid,
|
||||
detectedMime: detected.mime,
|
||||
declaredMime,
|
||||
error: isValid
|
||||
? undefined
|
||||
: `MIME mismatch. Declared: ${declaredMime}, Real: ${detected.mime}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: '',
|
||||
declaredMime,
|
||||
error: `Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MIME type is in allowed list
|
||||
* @param mimeType - MIME type to check
|
||||
* @param allowedTypes - List of allowed MIME types
|
||||
* @returns True if MIME type is allowed
|
||||
*/
|
||||
isAllowedMimeType(mimeType: string, allowedTypes: string[]): boolean {
|
||||
if (!allowedTypes || allowedTypes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return allowedTypes.includes(mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive validation: detect type, validate against declared, check whitelist, scan content
|
||||
* @param buffer - File buffer to validate
|
||||
* @param declaredMime - MIME type declared by client
|
||||
* @param options - Validation options including allowed types
|
||||
* @returns Validation result
|
||||
*/
|
||||
async validateUpload(
|
||||
buffer: Buffer,
|
||||
declaredMime: string,
|
||||
options: UploadValidationOptions
|
||||
): Promise<ValidationResult> {
|
||||
// First validate MIME type match
|
||||
const mimeValidation = await this.validateMimeType(buffer, declaredMime);
|
||||
|
||||
if (!mimeValidation.isValid) {
|
||||
return mimeValidation;
|
||||
}
|
||||
|
||||
// Then check if MIME type is allowed
|
||||
if (!this.isAllowedMimeType(declaredMime, options.allowedMimeTypes)) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: mimeValidation.detectedMime,
|
||||
declaredMime,
|
||||
error: `File type ${declaredMime} is not allowed for this endpoint`,
|
||||
};
|
||||
}
|
||||
|
||||
// Finally, scan file content for embedded XSS attacks
|
||||
const contentScan = this.scanContentForXSS(buffer, declaredMime);
|
||||
if (!contentScan.isValid) {
|
||||
return contentScan;
|
||||
}
|
||||
|
||||
return mimeValidation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SVG file content for XSS threats
|
||||
* @param buffer - SVG file buffer
|
||||
* @param fileName - File name for context
|
||||
* @returns Validation result
|
||||
*/
|
||||
validateSVGContent(
|
||||
buffer: Buffer,
|
||||
fileName: string
|
||||
): ValidationResult {
|
||||
const svgValidation = SVGContentValidator.validateSVGContent(buffer, fileName);
|
||||
|
||||
if (!svgValidation.isValid) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: 'image/svg+xml',
|
||||
declaredMime: 'image/svg+xml',
|
||||
error: svgValidation.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime: 'image/svg+xml',
|
||||
declaredMime: 'image/svg+xml',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan file content for embedded XSS attacks
|
||||
* Checks actual file content regardless of file type
|
||||
* @param buffer - File buffer to scan
|
||||
* @param declaredMime - Declared MIME type
|
||||
* @returns Validation result
|
||||
*/
|
||||
scanContentForXSS(
|
||||
buffer: Buffer,
|
||||
declaredMime: string
|
||||
): ValidationResult {
|
||||
try {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime: declaredMime,
|
||||
declaredMime,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for embedded XSS patterns in file content
|
||||
const hasXSSPatterns = EmbeddedContentValidator.containsXSSPatterns(buffer, declaredMime);
|
||||
|
||||
if (hasXSSPatterns) {
|
||||
const threatLevel = EmbeddedContentValidator.getThreatLevel(buffer, declaredMime);
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: declaredMime,
|
||||
declaredMime,
|
||||
error: `File content contains potentially malicious code (threat level: ${threatLevel}). This file may contain embedded scripts or dangerous patterns.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime: declaredMime,
|
||||
declaredMime,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: declaredMime,
|
||||
declaredMime,
|
||||
error: `Error scanning file content: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const bufferContentValidator = new BufferContentValidator();
|
||||
|
||||
@@ -1,270 +1,270 @@
|
||||
/**
|
||||
* Client-side file validation using magic byte detection
|
||||
* Works in the browser without external dependencies
|
||||
* Provides quick validation before sending to server
|
||||
*/
|
||||
|
||||
export interface MagicByteSignature {
|
||||
mime: string;
|
||||
ext: string;
|
||||
bytes: number[];
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic byte signatures for common file types
|
||||
* These are the first few bytes that identify file types
|
||||
*/
|
||||
const MAGIC_BYTES: MagicByteSignature[] = [
|
||||
// Images
|
||||
{ mime: 'image/png', ext: 'png', bytes: [0x89, 0x50, 0x4e, 0x47] },
|
||||
{ mime: 'image/jpeg', ext: 'jpg', bytes: [0xff, 0xd8, 0xff] },
|
||||
{ mime: 'image/gif', ext: 'gif', bytes: [0x47, 0x49, 0x46] },
|
||||
{ mime: 'image/webp', ext: 'webp', bytes: [0x52, 0x49, 0x46, 0x46], offset: 8 }, // RIFF...WEBP
|
||||
|
||||
// Documents
|
||||
{ mime: 'application/pdf', ext: 'pdf', bytes: [0x25, 0x50, 0x44, 0x46] }, // %PDF
|
||||
|
||||
// Office Documents
|
||||
{ mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ext: 'docx', bytes: [0x50, 0x4b, 0x03, 0x04] }, // PK (ZIP)
|
||||
{ mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ext: 'xlsx', bytes: [0x50, 0x4b, 0x03, 0x04] }, // PK (ZIP)
|
||||
{ mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ext: 'pptx', bytes: [0x50, 0x4b, 0x03, 0x04] }, // PK (ZIP)
|
||||
|
||||
// Legacy Office
|
||||
{ mime: 'application/msword', ext: 'doc', bytes: [0xd0, 0xcf, 0x11, 0xe0] }, // OLE2
|
||||
{ mime: 'application/vnd.ms-excel', ext: 'xls', bytes: [0xd0, 0xcf, 0x11, 0xe0] }, // OLE2
|
||||
{ mime: 'application/vnd.ms-powerpoint', ext: 'ppt', bytes: [0xd0, 0xcf, 0x11, 0xe0] }, // OLE2
|
||||
|
||||
// Archives
|
||||
{ mime: 'application/zip', ext: 'zip', bytes: [0x50, 0x4b, 0x03, 0x04] }, // PK
|
||||
{ mime: 'application/x-rar-compressed', ext: 'rar', bytes: [0x52, 0x61, 0x72, 0x21] }, // Rar!
|
||||
|
||||
// Text
|
||||
{ mime: 'text/plain', ext: 'txt', bytes: [0xef, 0xbb, 0xbf] }, // UTF-8 BOM
|
||||
];
|
||||
|
||||
/**
|
||||
* Client-side validator for file uploads
|
||||
* Uses magic byte detection to identify file types
|
||||
*/
|
||||
export class ClientSideValidator {
|
||||
/**
|
||||
* Detect file type from buffer using magic bytes
|
||||
* @param buffer - File buffer to analyze
|
||||
* @returns Detected MIME type or null if not recognized
|
||||
*/
|
||||
async detectFileType(buffer: ArrayBuffer): Promise<string | null> {
|
||||
try {
|
||||
if (!buffer || buffer.byteLength === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(buffer.slice(0, 512)); // Check first 512 bytes
|
||||
|
||||
for (const signature of MAGIC_BYTES) {
|
||||
const offset = signature.offset || 0;
|
||||
let matches = true;
|
||||
|
||||
for (let i = 0; i < signature.bytes.length; i++) {
|
||||
if (bytes[offset + i] !== signature.bytes[i]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
return signature.mime;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error detecting file type:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file against declared MIME type
|
||||
* @param file - File to validate
|
||||
* @param declaredMime - MIME type declared by the file
|
||||
* @returns Validation result
|
||||
* @deprecated Use validateUpload() instead for better Office document handling
|
||||
*/
|
||||
async validateFile(
|
||||
file: File,
|
||||
declaredMime: string
|
||||
): Promise<{ isValid: boolean; detectedMime: string | null; error?: string }> {
|
||||
try {
|
||||
if (!file) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: null,
|
||||
error: 'No file provided',
|
||||
};
|
||||
}
|
||||
|
||||
// Read first 512 bytes
|
||||
const buffer = await file.slice(0, 512).arrayBuffer();
|
||||
const detectedMime = await this.detectFileType(buffer);
|
||||
|
||||
if (!detectedMime) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: null,
|
||||
error: 'Unable to detect file type from content',
|
||||
};
|
||||
}
|
||||
|
||||
// For Office documents, check if detected type is in a related family
|
||||
// Both .xlsx and .docx are ZIP containers, so we need flexible matching
|
||||
const officeDocTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
];
|
||||
|
||||
const isOfficeDoc = officeDocTypes.includes(declaredMime);
|
||||
const detectedIsOfficeDoc = officeDocTypes.includes(detectedMime);
|
||||
|
||||
// If both are Office documents, allow the match (they're all ZIP containers)
|
||||
if (isOfficeDoc && detectedIsOfficeDoc) {
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime,
|
||||
};
|
||||
}
|
||||
|
||||
const isValid = detectedMime === declaredMime;
|
||||
|
||||
return {
|
||||
isValid,
|
||||
detectedMime,
|
||||
error: isValid
|
||||
? undefined
|
||||
: `File type mismatch. Expected: ${declaredMime}, Detected: ${detectedMime}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: null,
|
||||
error: `Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MIME type is in allowed list
|
||||
* @param mimeType - MIME type to check
|
||||
* @param allowedTypes - List of allowed MIME types
|
||||
* @returns True if MIME type is allowed
|
||||
*/
|
||||
isAllowedMimeType(mimeType: string, allowedTypes: string[]): boolean {
|
||||
if (!allowedTypes || allowedTypes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return allowedTypes.includes(mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive validation: detect type, validate against declared, check whitelist
|
||||
* @param file - File to validate
|
||||
* @param declaredMime - MIME type declared by the file
|
||||
* @param allowedTypes - List of allowed MIME types
|
||||
* @returns Validation result
|
||||
*/
|
||||
async validateUpload(
|
||||
file: File,
|
||||
declaredMime: string,
|
||||
allowedTypes: string[]
|
||||
): Promise<{ isValid: boolean; detectedMime: string | null; error?: string }> {
|
||||
// First detect the actual file type
|
||||
const buffer = await file.slice(0, 512).arrayBuffer();
|
||||
const detectedMime = await this.detectFileType(buffer);
|
||||
|
||||
// If we can detect the MIME type, validate it
|
||||
if (detectedMime) {
|
||||
// Check if detected MIME type is in the allowed list
|
||||
// This is more flexible than strict matching, especially for Office documents
|
||||
if (!this.isAllowedMimeType(detectedMime, allowedTypes)) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime,
|
||||
error: `File type ${detectedMime} is not allowed for this upload. Allowed types: ${allowedTypes.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime,
|
||||
};
|
||||
}
|
||||
|
||||
// Magic byte detection failed. Only allow extension-based fallback for types
|
||||
// that legitimately lack magic byte signatures (GIS files, plain text, etc).
|
||||
// Reject anything that should have detectable magic bytes (PDF, images, Office docs).
|
||||
const requiresMagicBytes = [
|
||||
"application/pdf",
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||||
"application/msword",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
];
|
||||
|
||||
if (declaredMime && requiresMagicBytes.includes(declaredMime)) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: null,
|
||||
error: `Could not verify file content for ${declaredMime}. The file may be corrupt or misnamed.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (declaredMime && this.isAllowedMimeType(declaredMime, allowedTypes)) {
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime: declaredMime,
|
||||
};
|
||||
}
|
||||
|
||||
// If declared MIME type is empty, try to infer from file extension
|
||||
if (!declaredMime && file.name) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
|
||||
// Map common GIS and other file extensions to MIME types
|
||||
const extensionMimeMap: Record<string, string> = {
|
||||
'shp': 'application/vnd.shp',
|
||||
'dbf': 'application/vnd.dbf',
|
||||
'cpg': 'application/vnd.cpg',
|
||||
'shx': 'application/vnd.shx',
|
||||
'qmd': 'application/vnd.qmd',
|
||||
'dwg': 'application/x-dwg',
|
||||
'dxf': 'application/dxf',
|
||||
'txt': 'text/plain',
|
||||
'pdf': 'application/pdf',
|
||||
'zip': 'application/zip',
|
||||
'rar': 'application/x-rar-compressed',
|
||||
};
|
||||
|
||||
const inferredMime = ext ? extensionMimeMap[ext] : null;
|
||||
|
||||
if (inferredMime && this.isAllowedMimeType(inferredMime, allowedTypes)) {
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime: inferredMime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: null,
|
||||
error: `File type verification failed. Declared type: ${declaredMime}. Allowed types: ${allowedTypes.join(', ')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const clientSideValidator = new ClientSideValidator();
|
||||
/**
|
||||
* Client-side file validation using magic byte detection
|
||||
* Works in the browser without external dependencies
|
||||
* Provides quick validation before sending to server
|
||||
*/
|
||||
|
||||
export interface MagicByteSignature {
|
||||
mime: string;
|
||||
ext: string;
|
||||
bytes: number[];
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic byte signatures for common file types
|
||||
* These are the first few bytes that identify file types
|
||||
*/
|
||||
const MAGIC_BYTES: MagicByteSignature[] = [
|
||||
// Images
|
||||
{ mime: 'image/png', ext: 'png', bytes: [0x89, 0x50, 0x4e, 0x47] },
|
||||
{ mime: 'image/jpeg', ext: 'jpg', bytes: [0xff, 0xd8, 0xff] },
|
||||
{ mime: 'image/gif', ext: 'gif', bytes: [0x47, 0x49, 0x46] },
|
||||
{ mime: 'image/webp', ext: 'webp', bytes: [0x52, 0x49, 0x46, 0x46], offset: 8 }, // RIFF...WEBP
|
||||
|
||||
// Documents
|
||||
{ mime: 'application/pdf', ext: 'pdf', bytes: [0x25, 0x50, 0x44, 0x46] }, // %PDF
|
||||
|
||||
// Office Documents
|
||||
{ mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ext: 'docx', bytes: [0x50, 0x4b, 0x03, 0x04] }, // PK (ZIP)
|
||||
{ mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ext: 'xlsx', bytes: [0x50, 0x4b, 0x03, 0x04] }, // PK (ZIP)
|
||||
{ mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ext: 'pptx', bytes: [0x50, 0x4b, 0x03, 0x04] }, // PK (ZIP)
|
||||
|
||||
// Legacy Office
|
||||
{ mime: 'application/msword', ext: 'doc', bytes: [0xd0, 0xcf, 0x11, 0xe0] }, // OLE2
|
||||
{ mime: 'application/vnd.ms-excel', ext: 'xls', bytes: [0xd0, 0xcf, 0x11, 0xe0] }, // OLE2
|
||||
{ mime: 'application/vnd.ms-powerpoint', ext: 'ppt', bytes: [0xd0, 0xcf, 0x11, 0xe0] }, // OLE2
|
||||
|
||||
// Archives
|
||||
{ mime: 'application/zip', ext: 'zip', bytes: [0x50, 0x4b, 0x03, 0x04] }, // PK
|
||||
{ mime: 'application/x-rar-compressed', ext: 'rar', bytes: [0x52, 0x61, 0x72, 0x21] }, // Rar!
|
||||
|
||||
// Text
|
||||
{ mime: 'text/plain', ext: 'txt', bytes: [0xef, 0xbb, 0xbf] }, // UTF-8 BOM
|
||||
];
|
||||
|
||||
/**
|
||||
* Client-side validator for file uploads
|
||||
* Uses magic byte detection to identify file types
|
||||
*/
|
||||
export class ClientSideValidator {
|
||||
/**
|
||||
* Detect file type from buffer using magic bytes
|
||||
* @param buffer - File buffer to analyze
|
||||
* @returns Detected MIME type or null if not recognized
|
||||
*/
|
||||
async detectFileType(buffer: ArrayBuffer): Promise<string | null> {
|
||||
try {
|
||||
if (!buffer || buffer.byteLength === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(buffer.slice(0, 512)); // Check first 512 bytes
|
||||
|
||||
for (const signature of MAGIC_BYTES) {
|
||||
const offset = signature.offset || 0;
|
||||
let matches = true;
|
||||
|
||||
for (let i = 0; i < signature.bytes.length; i++) {
|
||||
if (bytes[offset + i] !== signature.bytes[i]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
return signature.mime;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error detecting file type:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file against declared MIME type
|
||||
* @param file - File to validate
|
||||
* @param declaredMime - MIME type declared by the file
|
||||
* @returns Validation result
|
||||
* @deprecated Use validateUpload() instead for better Office document handling
|
||||
*/
|
||||
async validateFile(
|
||||
file: File,
|
||||
declaredMime: string
|
||||
): Promise<{ isValid: boolean; detectedMime: string | null; error?: string }> {
|
||||
try {
|
||||
if (!file) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: null,
|
||||
error: 'No file provided',
|
||||
};
|
||||
}
|
||||
|
||||
// Read first 512 bytes
|
||||
const buffer = await file.slice(0, 512).arrayBuffer();
|
||||
const detectedMime = await this.detectFileType(buffer);
|
||||
|
||||
if (!detectedMime) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: null,
|
||||
error: 'Unable to detect file type from content',
|
||||
};
|
||||
}
|
||||
|
||||
// For Office documents, check if detected type is in a related family
|
||||
// Both .xlsx and .docx are ZIP containers, so we need flexible matching
|
||||
const officeDocTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
];
|
||||
|
||||
const isOfficeDoc = officeDocTypes.includes(declaredMime);
|
||||
const detectedIsOfficeDoc = officeDocTypes.includes(detectedMime);
|
||||
|
||||
// If both are Office documents, allow the match (they're all ZIP containers)
|
||||
if (isOfficeDoc && detectedIsOfficeDoc) {
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime,
|
||||
};
|
||||
}
|
||||
|
||||
const isValid = detectedMime === declaredMime;
|
||||
|
||||
return {
|
||||
isValid,
|
||||
detectedMime,
|
||||
error: isValid
|
||||
? undefined
|
||||
: `File type mismatch. Expected: ${declaredMime}, Detected: ${detectedMime}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: null,
|
||||
error: `Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MIME type is in allowed list
|
||||
* @param mimeType - MIME type to check
|
||||
* @param allowedTypes - List of allowed MIME types
|
||||
* @returns True if MIME type is allowed
|
||||
*/
|
||||
isAllowedMimeType(mimeType: string, allowedTypes: string[]): boolean {
|
||||
if (!allowedTypes || allowedTypes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return allowedTypes.includes(mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive validation: detect type, validate against declared, check whitelist
|
||||
* @param file - File to validate
|
||||
* @param declaredMime - MIME type declared by the file
|
||||
* @param allowedTypes - List of allowed MIME types
|
||||
* @returns Validation result
|
||||
*/
|
||||
async validateUpload(
|
||||
file: File,
|
||||
declaredMime: string,
|
||||
allowedTypes: string[]
|
||||
): Promise<{ isValid: boolean; detectedMime: string | null; error?: string }> {
|
||||
// First detect the actual file type
|
||||
const buffer = await file.slice(0, 512).arrayBuffer();
|
||||
const detectedMime = await this.detectFileType(buffer);
|
||||
|
||||
// If we can detect the MIME type, validate it
|
||||
if (detectedMime) {
|
||||
// Check if detected MIME type is in the allowed list
|
||||
// This is more flexible than strict matching, especially for Office documents
|
||||
if (!this.isAllowedMimeType(detectedMime, allowedTypes)) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime,
|
||||
error: `File type ${detectedMime} is not allowed for this upload. Allowed types: ${allowedTypes.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime,
|
||||
};
|
||||
}
|
||||
|
||||
// Magic byte detection failed. Only allow extension-based fallback for types
|
||||
// that legitimately lack magic byte signatures (GIS files, plain text, etc).
|
||||
// Reject anything that should have detectable magic bytes (PDF, images, Office docs).
|
||||
const requiresMagicBytes = [
|
||||
"application/pdf",
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||||
"application/msword",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
];
|
||||
|
||||
if (declaredMime && requiresMagicBytes.includes(declaredMime)) {
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: null,
|
||||
error: `Could not verify file content for ${declaredMime}. The file may be corrupt or misnamed.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (declaredMime && this.isAllowedMimeType(declaredMime, allowedTypes)) {
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime: declaredMime,
|
||||
};
|
||||
}
|
||||
|
||||
// If declared MIME type is empty, try to infer from file extension
|
||||
if (!declaredMime && file.name) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
|
||||
// Map common GIS and other file extensions to MIME types
|
||||
const extensionMimeMap: Record<string, string> = {
|
||||
'shp': 'application/vnd.shp',
|
||||
'dbf': 'application/vnd.dbf',
|
||||
'cpg': 'application/vnd.cpg',
|
||||
'shx': 'application/vnd.shx',
|
||||
'qmd': 'application/vnd.qmd',
|
||||
'dwg': 'application/x-dwg',
|
||||
'dxf': 'application/dxf',
|
||||
'txt': 'text/plain',
|
||||
'pdf': 'application/pdf',
|
||||
'zip': 'application/zip',
|
||||
'rar': 'application/x-rar-compressed',
|
||||
};
|
||||
|
||||
const inferredMime = ext ? extensionMimeMap[ext] : null;
|
||||
|
||||
if (inferredMime && this.isAllowedMimeType(inferredMime, allowedTypes)) {
|
||||
return {
|
||||
isValid: true,
|
||||
detectedMime: inferredMime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
detectedMime: null,
|
||||
error: `File type verification failed. Declared type: ${declaredMime}. Allowed types: ${allowedTypes.join(', ')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const clientSideValidator = new ClientSideValidator();
|
||||
|
||||
@@ -1,288 +1,288 @@
|
||||
/**
|
||||
* Validator for detecting XSS attacks embedded in various file types
|
||||
* Covers HTML, XML, PDF, Office documents, and other formats that can contain scripts
|
||||
*/
|
||||
|
||||
export class EmbeddedContentValidator {
|
||||
/**
|
||||
* Check if content contains XSS attack patterns
|
||||
* @param content - File content as string, Buffer, or Uint8Array
|
||||
* @param mimeType - MIME type of the file
|
||||
* @returns True if malicious content is detected
|
||||
*/
|
||||
static containsXSSPatterns(content: string | Buffer | Uint8Array, mimeType: string): boolean {
|
||||
let contentStr: string;
|
||||
if (typeof content === 'string') {
|
||||
contentStr = content;
|
||||
} else if (content instanceof Uint8Array) {
|
||||
contentStr = new TextDecoder().decode(content);
|
||||
} else {
|
||||
contentStr = (content as Buffer).toString('utf-8');
|
||||
}
|
||||
|
||||
// Universal XSS patterns that apply to most file types
|
||||
const xssPatterns = [
|
||||
/<script[\s>]/i, // Script tags
|
||||
/on\w+\s*=\s*["'][^"']*["']/i, // Event handlers with quotes
|
||||
/on\w+\s*=\s*[^\s>]*/i, // Event handlers without quotes
|
||||
/javascript:/i, // JavaScript protocol
|
||||
/data:text\/html/i, // Data URI with HTML
|
||||
/vbscript:/i, // VBScript protocol
|
||||
/<iframe[\s>]/i, // iFrame tags
|
||||
/<object[\s>]/i, // Object tags
|
||||
/<embed[\s>]/i, // Embed tags
|
||||
/<applet[\s>]/i, // Applet tags
|
||||
/<meta[\s>]/i, // Meta tags (can redirect)
|
||||
/<link[\s>]/i, // Link tags (can load malicious resources)
|
||||
/<style[\s>][\s\S]*?expression\s*\(/i, // CSS expressions
|
||||
/eval\s*\(/i, // Eval function
|
||||
/expression\s*\(/i, // CSS expression
|
||||
/import\s+/i, // Import statements
|
||||
/<!--[\s\S]*?-->/i, // HTML comments (can hide code)
|
||||
];
|
||||
|
||||
for (const pattern of xssPatterns) {
|
||||
if (pattern.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// MIME-type specific checks
|
||||
if (mimeType.includes('html') || mimeType.includes('xml')) {
|
||||
return this.checkHTMLXMLContent(contentStr);
|
||||
}
|
||||
|
||||
if (mimeType.includes('pdf')) {
|
||||
return this.checkPDFContent(contentStr);
|
||||
}
|
||||
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) {
|
||||
return this.checkOfficeDocumentContent(contentStr);
|
||||
}
|
||||
|
||||
if (mimeType.includes('sheet') || mimeType.includes('excel')) {
|
||||
return this.checkSpreadsheetContent(contentStr);
|
||||
}
|
||||
|
||||
if (mimeType.includes('svg')) {
|
||||
return this.checkSVGContent(contentStr);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check HTML/XML content for XSS
|
||||
*/
|
||||
private static checkHTMLXMLContent(content: string): boolean {
|
||||
// Check for script tags
|
||||
if (/<script[\s\S]*?<\/script>/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for event handlers
|
||||
if (/\s+on\w+\s*=/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for dangerous tags
|
||||
if (/<(iframe|object|embed|applet|meta|link|form|input|button)[\s>]/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for protocol handlers
|
||||
if (/(javascript|data|vbscript):/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check PDF content for embedded XSS
|
||||
* PDFs can contain JavaScript and embedded files
|
||||
*/
|
||||
private static checkPDFContent(content: string): boolean {
|
||||
// Check for JavaScript in PDF
|
||||
if (/\/JavaScript|\/JS|\/OpenAction|\/AA/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for embedded files
|
||||
if (/\/EmbeddedFile|\/ObjStm/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for launch actions
|
||||
if (/\/Launch|\/SubmitForm|\/ImportData/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Office document content for XSS
|
||||
* Office documents (DOCX, XLSX) are ZIP files containing XML
|
||||
*/
|
||||
private static checkOfficeDocumentContent(content: string): boolean {
|
||||
// Check for VBA macros
|
||||
if (/vbaProject|VBA|Macro/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for embedded scripts
|
||||
if (/<script[\s\S]*?<\/script>/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for event handlers
|
||||
if (/\s+on\w+\s*=/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for external data connections
|
||||
if (/<externalData|<connection/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check spreadsheet content for XSS
|
||||
*/
|
||||
private static checkSpreadsheetContent(content: string): boolean {
|
||||
// Check for formulas that could execute code
|
||||
if (/=cmd\(|=powershell|=system|=exec/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for VBA macros
|
||||
if (/vbaProject|VBA|Macro/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for embedded scripts
|
||||
if (/<script[\s\S]*?<\/script>/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check SVG content for XSS
|
||||
*/
|
||||
private static checkSVGContent(content: string): boolean {
|
||||
// Check for script tags
|
||||
if (/<script[\s\S]*?<\/script>/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for event handlers
|
||||
if (/\s+on\w+\s*=/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for dangerous tags
|
||||
if (/<(iframe|object|embed|applet)[\s>]/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for protocol handlers
|
||||
if (/(javascript|data|vbscript):/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get XSS threat level for content
|
||||
* @param content - File content as string, Buffer, or Uint8Array
|
||||
* @param mimeType - MIME type
|
||||
* @returns Threat level: 'none', 'low', 'medium', 'high', 'critical'
|
||||
*/
|
||||
static getThreatLevel(content: string | Buffer | Uint8Array, mimeType: string): 'none' | 'low' | 'medium' | 'high' | 'critical' {
|
||||
let contentStr: string;
|
||||
if (typeof content === 'string') {
|
||||
contentStr = content;
|
||||
} else if (content instanceof Uint8Array) {
|
||||
contentStr = new TextDecoder().decode(content);
|
||||
} else {
|
||||
contentStr = (content as Buffer).toString('utf-8');
|
||||
}
|
||||
|
||||
// Critical threats
|
||||
if (/<script[\s\S]*?<\/script>/i.test(contentStr)) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if (/eval\s*\(/i.test(contentStr)) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if (/\/JavaScript|\/JS|\/OpenAction/i.test(contentStr)) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
// High threats
|
||||
if (/on\w+\s*=\s*["'][^"']*["']/i.test(contentStr)) {
|
||||
return 'high';
|
||||
}
|
||||
|
||||
if (/(javascript|vbscript):/i.test(contentStr)) {
|
||||
return 'high';
|
||||
}
|
||||
|
||||
if (/<(iframe|object|embed|applet)[\s>]/i.test(contentStr)) {
|
||||
return 'high';
|
||||
}
|
||||
|
||||
// Medium threats
|
||||
if (/data:text\/html/i.test(contentStr)) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
if (/vbaProject|VBA|Macro/i.test(contentStr)) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
// Low threats
|
||||
if (/<!--[\s\S]*?-->/i.test(contentStr)) {
|
||||
return 'low';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize content by removing XSS patterns
|
||||
* WARNING: This is a basic sanitization and may not catch all attacks
|
||||
* For production, use a dedicated sanitization library
|
||||
* @param content - File content
|
||||
* @param mimeType - MIME type
|
||||
* @returns Sanitized content
|
||||
*/
|
||||
static sanitizeContent(content: Buffer, mimeType: string): Buffer {
|
||||
let contentStr = content.toString('utf-8');
|
||||
|
||||
// Remove script tags
|
||||
contentStr = contentStr.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
|
||||
// Remove event handlers
|
||||
contentStr = contentStr.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '');
|
||||
contentStr = contentStr.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, '');
|
||||
|
||||
// Remove dangerous protocols
|
||||
contentStr = contentStr.replace(/javascript:/gi, '');
|
||||
contentStr = contentStr.replace(/vbscript:/gi, '');
|
||||
contentStr = contentStr.replace(/data:text\/html/gi, '');
|
||||
|
||||
// Remove dangerous tags
|
||||
contentStr = contentStr.replace(/<(iframe|object|embed|applet)[\s\S]*?<\/\1>/gi, '');
|
||||
|
||||
return Buffer.from(contentStr, 'utf-8');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Validator for detecting XSS attacks embedded in various file types
|
||||
* Covers HTML, XML, PDF, Office documents, and other formats that can contain scripts
|
||||
*/
|
||||
|
||||
export class EmbeddedContentValidator {
|
||||
/**
|
||||
* Check if content contains XSS attack patterns
|
||||
* @param content - File content as string, Buffer, or Uint8Array
|
||||
* @param mimeType - MIME type of the file
|
||||
* @returns True if malicious content is detected
|
||||
*/
|
||||
static containsXSSPatterns(content: string | Buffer | Uint8Array, mimeType: string): boolean {
|
||||
let contentStr: string;
|
||||
if (typeof content === 'string') {
|
||||
contentStr = content;
|
||||
} else if (content instanceof Uint8Array) {
|
||||
contentStr = new TextDecoder().decode(content);
|
||||
} else {
|
||||
contentStr = (content as Buffer).toString('utf-8');
|
||||
}
|
||||
|
||||
// Universal XSS patterns that apply to most file types
|
||||
const xssPatterns = [
|
||||
/<script[\s>]/i, // Script tags
|
||||
/on\w+\s*=\s*["'][^"']*["']/i, // Event handlers with quotes
|
||||
/on\w+\s*=\s*[^\s>]*/i, // Event handlers without quotes
|
||||
/javascript:/i, // JavaScript protocol
|
||||
/data:text\/html/i, // Data URI with HTML
|
||||
/vbscript:/i, // VBScript protocol
|
||||
/<iframe[\s>]/i, // iFrame tags
|
||||
/<object[\s>]/i, // Object tags
|
||||
/<embed[\s>]/i, // Embed tags
|
||||
/<applet[\s>]/i, // Applet tags
|
||||
/<meta[\s>]/i, // Meta tags (can redirect)
|
||||
/<link[\s>]/i, // Link tags (can load malicious resources)
|
||||
/<style[\s>][\s\S]*?expression\s*\(/i, // CSS expressions
|
||||
/eval\s*\(/i, // Eval function
|
||||
/expression\s*\(/i, // CSS expression
|
||||
/import\s+/i, // Import statements
|
||||
/<!--[\s\S]*?-->/i, // HTML comments (can hide code)
|
||||
];
|
||||
|
||||
for (const pattern of xssPatterns) {
|
||||
if (pattern.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// MIME-type specific checks
|
||||
if (mimeType.includes('html') || mimeType.includes('xml')) {
|
||||
return this.checkHTMLXMLContent(contentStr);
|
||||
}
|
||||
|
||||
if (mimeType.includes('pdf')) {
|
||||
return this.checkPDFContent(contentStr);
|
||||
}
|
||||
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) {
|
||||
return this.checkOfficeDocumentContent(contentStr);
|
||||
}
|
||||
|
||||
if (mimeType.includes('sheet') || mimeType.includes('excel')) {
|
||||
return this.checkSpreadsheetContent(contentStr);
|
||||
}
|
||||
|
||||
if (mimeType.includes('svg')) {
|
||||
return this.checkSVGContent(contentStr);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check HTML/XML content for XSS
|
||||
*/
|
||||
private static checkHTMLXMLContent(content: string): boolean {
|
||||
// Check for script tags
|
||||
if (/<script[\s\S]*?<\/script>/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for event handlers
|
||||
if (/\s+on\w+\s*=/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for dangerous tags
|
||||
if (/<(iframe|object|embed|applet|meta|link|form|input|button)[\s>]/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for protocol handlers
|
||||
if (/(javascript|data|vbscript):/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check PDF content for embedded XSS
|
||||
* PDFs can contain JavaScript and embedded files
|
||||
*/
|
||||
private static checkPDFContent(content: string): boolean {
|
||||
// Check for JavaScript in PDF
|
||||
if (/\/JavaScript|\/JS|\/OpenAction|\/AA/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for embedded files
|
||||
if (/\/EmbeddedFile|\/ObjStm/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for launch actions
|
||||
if (/\/Launch|\/SubmitForm|\/ImportData/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Office document content for XSS
|
||||
* Office documents (DOCX, XLSX) are ZIP files containing XML
|
||||
*/
|
||||
private static checkOfficeDocumentContent(content: string): boolean {
|
||||
// Check for VBA macros
|
||||
if (/vbaProject|VBA|Macro/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for embedded scripts
|
||||
if (/<script[\s\S]*?<\/script>/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for event handlers
|
||||
if (/\s+on\w+\s*=/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for external data connections
|
||||
if (/<externalData|<connection/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check spreadsheet content for XSS
|
||||
*/
|
||||
private static checkSpreadsheetContent(content: string): boolean {
|
||||
// Check for formulas that could execute code
|
||||
if (/=cmd\(|=powershell|=system|=exec/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for VBA macros
|
||||
if (/vbaProject|VBA|Macro/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for embedded scripts
|
||||
if (/<script[\s\S]*?<\/script>/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check SVG content for XSS
|
||||
*/
|
||||
private static checkSVGContent(content: string): boolean {
|
||||
// Check for script tags
|
||||
if (/<script[\s\S]*?<\/script>/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for event handlers
|
||||
if (/\s+on\w+\s*=/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for dangerous tags
|
||||
if (/<(iframe|object|embed|applet)[\s>]/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for protocol handlers
|
||||
if (/(javascript|data|vbscript):/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get XSS threat level for content
|
||||
* @param content - File content as string, Buffer, or Uint8Array
|
||||
* @param mimeType - MIME type
|
||||
* @returns Threat level: 'none', 'low', 'medium', 'high', 'critical'
|
||||
*/
|
||||
static getThreatLevel(content: string | Buffer | Uint8Array, mimeType: string): 'none' | 'low' | 'medium' | 'high' | 'critical' {
|
||||
let contentStr: string;
|
||||
if (typeof content === 'string') {
|
||||
contentStr = content;
|
||||
} else if (content instanceof Uint8Array) {
|
||||
contentStr = new TextDecoder().decode(content);
|
||||
} else {
|
||||
contentStr = (content as Buffer).toString('utf-8');
|
||||
}
|
||||
|
||||
// Critical threats
|
||||
if (/<script[\s\S]*?<\/script>/i.test(contentStr)) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if (/eval\s*\(/i.test(contentStr)) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if (/\/JavaScript|\/JS|\/OpenAction/i.test(contentStr)) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
// High threats
|
||||
if (/on\w+\s*=\s*["'][^"']*["']/i.test(contentStr)) {
|
||||
return 'high';
|
||||
}
|
||||
|
||||
if (/(javascript|vbscript):/i.test(contentStr)) {
|
||||
return 'high';
|
||||
}
|
||||
|
||||
if (/<(iframe|object|embed|applet)[\s>]/i.test(contentStr)) {
|
||||
return 'high';
|
||||
}
|
||||
|
||||
// Medium threats
|
||||
if (/data:text\/html/i.test(contentStr)) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
if (/vbaProject|VBA|Macro/i.test(contentStr)) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
// Low threats
|
||||
if (/<!--[\s\S]*?-->/i.test(contentStr)) {
|
||||
return 'low';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize content by removing XSS patterns
|
||||
* WARNING: This is a basic sanitization and may not catch all attacks
|
||||
* For production, use a dedicated sanitization library
|
||||
* @param content - File content
|
||||
* @param mimeType - MIME type
|
||||
* @returns Sanitized content
|
||||
*/
|
||||
static sanitizeContent(content: Buffer, mimeType: string): Buffer {
|
||||
let contentStr = content.toString('utf-8');
|
||||
|
||||
// Remove script tags
|
||||
contentStr = contentStr.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
|
||||
// Remove event handlers
|
||||
contentStr = contentStr.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '');
|
||||
contentStr = contentStr.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, '');
|
||||
|
||||
// Remove dangerous protocols
|
||||
contentStr = contentStr.replace(/javascript:/gi, '');
|
||||
contentStr = contentStr.replace(/vbscript:/gi, '');
|
||||
contentStr = contentStr.replace(/data:text\/html/gi, '');
|
||||
|
||||
// Remove dangerous tags
|
||||
contentStr = contentStr.replace(/<(iframe|object|embed|applet)[\s\S]*?<\/\1>/gi, '');
|
||||
|
||||
return Buffer.from(contentStr, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,287 +1,287 @@
|
||||
/**
|
||||
* Comprehensive client-side file upload validation
|
||||
* Prevents XSS attacks through file uploads
|
||||
*/
|
||||
|
||||
export interface FileValidationOptions {
|
||||
maxSizeMB?: number;
|
||||
allowedMimeTypes?: string[];
|
||||
allowedExtensions?: string[];
|
||||
}
|
||||
|
||||
export interface FileValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export class FileUploadValidator {
|
||||
private static readonly DEFAULT_MAX_SIZE_MB = 500;
|
||||
private static readonly DANGEROUS_EXTENSIONS = [
|
||||
".exe",
|
||||
".bat",
|
||||
".cmd",
|
||||
".com",
|
||||
".pif",
|
||||
".scr",
|
||||
".vbs",
|
||||
".js",
|
||||
".jar",
|
||||
".sh",
|
||||
".bash",
|
||||
".py",
|
||||
".pl",
|
||||
".php",
|
||||
".asp",
|
||||
".aspx",
|
||||
".jsp",
|
||||
".cfm",
|
||||
".cgi",
|
||||
".dll",
|
||||
".so",
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate file size
|
||||
*/
|
||||
static validateFileSize(
|
||||
file: File,
|
||||
maxSizeMB?: number,
|
||||
): FileValidationResult {
|
||||
const max = maxSizeMB || this.DEFAULT_MAX_SIZE_MB;
|
||||
const fileSizeMB = file.size / (1024 * 1024);
|
||||
|
||||
if (fileSizeMB > max) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `File size (${fileSizeMB.toFixed(2)}MB) exceeds maximum allowed size (${max}MB)`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file extension
|
||||
*/
|
||||
static validateFileExtension(
|
||||
file: File,
|
||||
allowedExtensions?: string[],
|
||||
): FileValidationResult {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const fileExtension = fileName.substring(fileName.lastIndexOf("."));
|
||||
|
||||
// Check for dangerous extensions
|
||||
if (this.DANGEROUS_EXTENSIONS.includes(fileExtension)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `File extension ${fileExtension} is not allowed for security reasons`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check against allowed extensions if specified
|
||||
if (allowedExtensions && allowedExtensions.length > 0) {
|
||||
const normalizedAllowed = allowedExtensions.map((ext) =>
|
||||
ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`,
|
||||
);
|
||||
|
||||
if (!normalizedAllowed.includes(fileExtension)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `File extension ${fileExtension} is not allowed. Allowed: ${normalizedAllowed.join(", ")}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate MIME type
|
||||
*/
|
||||
static validateMimeType(
|
||||
file: File,
|
||||
allowedMimeTypes?: string[],
|
||||
): FileValidationResult {
|
||||
if (!allowedMimeTypes || allowedMimeTypes.length === 0) {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// Check exact match
|
||||
if (allowedMimeTypes.includes(file.type)) {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// Check wildcard match (e.g., image/*)
|
||||
const wildcardMatch = allowedMimeTypes.some((allowed) => {
|
||||
if (allowed.endsWith("/*")) {
|
||||
const prefix = allowed.substring(0, allowed.length - 2);
|
||||
return file.type.startsWith(prefix);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (wildcardMatch) {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
error: `File type ${file.type} is not allowed. Allowed types: ${allowedMimeTypes.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file name for XSS
|
||||
*/
|
||||
static validateFileName(file: File): FileValidationResult {
|
||||
const fileName = file.name;
|
||||
|
||||
// Check for suspicious patterns
|
||||
const suspiciousPatterns = [
|
||||
/[<>:"\/\\|?*]/g, // Invalid filename characters
|
||||
/javascript:/i, // JavaScript protocol
|
||||
/data:/i, // Data URI
|
||||
/vbscript:/i, // VBScript protocol
|
||||
/on\w+\s*=/i, // Event handlers
|
||||
/<script/i, // Script tags (with < to avoid false positives)
|
||||
];
|
||||
|
||||
for (const pattern of suspiciousPatterns) {
|
||||
if (pattern.test(fileName)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `File name contains suspicious characters or patterns: ${fileName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for null bytes
|
||||
if (fileName.includes("\0")) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "File name contains null bytes",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for excessive length
|
||||
if (fileName.length > 255) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `File name is too long (${fileName.length} > 255 characters)`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive file validation
|
||||
*/
|
||||
static validateFile(
|
||||
file: File,
|
||||
options: FileValidationOptions = {},
|
||||
): FileValidationResult {
|
||||
// Validate file exists
|
||||
if (!file) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "No file provided",
|
||||
};
|
||||
}
|
||||
|
||||
// Validate file name
|
||||
const nameValidation = this.validateFileName(file);
|
||||
if (!nameValidation.isValid) {
|
||||
return nameValidation;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
const sizeValidation = this.validateFileSize(file, options.maxSizeMB);
|
||||
if (!sizeValidation.isValid) {
|
||||
return sizeValidation;
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
const extensionValidation = this.validateFileExtension(
|
||||
file,
|
||||
options.allowedExtensions,
|
||||
);
|
||||
if (!extensionValidation.isValid) {
|
||||
return extensionValidation;
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
const mimeValidation = this.validateMimeType(
|
||||
file,
|
||||
options.allowedMimeTypes,
|
||||
);
|
||||
if (!mimeValidation.isValid) {
|
||||
return mimeValidation;
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate multiple files
|
||||
*/
|
||||
static validateFiles(
|
||||
files: File[],
|
||||
options: FileValidationOptions = {},
|
||||
): { valid: File[]; invalid: Array<{ file: File; error: string }> } {
|
||||
const valid: File[] = [];
|
||||
const invalid: Array<{ file: File; error: string }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
const result = this.validateFile(file, options);
|
||||
if (result.isValid) {
|
||||
valid.push(file);
|
||||
} else {
|
||||
invalid.push({
|
||||
file,
|
||||
error: result.error || "Validation failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { valid, invalid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file type category
|
||||
*/
|
||||
static getFileCategory(
|
||||
file: File,
|
||||
): "image" | "document" | "archive" | "other" {
|
||||
const mimeType = file.type.toLowerCase();
|
||||
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (
|
||||
mimeType.includes("pdf") ||
|
||||
mimeType.includes("word") ||
|
||||
mimeType.includes("sheet")
|
||||
) {
|
||||
return "document";
|
||||
}
|
||||
if (
|
||||
mimeType.includes("zip") ||
|
||||
mimeType.includes("rar") ||
|
||||
mimeType.includes("7z")
|
||||
) {
|
||||
return "archive";
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize file name for safe display
|
||||
*/
|
||||
static sanitizeFileName(fileName: string): string {
|
||||
return fileName
|
||||
.replace(/[<>:"\/\\|?*]/g, "_")
|
||||
.replace(/\0/g, "")
|
||||
.substring(0, 255);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Comprehensive client-side file upload validation
|
||||
* Prevents XSS attacks through file uploads
|
||||
*/
|
||||
|
||||
export interface FileValidationOptions {
|
||||
maxSizeMB?: number;
|
||||
allowedMimeTypes?: string[];
|
||||
allowedExtensions?: string[];
|
||||
}
|
||||
|
||||
export interface FileValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export class FileUploadValidator {
|
||||
private static readonly DEFAULT_MAX_SIZE_MB = 500;
|
||||
private static readonly DANGEROUS_EXTENSIONS = [
|
||||
".exe",
|
||||
".bat",
|
||||
".cmd",
|
||||
".com",
|
||||
".pif",
|
||||
".scr",
|
||||
".vbs",
|
||||
".js",
|
||||
".jar",
|
||||
".sh",
|
||||
".bash",
|
||||
".py",
|
||||
".pl",
|
||||
".php",
|
||||
".asp",
|
||||
".aspx",
|
||||
".jsp",
|
||||
".cfm",
|
||||
".cgi",
|
||||
".dll",
|
||||
".so",
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate file size
|
||||
*/
|
||||
static validateFileSize(
|
||||
file: File,
|
||||
maxSizeMB?: number,
|
||||
): FileValidationResult {
|
||||
const max = maxSizeMB || this.DEFAULT_MAX_SIZE_MB;
|
||||
const fileSizeMB = file.size / (1024 * 1024);
|
||||
|
||||
if (fileSizeMB > max) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `File size (${fileSizeMB.toFixed(2)}MB) exceeds maximum allowed size (${max}MB)`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file extension
|
||||
*/
|
||||
static validateFileExtension(
|
||||
file: File,
|
||||
allowedExtensions?: string[],
|
||||
): FileValidationResult {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const fileExtension = fileName.substring(fileName.lastIndexOf("."));
|
||||
|
||||
// Check for dangerous extensions
|
||||
if (this.DANGEROUS_EXTENSIONS.includes(fileExtension)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `File extension ${fileExtension} is not allowed for security reasons`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check against allowed extensions if specified
|
||||
if (allowedExtensions && allowedExtensions.length > 0) {
|
||||
const normalizedAllowed = allowedExtensions.map((ext) =>
|
||||
ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`,
|
||||
);
|
||||
|
||||
if (!normalizedAllowed.includes(fileExtension)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `File extension ${fileExtension} is not allowed. Allowed: ${normalizedAllowed.join(", ")}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate MIME type
|
||||
*/
|
||||
static validateMimeType(
|
||||
file: File,
|
||||
allowedMimeTypes?: string[],
|
||||
): FileValidationResult {
|
||||
if (!allowedMimeTypes || allowedMimeTypes.length === 0) {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// Check exact match
|
||||
if (allowedMimeTypes.includes(file.type)) {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// Check wildcard match (e.g., image/*)
|
||||
const wildcardMatch = allowedMimeTypes.some((allowed) => {
|
||||
if (allowed.endsWith("/*")) {
|
||||
const prefix = allowed.substring(0, allowed.length - 2);
|
||||
return file.type.startsWith(prefix);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (wildcardMatch) {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
error: `File type ${file.type} is not allowed. Allowed types: ${allowedMimeTypes.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file name for XSS
|
||||
*/
|
||||
static validateFileName(file: File): FileValidationResult {
|
||||
const fileName = file.name;
|
||||
|
||||
// Check for suspicious patterns
|
||||
const suspiciousPatterns = [
|
||||
/[<>:"\/\\|?*]/g, // Invalid filename characters
|
||||
/javascript:/i, // JavaScript protocol
|
||||
/data:/i, // Data URI
|
||||
/vbscript:/i, // VBScript protocol
|
||||
/on\w+\s*=/i, // Event handlers
|
||||
/<script/i, // Script tags (with < to avoid false positives)
|
||||
];
|
||||
|
||||
for (const pattern of suspiciousPatterns) {
|
||||
if (pattern.test(fileName)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `File name contains suspicious characters or patterns: ${fileName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for null bytes
|
||||
if (fileName.includes("\0")) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "File name contains null bytes",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for excessive length
|
||||
if (fileName.length > 255) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `File name is too long (${fileName.length} > 255 characters)`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive file validation
|
||||
*/
|
||||
static validateFile(
|
||||
file: File,
|
||||
options: FileValidationOptions = {},
|
||||
): FileValidationResult {
|
||||
// Validate file exists
|
||||
if (!file) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "No file provided",
|
||||
};
|
||||
}
|
||||
|
||||
// Validate file name
|
||||
const nameValidation = this.validateFileName(file);
|
||||
if (!nameValidation.isValid) {
|
||||
return nameValidation;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
const sizeValidation = this.validateFileSize(file, options.maxSizeMB);
|
||||
if (!sizeValidation.isValid) {
|
||||
return sizeValidation;
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
const extensionValidation = this.validateFileExtension(
|
||||
file,
|
||||
options.allowedExtensions,
|
||||
);
|
||||
if (!extensionValidation.isValid) {
|
||||
return extensionValidation;
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
const mimeValidation = this.validateMimeType(
|
||||
file,
|
||||
options.allowedMimeTypes,
|
||||
);
|
||||
if (!mimeValidation.isValid) {
|
||||
return mimeValidation;
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate multiple files
|
||||
*/
|
||||
static validateFiles(
|
||||
files: File[],
|
||||
options: FileValidationOptions = {},
|
||||
): { valid: File[]; invalid: Array<{ file: File; error: string }> } {
|
||||
const valid: File[] = [];
|
||||
const invalid: Array<{ file: File; error: string }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
const result = this.validateFile(file, options);
|
||||
if (result.isValid) {
|
||||
valid.push(file);
|
||||
} else {
|
||||
invalid.push({
|
||||
file,
|
||||
error: result.error || "Validation failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { valid, invalid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file type category
|
||||
*/
|
||||
static getFileCategory(
|
||||
file: File,
|
||||
): "image" | "document" | "archive" | "other" {
|
||||
const mimeType = file.type.toLowerCase();
|
||||
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (
|
||||
mimeType.includes("pdf") ||
|
||||
mimeType.includes("word") ||
|
||||
mimeType.includes("sheet")
|
||||
) {
|
||||
return "document";
|
||||
}
|
||||
if (
|
||||
mimeType.includes("zip") ||
|
||||
mimeType.includes("rar") ||
|
||||
mimeType.includes("7z")
|
||||
) {
|
||||
return "archive";
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize file name for safe display
|
||||
*/
|
||||
static sanitizeFileName(fileName: string): string {
|
||||
return fileName
|
||||
.replace(/[<>:"\/\\|?*]/g, "_")
|
||||
.replace(/\0/g, "")
|
||||
.substring(0, 255);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,153 +1,153 @@
|
||||
/**
|
||||
* Magic byte detector for identifying actual file types from content
|
||||
* Prevents file type spoofing attacks
|
||||
*/
|
||||
|
||||
export interface MagicByteSignature {
|
||||
mime: string;
|
||||
ext: string;
|
||||
bytes: number[];
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export class MagicByteDetector {
|
||||
// Common file signatures (magic bytes)
|
||||
private static readonly SIGNATURES: MagicByteSignature[] = [
|
||||
// PNG: 89 50 4E 47 8D 0A 1A 0A
|
||||
{ mime: 'image/png', ext: '.png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x8d, 0x0a, 0x1a, 0x0a] },
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
{ mime: 'image/jpeg', ext: '.jpg', bytes: [0xff, 0xd8, 0xff], offset: 0 },
|
||||
|
||||
// GIF87a: 47 49 46 38 37 61
|
||||
{ mime: 'image/gif', ext: '.gif', bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] },
|
||||
|
||||
// GIF89a: 47 49 46 38 39 61
|
||||
{ mime: 'image/gif', ext: '.gif', bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] },
|
||||
|
||||
// SVG: 3C 3F 78 6D 6C (<?xml) or 3C 73 76 67 (<svg)
|
||||
{ mime: 'image/svg+xml', ext: '.svg', bytes: [0x3c, 0x3f, 0x78, 0x6d, 0x6c] },
|
||||
{ mime: 'image/svg+xml', ext: '.svg', bytes: [0x3c, 0x73, 0x76, 0x67] },
|
||||
|
||||
// PDF: 25 50 44 46 (%PDF)
|
||||
{ mime: 'application/pdf', ext: '.pdf', bytes: [0x25, 0x50, 0x44, 0x46] },
|
||||
|
||||
// ZIP: 50 4B 03 04 (PK..)
|
||||
{ mime: 'application/zip', ext: '.zip', bytes: [0x50, 0x4b, 0x03, 0x04] },
|
||||
|
||||
// DOCX/XLSX (ZIP-based): 50 4B 03 04
|
||||
{ mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ext: '.docx', bytes: [0x50, 0x4b, 0x03, 0x04] },
|
||||
{ mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ext: '.xlsx', bytes: [0x50, 0x4b, 0x03, 0x04] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Detect file type from magic bytes
|
||||
* @param buffer - File content as Uint8Array
|
||||
* @returns Detected MIME type and extension, or null if not recognized
|
||||
*/
|
||||
static detectFileType(buffer: Uint8Array): { mime: string; ext: string } | null {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check each signature
|
||||
for (const sig of this.SIGNATURES) {
|
||||
const offset = sig.offset || 0;
|
||||
|
||||
// Check if buffer has enough bytes
|
||||
if (buffer.length < offset + sig.bytes.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if bytes match
|
||||
let match = true;
|
||||
for (let i = 0; i < sig.bytes.length; i++) {
|
||||
if (buffer[offset + i] !== sig.bytes[i]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
return { mime: sig.mime, ext: sig.ext };
|
||||
}
|
||||
}
|
||||
|
||||
// Additional check: look for text content that looks like XML/SVG/HTML
|
||||
// This catches files that might not have standard magic bytes
|
||||
try {
|
||||
const textContent = new TextDecoder().decode(buffer.slice(0, 1000));
|
||||
|
||||
// Check for SVG content
|
||||
if (/<svg[\s>]/i.test(textContent) || /<\?xml[\s>]/i.test(textContent)) {
|
||||
return { mime: 'image/svg+xml', ext: '.svg' };
|
||||
}
|
||||
|
||||
// Check for HTML content
|
||||
if (/<html[\s>]/i.test(textContent) || /<!DOCTYPE\s+html/i.test(textContent)) {
|
||||
return { mime: 'text/html', ext: '.html' };
|
||||
}
|
||||
} catch (e) {
|
||||
// If text decoding fails, continue
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that declared MIME type matches actual file content
|
||||
* @param buffer - File content
|
||||
* @param declaredMime - MIME type declared by browser/user
|
||||
* @returns true if types match, false if mismatch or cannot be determined
|
||||
*/
|
||||
static validateMimeTypeMatch(buffer: Uint8Array, declaredMime: string): boolean {
|
||||
const detected = this.detectFileType(buffer);
|
||||
|
||||
// If we can't detect the type from magic bytes, reject it for image files
|
||||
// (images should always have recognizable magic bytes)
|
||||
if (!detected) {
|
||||
// For image files, if we can't detect magic bytes, it's suspicious
|
||||
if (declaredMime.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
// For other types, allow it (might be a valid file we don't recognize)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for exact match
|
||||
if (detected.mime === declaredMime) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for wildcard match (e.g., image/*)
|
||||
const [declaredType] = declaredMime.split('/');
|
||||
const [detectedType] = detected.mime.split('/');
|
||||
|
||||
if (declaredType === detectedType && declaredType !== '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mismatch detected
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable file type description
|
||||
* @param mime - MIME type
|
||||
* @returns Description of file type
|
||||
*/
|
||||
static getFileTypeDescription(mime: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
'image/png': 'PNG Image',
|
||||
'image/jpeg': 'JPEG Image',
|
||||
'image/gif': 'GIF Image',
|
||||
'image/svg+xml': 'SVG Image',
|
||||
'application/pdf': 'PDF Document',
|
||||
'application/zip': 'ZIP Archive',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word Document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel Spreadsheet',
|
||||
};
|
||||
|
||||
return descriptions[mime] || mime;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Magic byte detector for identifying actual file types from content
|
||||
* Prevents file type spoofing attacks
|
||||
*/
|
||||
|
||||
export interface MagicByteSignature {
|
||||
mime: string;
|
||||
ext: string;
|
||||
bytes: number[];
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export class MagicByteDetector {
|
||||
// Common file signatures (magic bytes)
|
||||
private static readonly SIGNATURES: MagicByteSignature[] = [
|
||||
// PNG: 89 50 4E 47 8D 0A 1A 0A
|
||||
{ mime: 'image/png', ext: '.png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x8d, 0x0a, 0x1a, 0x0a] },
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
{ mime: 'image/jpeg', ext: '.jpg', bytes: [0xff, 0xd8, 0xff], offset: 0 },
|
||||
|
||||
// GIF87a: 47 49 46 38 37 61
|
||||
{ mime: 'image/gif', ext: '.gif', bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] },
|
||||
|
||||
// GIF89a: 47 49 46 38 39 61
|
||||
{ mime: 'image/gif', ext: '.gif', bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] },
|
||||
|
||||
// SVG: 3C 3F 78 6D 6C (<?xml) or 3C 73 76 67 (<svg)
|
||||
{ mime: 'image/svg+xml', ext: '.svg', bytes: [0x3c, 0x3f, 0x78, 0x6d, 0x6c] },
|
||||
{ mime: 'image/svg+xml', ext: '.svg', bytes: [0x3c, 0x73, 0x76, 0x67] },
|
||||
|
||||
// PDF: 25 50 44 46 (%PDF)
|
||||
{ mime: 'application/pdf', ext: '.pdf', bytes: [0x25, 0x50, 0x44, 0x46] },
|
||||
|
||||
// ZIP: 50 4B 03 04 (PK..)
|
||||
{ mime: 'application/zip', ext: '.zip', bytes: [0x50, 0x4b, 0x03, 0x04] },
|
||||
|
||||
// DOCX/XLSX (ZIP-based): 50 4B 03 04
|
||||
{ mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ext: '.docx', bytes: [0x50, 0x4b, 0x03, 0x04] },
|
||||
{ mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ext: '.xlsx', bytes: [0x50, 0x4b, 0x03, 0x04] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Detect file type from magic bytes
|
||||
* @param buffer - File content as Uint8Array
|
||||
* @returns Detected MIME type and extension, or null if not recognized
|
||||
*/
|
||||
static detectFileType(buffer: Uint8Array): { mime: string; ext: string } | null {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check each signature
|
||||
for (const sig of this.SIGNATURES) {
|
||||
const offset = sig.offset || 0;
|
||||
|
||||
// Check if buffer has enough bytes
|
||||
if (buffer.length < offset + sig.bytes.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if bytes match
|
||||
let match = true;
|
||||
for (let i = 0; i < sig.bytes.length; i++) {
|
||||
if (buffer[offset + i] !== sig.bytes[i]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
return { mime: sig.mime, ext: sig.ext };
|
||||
}
|
||||
}
|
||||
|
||||
// Additional check: look for text content that looks like XML/SVG/HTML
|
||||
// This catches files that might not have standard magic bytes
|
||||
try {
|
||||
const textContent = new TextDecoder().decode(buffer.slice(0, 1000));
|
||||
|
||||
// Check for SVG content
|
||||
if (/<svg[\s>]/i.test(textContent) || /<\?xml[\s>]/i.test(textContent)) {
|
||||
return { mime: 'image/svg+xml', ext: '.svg' };
|
||||
}
|
||||
|
||||
// Check for HTML content
|
||||
if (/<html[\s>]/i.test(textContent) || /<!DOCTYPE\s+html/i.test(textContent)) {
|
||||
return { mime: 'text/html', ext: '.html' };
|
||||
}
|
||||
} catch (e) {
|
||||
// If text decoding fails, continue
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that declared MIME type matches actual file content
|
||||
* @param buffer - File content
|
||||
* @param declaredMime - MIME type declared by browser/user
|
||||
* @returns true if types match, false if mismatch or cannot be determined
|
||||
*/
|
||||
static validateMimeTypeMatch(buffer: Uint8Array, declaredMime: string): boolean {
|
||||
const detected = this.detectFileType(buffer);
|
||||
|
||||
// If we can't detect the type from magic bytes, reject it for image files
|
||||
// (images should always have recognizable magic bytes)
|
||||
if (!detected) {
|
||||
// For image files, if we can't detect magic bytes, it's suspicious
|
||||
if (declaredMime.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
// For other types, allow it (might be a valid file we don't recognize)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for exact match
|
||||
if (detected.mime === declaredMime) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for wildcard match (e.g., image/*)
|
||||
const [declaredType] = declaredMime.split('/');
|
||||
const [detectedType] = detected.mime.split('/');
|
||||
|
||||
if (declaredType === detectedType && declaredType !== '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mismatch detected
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable file type description
|
||||
* @param mime - MIME type
|
||||
* @returns Description of file type
|
||||
*/
|
||||
static getFileTypeDescription(mime: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
'image/png': 'PNG Image',
|
||||
'image/jpeg': 'JPEG Image',
|
||||
'image/gif': 'GIF Image',
|
||||
'image/svg+xml': 'SVG Image',
|
||||
'application/pdf': 'PDF Document',
|
||||
'application/zip': 'ZIP Archive',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word Document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel Spreadsheet',
|
||||
};
|
||||
|
||||
return descriptions[mime] || mime;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +1,114 @@
|
||||
# File Upload Validation System
|
||||
|
||||
## Architecture
|
||||
|
||||
This validation system is designed to work with a **server-side backend** that handles the actual file type detection using the `file-type` library.
|
||||
|
||||
### Why Server-Side Only?
|
||||
|
||||
1. **`file-type` is ESM-only** - Cannot be bundled for browser use
|
||||
2. **Security** - Validation should happen on the server, not client
|
||||
3. **Reliability** - Server-side validation cannot be bypassed
|
||||
4. **Performance** - Reduces client-side bundle size
|
||||
|
||||
## How It Works
|
||||
|
||||
### Client-Side (Browser)
|
||||
1. User selects file
|
||||
2. File is sent to server via presigned URL or direct upload
|
||||
3. Server validates the file buffer
|
||||
|
||||
### Server-Side (Backend)
|
||||
1. Receive file buffer
|
||||
2. Use `BufferContentValidator` to detect actual MIME type
|
||||
3. Compare with declared MIME type
|
||||
4. Return validation result
|
||||
5. Log validation event via `SecurityLogger`
|
||||
|
||||
## Implementation
|
||||
|
||||
### For Backend Developers
|
||||
|
||||
The `BufferContentValidator` class is designed to be used on your backend:
|
||||
|
||||
```typescript
|
||||
import { BufferContentValidator } from '@/shared/services/validation';
|
||||
|
||||
const validator = new BufferContentValidator();
|
||||
|
||||
// Detect file type from buffer
|
||||
const detected = await validator.detectFileType(buffer);
|
||||
|
||||
// Validate MIME type
|
||||
const result = await validator.validateMimeType(buffer, declaredMime);
|
||||
|
||||
// Check whitelist
|
||||
const isAllowed = validator.isAllowedMimeType(mimeType, allowedTypes);
|
||||
```
|
||||
|
||||
### For Frontend Developers
|
||||
|
||||
The frontend code in `fileService.ts` calls the backend validation:
|
||||
|
||||
```typescript
|
||||
// This happens on the server/backend
|
||||
const validationResult = await bufferContentValidator.validateMimeType(
|
||||
buffer,
|
||||
declaredMime
|
||||
);
|
||||
|
||||
// Log the event
|
||||
await securityLogger.logValidationEvent({
|
||||
// ... event details
|
||||
});
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- **`BufferContentValidator.ts`** - Core validation logic (server-side)
|
||||
- **`SecurityLogger.ts`** - Audit logging (server-side)
|
||||
- **`types.ts`** - Shared TypeScript interfaces
|
||||
- **`index.ts`** - Clean exports
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use mocked `file-type` to avoid ESM issues:
|
||||
|
||||
```typescript
|
||||
jest.mock('file-type', () => ({
|
||||
fileTypeFromBuffer: jest.fn(async (buffer: Buffer) => {
|
||||
// Mock implementation
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Backend Integration**: Implement validation endpoint on your backend
|
||||
2. **API Contract**: Define request/response format for validation
|
||||
3. **Error Handling**: Handle validation errors gracefully
|
||||
4. **Logging**: Integrate with your logging service
|
||||
|
||||
## Example Backend Implementation
|
||||
|
||||
```typescript
|
||||
// backend/routes/validate-upload.ts
|
||||
import { BufferContentValidator } from '@/shared/services/validation';
|
||||
|
||||
app.post('/api/validate-upload', async (req, res) => {
|
||||
const { buffer, declaredMime } = req.body;
|
||||
const validator = new BufferContentValidator();
|
||||
|
||||
const result = await validator.validateMimeType(buffer, declaredMime);
|
||||
|
||||
if (!result.isValid) {
|
||||
return res.status(400).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Note**: The `file-type` package is only needed on the backend. The frontend uses the validation results from the backend API.
|
||||
# File Upload Validation System
|
||||
|
||||
## Architecture
|
||||
|
||||
This validation system is designed to work with a **server-side backend** that handles the actual file type detection using the `file-type` library.
|
||||
|
||||
### Why Server-Side Only?
|
||||
|
||||
1. **`file-type` is ESM-only** - Cannot be bundled for browser use
|
||||
2. **Security** - Validation should happen on the server, not client
|
||||
3. **Reliability** - Server-side validation cannot be bypassed
|
||||
4. **Performance** - Reduces client-side bundle size
|
||||
|
||||
## How It Works
|
||||
|
||||
### Client-Side (Browser)
|
||||
1. User selects file
|
||||
2. File is sent to server via presigned URL or direct upload
|
||||
3. Server validates the file buffer
|
||||
|
||||
### Server-Side (Backend)
|
||||
1. Receive file buffer
|
||||
2. Use `BufferContentValidator` to detect actual MIME type
|
||||
3. Compare with declared MIME type
|
||||
4. Return validation result
|
||||
5. Log validation event via `SecurityLogger`
|
||||
|
||||
## Implementation
|
||||
|
||||
### For Backend Developers
|
||||
|
||||
The `BufferContentValidator` class is designed to be used on your backend:
|
||||
|
||||
```typescript
|
||||
import { BufferContentValidator } from '@/shared/services/validation';
|
||||
|
||||
const validator = new BufferContentValidator();
|
||||
|
||||
// Detect file type from buffer
|
||||
const detected = await validator.detectFileType(buffer);
|
||||
|
||||
// Validate MIME type
|
||||
const result = await validator.validateMimeType(buffer, declaredMime);
|
||||
|
||||
// Check whitelist
|
||||
const isAllowed = validator.isAllowedMimeType(mimeType, allowedTypes);
|
||||
```
|
||||
|
||||
### For Frontend Developers
|
||||
|
||||
The frontend code in `fileService.ts` calls the backend validation:
|
||||
|
||||
```typescript
|
||||
// This happens on the server/backend
|
||||
const validationResult = await bufferContentValidator.validateMimeType(
|
||||
buffer,
|
||||
declaredMime
|
||||
);
|
||||
|
||||
// Log the event
|
||||
await securityLogger.logValidationEvent({
|
||||
// ... event details
|
||||
});
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- **`BufferContentValidator.ts`** - Core validation logic (server-side)
|
||||
- **`SecurityLogger.ts`** - Audit logging (server-side)
|
||||
- **`types.ts`** - Shared TypeScript interfaces
|
||||
- **`index.ts`** - Clean exports
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use mocked `file-type` to avoid ESM issues:
|
||||
|
||||
```typescript
|
||||
jest.mock('file-type', () => ({
|
||||
fileTypeFromBuffer: jest.fn(async (buffer: Buffer) => {
|
||||
// Mock implementation
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Backend Integration**: Implement validation endpoint on your backend
|
||||
2. **API Contract**: Define request/response format for validation
|
||||
3. **Error Handling**: Handle validation errors gracefully
|
||||
4. **Logging**: Integrate with your logging service
|
||||
|
||||
## Example Backend Implementation
|
||||
|
||||
```typescript
|
||||
// backend/routes/validate-upload.ts
|
||||
import { BufferContentValidator } from '@/shared/services/validation';
|
||||
|
||||
app.post('/api/validate-upload', async (req, res) => {
|
||||
const { buffer, declaredMime } = req.body;
|
||||
const validator = new BufferContentValidator();
|
||||
|
||||
const result = await validator.validateMimeType(buffer, declaredMime);
|
||||
|
||||
if (!result.isValid) {
|
||||
return res.status(400).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Note**: The `file-type` package is only needed on the backend. The frontend uses the validation results from the backend API.
|
||||
|
||||
@@ -1,152 +1,152 @@
|
||||
/**
|
||||
* SVG content validator to detect XSS attacks in SVG files
|
||||
* SVG files are XML-based and can contain embedded JavaScript
|
||||
*/
|
||||
|
||||
export class SVGContentValidator {
|
||||
/**
|
||||
* Check if content contains SVG with embedded scripts
|
||||
* @param content - File content as string or buffer
|
||||
* @returns True if malicious scripts are detected
|
||||
*/
|
||||
static containsMaliciousScripts(content: string | Buffer): boolean {
|
||||
const contentStr = typeof content === 'string' ? content : content.toString('utf-8');
|
||||
|
||||
// Check for script tags
|
||||
if (/<script[\s>]/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for event handlers (onclick, onload, etc.)
|
||||
if (/on\w+\s*=/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for javascript: protocol
|
||||
if (/javascript:/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for data: protocol (can be used for XSS)
|
||||
if (/data:text\/html/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for vbscript: protocol
|
||||
if (/vbscript:/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for iframe tags
|
||||
if (/<iframe[\s>]/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for object/embed tags
|
||||
if (/<(object|embed)[\s>]/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for style tags with expressions
|
||||
if (/<style[\s>][\s\S]*?expression\s*\(/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SVG file for XSS threats
|
||||
* @param buffer - File buffer (Buffer or Uint8Array)
|
||||
* @param fileName - File name for context
|
||||
* @returns Validation result
|
||||
*/
|
||||
static validateSVGContent(
|
||||
buffer: Buffer | Uint8Array,
|
||||
fileName: string
|
||||
): { isValid: boolean; error?: string } {
|
||||
try {
|
||||
// Check if it's valid UTF-8 (SVG should be text-based)
|
||||
let content: string;
|
||||
if (buffer instanceof Uint8Array) {
|
||||
content = new TextDecoder().decode(buffer);
|
||||
} else {
|
||||
content = (buffer as Buffer).toString('utf-8');
|
||||
}
|
||||
|
||||
// Check for malicious scripts
|
||||
if (this.containsMaliciousScripts(content)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `SVG file contains potentially malicious scripts or event handlers`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's valid XML
|
||||
if (!this.isValidXML(content)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `SVG file is not valid XML`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Failed to validate SVG content: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content is valid XML
|
||||
* @param content - XML content as string
|
||||
* @returns True if valid XML
|
||||
*/
|
||||
private static isValidXML(content: string): boolean {
|
||||
try {
|
||||
// Basic XML validation - check for matching tags
|
||||
const openTags = (content.match(/<[^/][^>]*>/g) || []).length;
|
||||
const closeTags = (content.match(/<\/[^>]*>/g) || []).length;
|
||||
|
||||
// Should have at least one root element
|
||||
return openTags > 0 && closeTags > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize SVG by removing potentially dangerous elements
|
||||
* @param buffer - SVG file buffer
|
||||
* @returns Sanitized SVG content
|
||||
*/
|
||||
static sanitizeSVG(buffer: Buffer): Buffer {
|
||||
let content = buffer.toString('utf-8');
|
||||
|
||||
// Remove script tags and content
|
||||
content = content.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
|
||||
// Remove event handlers
|
||||
content = content.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '');
|
||||
content = content.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, '');
|
||||
|
||||
// Remove javascript: protocol
|
||||
content = content.replace(/javascript:/gi, '');
|
||||
|
||||
// Remove data:text/html protocol
|
||||
content = content.replace(/data:text\/html/gi, '');
|
||||
|
||||
// Remove vbscript: protocol
|
||||
content = content.replace(/vbscript:/gi, '');
|
||||
|
||||
// Remove iframe tags
|
||||
content = content.replace(/<iframe[\s\S]*?<\/iframe>/gi, '');
|
||||
|
||||
// Remove object/embed tags
|
||||
content = content.replace(/<(object|embed)[^>]*>/gi, '');
|
||||
|
||||
return Buffer.from(content, 'utf-8');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* SVG content validator to detect XSS attacks in SVG files
|
||||
* SVG files are XML-based and can contain embedded JavaScript
|
||||
*/
|
||||
|
||||
export class SVGContentValidator {
|
||||
/**
|
||||
* Check if content contains SVG with embedded scripts
|
||||
* @param content - File content as string or buffer
|
||||
* @returns True if malicious scripts are detected
|
||||
*/
|
||||
static containsMaliciousScripts(content: string | Buffer): boolean {
|
||||
const contentStr = typeof content === 'string' ? content : content.toString('utf-8');
|
||||
|
||||
// Check for script tags
|
||||
if (/<script[\s>]/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for event handlers (onclick, onload, etc.)
|
||||
if (/on\w+\s*=/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for javascript: protocol
|
||||
if (/javascript:/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for data: protocol (can be used for XSS)
|
||||
if (/data:text\/html/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for vbscript: protocol
|
||||
if (/vbscript:/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for iframe tags
|
||||
if (/<iframe[\s>]/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for object/embed tags
|
||||
if (/<(object|embed)[\s>]/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for style tags with expressions
|
||||
if (/<style[\s>][\s\S]*?expression\s*\(/i.test(contentStr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SVG file for XSS threats
|
||||
* @param buffer - File buffer (Buffer or Uint8Array)
|
||||
* @param fileName - File name for context
|
||||
* @returns Validation result
|
||||
*/
|
||||
static validateSVGContent(
|
||||
buffer: Buffer | Uint8Array,
|
||||
fileName: string
|
||||
): { isValid: boolean; error?: string } {
|
||||
try {
|
||||
// Check if it's valid UTF-8 (SVG should be text-based)
|
||||
let content: string;
|
||||
if (buffer instanceof Uint8Array) {
|
||||
content = new TextDecoder().decode(buffer);
|
||||
} else {
|
||||
content = (buffer as Buffer).toString('utf-8');
|
||||
}
|
||||
|
||||
// Check for malicious scripts
|
||||
if (this.containsMaliciousScripts(content)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `SVG file contains potentially malicious scripts or event handlers`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's valid XML
|
||||
if (!this.isValidXML(content)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `SVG file is not valid XML`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Failed to validate SVG content: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content is valid XML
|
||||
* @param content - XML content as string
|
||||
* @returns True if valid XML
|
||||
*/
|
||||
private static isValidXML(content: string): boolean {
|
||||
try {
|
||||
// Basic XML validation - check for matching tags
|
||||
const openTags = (content.match(/<[^/][^>]*>/g) || []).length;
|
||||
const closeTags = (content.match(/<\/[^>]*>/g) || []).length;
|
||||
|
||||
// Should have at least one root element
|
||||
return openTags > 0 && closeTags > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize SVG by removing potentially dangerous elements
|
||||
* @param buffer - SVG file buffer
|
||||
* @returns Sanitized SVG content
|
||||
*/
|
||||
static sanitizeSVG(buffer: Buffer): Buffer {
|
||||
let content = buffer.toString('utf-8');
|
||||
|
||||
// Remove script tags and content
|
||||
content = content.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
|
||||
// Remove event handlers
|
||||
content = content.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '');
|
||||
content = content.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, '');
|
||||
|
||||
// Remove javascript: protocol
|
||||
content = content.replace(/javascript:/gi, '');
|
||||
|
||||
// Remove data:text/html protocol
|
||||
content = content.replace(/data:text\/html/gi, '');
|
||||
|
||||
// Remove vbscript: protocol
|
||||
content = content.replace(/vbscript:/gi, '');
|
||||
|
||||
// Remove iframe tags
|
||||
content = content.replace(/<iframe[\s\S]*?<\/iframe>/gi, '');
|
||||
|
||||
// Remove object/embed tags
|
||||
content = content.replace(/<(object|embed)[^>]*>/gi, '');
|
||||
|
||||
return Buffer.from(content, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +1,165 @@
|
||||
import { ValidationLogEntry, XSSValidationLogEntry } from './types';
|
||||
|
||||
/**
|
||||
* Central security logging service for file validation events
|
||||
* Records all validation attempts for audit and investigation
|
||||
*/
|
||||
export class SecurityLogger {
|
||||
private logs: ValidationLogEntry[] = [];
|
||||
private xssLogs: XSSValidationLogEntry[] = [];
|
||||
|
||||
/**
|
||||
* Log a validation event
|
||||
* @param entry - Validation log entry with all required metadata
|
||||
*/
|
||||
async logValidationEvent(entry: ValidationLogEntry): Promise<void> {
|
||||
try {
|
||||
// Ensure timestamp is set
|
||||
if (!entry.timestamp) {
|
||||
entry.timestamp = new Date();
|
||||
}
|
||||
|
||||
// Store log entry
|
||||
this.logs.push(entry);
|
||||
|
||||
// In production, this would write to a secure logging service
|
||||
// For now, we log to console in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[SECURITY LOG]', {
|
||||
timestamp: entry.timestamp.toISOString(),
|
||||
module: entry.module,
|
||||
endpoint: entry.endpoint,
|
||||
result: entry.validationResult,
|
||||
declaredMime: entry.declaredMime,
|
||||
detectedMime: entry.detectedMime,
|
||||
error: entry.errorDetails,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: In production, integrate with your logging service
|
||||
// Examples:
|
||||
// - Send to CloudWatch, DataDog, Splunk, etc.
|
||||
// - Write to database audit table
|
||||
// - Send to centralized logging system
|
||||
} catch (error) {
|
||||
console.error('Failed to log validation event:', error);
|
||||
// Don't throw - logging failures shouldn't break uploads
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an XSS validation event
|
||||
* @param entry - XSS validation log entry
|
||||
*/
|
||||
async logXSSValidationEvent(entry: XSSValidationLogEntry): Promise<void> {
|
||||
try {
|
||||
// Ensure timestamp is set
|
||||
if (!entry.timestamp) {
|
||||
entry.timestamp = new Date();
|
||||
}
|
||||
|
||||
// Store log entry
|
||||
this.xssLogs.push(entry);
|
||||
|
||||
// Log to console in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[XSS VALIDATION LOG]', {
|
||||
timestamp: entry.timestamp.toISOString(),
|
||||
module: entry.module,
|
||||
uploadContext: entry.uploadContext,
|
||||
result: entry.validationResult,
|
||||
fileName: entry.sanitizedFileName, // Use sanitized name for logging
|
||||
declaredMime: entry.declaredMimeType,
|
||||
detectedMime: entry.detectedMimeType,
|
||||
failureReason: entry.failureReason,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: In production, integrate with your logging service
|
||||
// Ensure sensitive file content is NOT logged
|
||||
} catch (error) {
|
||||
console.error('Failed to log XSS validation event:', error);
|
||||
// Don't throw - logging failures shouldn't break uploads
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query validation logs for investigation
|
||||
* @param filters - Filter criteria for logs
|
||||
* @returns Matching log entries
|
||||
*/
|
||||
async queryValidationLogs(filters: {
|
||||
module?: string;
|
||||
validationResult?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
userId?: string;
|
||||
}): Promise<ValidationLogEntry[]> {
|
||||
return this.logs.filter((log) => {
|
||||
if (filters.module && log.module !== filters.module) return false;
|
||||
if (
|
||||
filters.validationResult &&
|
||||
log.validationResult !== filters.validationResult
|
||||
)
|
||||
return false;
|
||||
if (filters.startDate && log.timestamp < filters.startDate) return false;
|
||||
if (filters.endDate && log.timestamp > filters.endDate) return false;
|
||||
if (filters.userId && log.userId !== filters.userId) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query XSS validation logs for investigation
|
||||
* @param filters - Filter criteria for logs
|
||||
* @returns Matching XSS validation log entries
|
||||
*/
|
||||
async queryXSSValidationLogs(filters: {
|
||||
module?: string;
|
||||
uploadContext?: string;
|
||||
validationResult?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
userId?: string;
|
||||
}): Promise<XSSValidationLogEntry[]> {
|
||||
return this.xssLogs.filter((log) => {
|
||||
if (filters.module && log.module !== filters.module) return false;
|
||||
if (filters.uploadContext && log.uploadContext !== filters.uploadContext)
|
||||
return false;
|
||||
if (
|
||||
filters.validationResult &&
|
||||
log.validationResult !== filters.validationResult
|
||||
)
|
||||
return false;
|
||||
if (filters.startDate && log.timestamp < filters.startDate) return false;
|
||||
if (filters.endDate && log.timestamp > filters.endDate) return false;
|
||||
if (filters.userId && log.userId !== filters.userId) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all logs (for testing/debugging)
|
||||
*/
|
||||
getAllLogs(): ValidationLogEntry[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all XSS validation logs (for testing/debugging)
|
||||
*/
|
||||
getAllXSSLogs(): XSSValidationLogEntry[] {
|
||||
return [...this.xssLogs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear logs (for testing)
|
||||
*/
|
||||
clearLogs(): void {
|
||||
this.logs = [];
|
||||
this.xssLogs = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const securityLogger = new SecurityLogger();
|
||||
import { ValidationLogEntry, XSSValidationLogEntry } from './types';
|
||||
|
||||
/**
|
||||
* Central security logging service for file validation events
|
||||
* Records all validation attempts for audit and investigation
|
||||
*/
|
||||
export class SecurityLogger {
|
||||
private logs: ValidationLogEntry[] = [];
|
||||
private xssLogs: XSSValidationLogEntry[] = [];
|
||||
|
||||
/**
|
||||
* Log a validation event
|
||||
* @param entry - Validation log entry with all required metadata
|
||||
*/
|
||||
async logValidationEvent(entry: ValidationLogEntry): Promise<void> {
|
||||
try {
|
||||
// Ensure timestamp is set
|
||||
if (!entry.timestamp) {
|
||||
entry.timestamp = new Date();
|
||||
}
|
||||
|
||||
// Store log entry
|
||||
this.logs.push(entry);
|
||||
|
||||
// In production, this would write to a secure logging service
|
||||
// For now, we log to console in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[SECURITY LOG]', {
|
||||
timestamp: entry.timestamp.toISOString(),
|
||||
module: entry.module,
|
||||
endpoint: entry.endpoint,
|
||||
result: entry.validationResult,
|
||||
declaredMime: entry.declaredMime,
|
||||
detectedMime: entry.detectedMime,
|
||||
error: entry.errorDetails,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: In production, integrate with your logging service
|
||||
// Examples:
|
||||
// - Send to CloudWatch, DataDog, Splunk, etc.
|
||||
// - Write to database audit table
|
||||
// - Send to centralized logging system
|
||||
} catch (error) {
|
||||
console.error('Failed to log validation event:', error);
|
||||
// Don't throw - logging failures shouldn't break uploads
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an XSS validation event
|
||||
* @param entry - XSS validation log entry
|
||||
*/
|
||||
async logXSSValidationEvent(entry: XSSValidationLogEntry): Promise<void> {
|
||||
try {
|
||||
// Ensure timestamp is set
|
||||
if (!entry.timestamp) {
|
||||
entry.timestamp = new Date();
|
||||
}
|
||||
|
||||
// Store log entry
|
||||
this.xssLogs.push(entry);
|
||||
|
||||
// Log to console in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[XSS VALIDATION LOG]', {
|
||||
timestamp: entry.timestamp.toISOString(),
|
||||
module: entry.module,
|
||||
uploadContext: entry.uploadContext,
|
||||
result: entry.validationResult,
|
||||
fileName: entry.sanitizedFileName, // Use sanitized name for logging
|
||||
declaredMime: entry.declaredMimeType,
|
||||
detectedMime: entry.detectedMimeType,
|
||||
failureReason: entry.failureReason,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: In production, integrate with your logging service
|
||||
// Ensure sensitive file content is NOT logged
|
||||
} catch (error) {
|
||||
console.error('Failed to log XSS validation event:', error);
|
||||
// Don't throw - logging failures shouldn't break uploads
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query validation logs for investigation
|
||||
* @param filters - Filter criteria for logs
|
||||
* @returns Matching log entries
|
||||
*/
|
||||
async queryValidationLogs(filters: {
|
||||
module?: string;
|
||||
validationResult?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
userId?: string;
|
||||
}): Promise<ValidationLogEntry[]> {
|
||||
return this.logs.filter((log) => {
|
||||
if (filters.module && log.module !== filters.module) return false;
|
||||
if (
|
||||
filters.validationResult &&
|
||||
log.validationResult !== filters.validationResult
|
||||
)
|
||||
return false;
|
||||
if (filters.startDate && log.timestamp < filters.startDate) return false;
|
||||
if (filters.endDate && log.timestamp > filters.endDate) return false;
|
||||
if (filters.userId && log.userId !== filters.userId) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query XSS validation logs for investigation
|
||||
* @param filters - Filter criteria for logs
|
||||
* @returns Matching XSS validation log entries
|
||||
*/
|
||||
async queryXSSValidationLogs(filters: {
|
||||
module?: string;
|
||||
uploadContext?: string;
|
||||
validationResult?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
userId?: string;
|
||||
}): Promise<XSSValidationLogEntry[]> {
|
||||
return this.xssLogs.filter((log) => {
|
||||
if (filters.module && log.module !== filters.module) return false;
|
||||
if (filters.uploadContext && log.uploadContext !== filters.uploadContext)
|
||||
return false;
|
||||
if (
|
||||
filters.validationResult &&
|
||||
log.validationResult !== filters.validationResult
|
||||
)
|
||||
return false;
|
||||
if (filters.startDate && log.timestamp < filters.startDate) return false;
|
||||
if (filters.endDate && log.timestamp > filters.endDate) return false;
|
||||
if (filters.userId && log.userId !== filters.userId) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all logs (for testing/debugging)
|
||||
*/
|
||||
getAllLogs(): ValidationLogEntry[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all XSS validation logs (for testing/debugging)
|
||||
*/
|
||||
getAllXSSLogs(): XSSValidationLogEntry[] {
|
||||
return [...this.xssLogs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear logs (for testing)
|
||||
*/
|
||||
clearLogs(): void {
|
||||
this.logs = [];
|
||||
this.xssLogs = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const securityLogger = new SecurityLogger();
|
||||
|
||||
@@ -1,149 +1,149 @@
|
||||
import { FileUploadValidator } from './FileUploadValidator';
|
||||
import { ClientSideValidator, clientSideValidator } from './ClientSideValidator';
|
||||
import {
|
||||
XSSUploadValidationContext,
|
||||
XSSUploadValidationResult,
|
||||
ClientValidationDetails,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Orchestrates client-side and server-side XSS validation for file uploads
|
||||
* Provides unified validation experience across all upload entry points
|
||||
*/
|
||||
export class XSSUploadValidator {
|
||||
/**
|
||||
* Validate file for upload with client-side checks
|
||||
* Runs immediately when user selects file
|
||||
* @param file - File to validate
|
||||
* @param context - Validation context with allowed types and constraints
|
||||
* @returns Validation result with specific error details
|
||||
*/
|
||||
static async validateFileForUpload(
|
||||
file: File,
|
||||
context: XSSUploadValidationContext
|
||||
): Promise<XSSUploadValidationResult> {
|
||||
const clientValidation: ClientValidationDetails = {
|
||||
passed: true,
|
||||
};
|
||||
|
||||
// Validate file name for XSS patterns
|
||||
const fileNameValidation = FileUploadValidator.validateFileName(file);
|
||||
if (!fileNameValidation.isValid) {
|
||||
clientValidation.passed = false;
|
||||
clientValidation.fileName = {
|
||||
passed: false,
|
||||
error: fileNameValidation.error,
|
||||
};
|
||||
return {
|
||||
isValid: false,
|
||||
clientValidation,
|
||||
sanitizedFileName: this.getSanitizedFileName(file.name),
|
||||
};
|
||||
}
|
||||
clientValidation.fileName = { passed: true };
|
||||
|
||||
// Validate file extension
|
||||
const extensionValidation = FileUploadValidator.validateFileExtension(
|
||||
file,
|
||||
context.allowedExtensions
|
||||
);
|
||||
if (!extensionValidation.isValid) {
|
||||
clientValidation.passed = false;
|
||||
clientValidation.extension = {
|
||||
passed: false,
|
||||
error: extensionValidation.error,
|
||||
};
|
||||
return {
|
||||
isValid: false,
|
||||
clientValidation,
|
||||
sanitizedFileName: this.getSanitizedFileName(file.name),
|
||||
};
|
||||
}
|
||||
clientValidation.extension = { passed: true };
|
||||
|
||||
// Validate file size
|
||||
const sizeValidation = FileUploadValidator.validateFileSize(
|
||||
file,
|
||||
context.maxSizeMB
|
||||
);
|
||||
if (!sizeValidation.isValid) {
|
||||
clientValidation.passed = false;
|
||||
clientValidation.size = {
|
||||
passed: false,
|
||||
error: sizeValidation.error,
|
||||
};
|
||||
return {
|
||||
isValid: false,
|
||||
clientValidation,
|
||||
sanitizedFileName: this.getSanitizedFileName(file.name),
|
||||
};
|
||||
}
|
||||
clientValidation.size = { passed: true };
|
||||
|
||||
// Validate MIME type using magic byte detection
|
||||
// This is more reliable than browser's file.type for Office documents
|
||||
const mimeValidation = await clientSideValidator.validateUpload(
|
||||
file,
|
||||
file.type,
|
||||
context.allowedMimeTypes || []
|
||||
);
|
||||
if (!mimeValidation.isValid) {
|
||||
clientValidation.passed = false;
|
||||
clientValidation.mimeType = {
|
||||
passed: false,
|
||||
error: mimeValidation.error,
|
||||
};
|
||||
return {
|
||||
isValid: false,
|
||||
clientValidation,
|
||||
sanitizedFileName: this.getSanitizedFileName(file.name),
|
||||
};
|
||||
}
|
||||
clientValidation.mimeType = { passed: true };
|
||||
|
||||
// All validations passed
|
||||
return {
|
||||
isValid: true,
|
||||
clientValidation,
|
||||
sanitizedFileName: this.getSanitizedFileName(file.name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sanitized file name for safe display in UI
|
||||
* Removes or replaces suspicious characters, removes null bytes, truncates to 255 chars
|
||||
* @param fileName - Original file name
|
||||
* @returns Sanitized file name safe for display
|
||||
*/
|
||||
static getSanitizedFileName(fileName: string): string {
|
||||
return FileUploadValidator.sanitizeFileName(fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate multiple files for upload
|
||||
* @param files - Files to validate
|
||||
* @param context - Validation context
|
||||
* @returns Object with valid and invalid files
|
||||
*/
|
||||
static async validateFilesForUpload(
|
||||
files: File[],
|
||||
context: XSSUploadValidationContext
|
||||
): Promise<{
|
||||
valid: Array<{ file: File; result: XSSUploadValidationResult }>;
|
||||
invalid: Array<{ file: File; result: XSSUploadValidationResult }>;
|
||||
}> {
|
||||
const valid: Array<{ file: File; result: XSSUploadValidationResult }> = [];
|
||||
const invalid: Array<{ file: File; result: XSSUploadValidationResult }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
const result = await this.validateFileForUpload(file, context);
|
||||
if (result.isValid) {
|
||||
valid.push({ file, result });
|
||||
} else {
|
||||
invalid.push({ file, result });
|
||||
}
|
||||
}
|
||||
|
||||
return { valid, invalid };
|
||||
}
|
||||
}
|
||||
import { FileUploadValidator } from './FileUploadValidator';
|
||||
import { ClientSideValidator, clientSideValidator } from './ClientSideValidator';
|
||||
import {
|
||||
XSSUploadValidationContext,
|
||||
XSSUploadValidationResult,
|
||||
ClientValidationDetails,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Orchestrates client-side and server-side XSS validation for file uploads
|
||||
* Provides unified validation experience across all upload entry points
|
||||
*/
|
||||
export class XSSUploadValidator {
|
||||
/**
|
||||
* Validate file for upload with client-side checks
|
||||
* Runs immediately when user selects file
|
||||
* @param file - File to validate
|
||||
* @param context - Validation context with allowed types and constraints
|
||||
* @returns Validation result with specific error details
|
||||
*/
|
||||
static async validateFileForUpload(
|
||||
file: File,
|
||||
context: XSSUploadValidationContext
|
||||
): Promise<XSSUploadValidationResult> {
|
||||
const clientValidation: ClientValidationDetails = {
|
||||
passed: true,
|
||||
};
|
||||
|
||||
// Validate file name for XSS patterns
|
||||
const fileNameValidation = FileUploadValidator.validateFileName(file);
|
||||
if (!fileNameValidation.isValid) {
|
||||
clientValidation.passed = false;
|
||||
clientValidation.fileName = {
|
||||
passed: false,
|
||||
error: fileNameValidation.error,
|
||||
};
|
||||
return {
|
||||
isValid: false,
|
||||
clientValidation,
|
||||
sanitizedFileName: this.getSanitizedFileName(file.name),
|
||||
};
|
||||
}
|
||||
clientValidation.fileName = { passed: true };
|
||||
|
||||
// Validate file extension
|
||||
const extensionValidation = FileUploadValidator.validateFileExtension(
|
||||
file,
|
||||
context.allowedExtensions
|
||||
);
|
||||
if (!extensionValidation.isValid) {
|
||||
clientValidation.passed = false;
|
||||
clientValidation.extension = {
|
||||
passed: false,
|
||||
error: extensionValidation.error,
|
||||
};
|
||||
return {
|
||||
isValid: false,
|
||||
clientValidation,
|
||||
sanitizedFileName: this.getSanitizedFileName(file.name),
|
||||
};
|
||||
}
|
||||
clientValidation.extension = { passed: true };
|
||||
|
||||
// Validate file size
|
||||
const sizeValidation = FileUploadValidator.validateFileSize(
|
||||
file,
|
||||
context.maxSizeMB
|
||||
);
|
||||
if (!sizeValidation.isValid) {
|
||||
clientValidation.passed = false;
|
||||
clientValidation.size = {
|
||||
passed: false,
|
||||
error: sizeValidation.error,
|
||||
};
|
||||
return {
|
||||
isValid: false,
|
||||
clientValidation,
|
||||
sanitizedFileName: this.getSanitizedFileName(file.name),
|
||||
};
|
||||
}
|
||||
clientValidation.size = { passed: true };
|
||||
|
||||
// Validate MIME type using magic byte detection
|
||||
// This is more reliable than browser's file.type for Office documents
|
||||
const mimeValidation = await clientSideValidator.validateUpload(
|
||||
file,
|
||||
file.type,
|
||||
context.allowedMimeTypes || []
|
||||
);
|
||||
if (!mimeValidation.isValid) {
|
||||
clientValidation.passed = false;
|
||||
clientValidation.mimeType = {
|
||||
passed: false,
|
||||
error: mimeValidation.error,
|
||||
};
|
||||
return {
|
||||
isValid: false,
|
||||
clientValidation,
|
||||
sanitizedFileName: this.getSanitizedFileName(file.name),
|
||||
};
|
||||
}
|
||||
clientValidation.mimeType = { passed: true };
|
||||
|
||||
// All validations passed
|
||||
return {
|
||||
isValid: true,
|
||||
clientValidation,
|
||||
sanitizedFileName: this.getSanitizedFileName(file.name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sanitized file name for safe display in UI
|
||||
* Removes or replaces suspicious characters, removes null bytes, truncates to 255 chars
|
||||
* @param fileName - Original file name
|
||||
* @returns Sanitized file name safe for display
|
||||
*/
|
||||
static getSanitizedFileName(fileName: string): string {
|
||||
return FileUploadValidator.sanitizeFileName(fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate multiple files for upload
|
||||
* @param files - Files to validate
|
||||
* @param context - Validation context
|
||||
* @returns Object with valid and invalid files
|
||||
*/
|
||||
static async validateFilesForUpload(
|
||||
files: File[],
|
||||
context: XSSUploadValidationContext
|
||||
): Promise<{
|
||||
valid: Array<{ file: File; result: XSSUploadValidationResult }>;
|
||||
invalid: Array<{ file: File; result: XSSUploadValidationResult }>;
|
||||
}> {
|
||||
const valid: Array<{ file: File; result: XSSUploadValidationResult }> = [];
|
||||
const invalid: Array<{ file: File; result: XSSUploadValidationResult }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
const result = await this.validateFileForUpload(file, context);
|
||||
if (result.isValid) {
|
||||
valid.push({ file, result });
|
||||
} else {
|
||||
invalid.push({ file, result });
|
||||
}
|
||||
}
|
||||
|
||||
return { valid, invalid };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,178 +1,178 @@
|
||||
import { BufferContentValidator } from '../BufferContentValidator';
|
||||
|
||||
// Mock file-type module
|
||||
jest.mock('file-type', () => ({
|
||||
fileTypeFromBuffer: jest.fn(async (buffer: Buffer) => {
|
||||
// PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
||||
return { mime: 'image/png', ext: 'png' };
|
||||
}
|
||||
// JPEG magic bytes: FF D8 FF
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return { mime: 'image/jpeg', ext: 'jpg' };
|
||||
}
|
||||
// PDF magic bytes: 25 50 44 46 (% P D F)
|
||||
if (buffer[0] === 0x25 && buffer[1] === 0x50 && buffer[2] === 0x44 && buffer[3] === 0x46) {
|
||||
return { mime: 'application/pdf', ext: 'pdf' };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('BufferContentValidator', () => {
|
||||
let validator: BufferContentValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new BufferContentValidator();
|
||||
});
|
||||
|
||||
describe('detectFileType', () => {
|
||||
it('should return null for empty buffer', async () => {
|
||||
const result = await validator.detectFileType(Buffer.alloc(0));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for undefined buffer', async () => {
|
||||
const result = await validator.detectFileType(undefined as any);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should detect PNG file type from magic bytes', async () => {
|
||||
// PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d,
|
||||
]);
|
||||
const result = await validator.detectFileType(pngBuffer);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mime).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should detect JPEG file type from magic bytes', async () => {
|
||||
// JPEG magic bytes: FF D8 FF
|
||||
const jpegBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
|
||||
const result = await validator.detectFileType(jpegBuffer);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mime).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should detect PDF file type from magic bytes', async () => {
|
||||
// PDF magic bytes: 25 50 44 46 (% P D F)
|
||||
const pdfBuffer = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31]);
|
||||
const result = await validator.detectFileType(pdfBuffer);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mime).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('should return null for unrecognized file type', async () => {
|
||||
const unknownBuffer = Buffer.from([0x00, 0x01, 0x02, 0x03]);
|
||||
const result = await validator.detectFileType(unknownBuffer);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMimeType', () => {
|
||||
it('should return error for empty buffer', async () => {
|
||||
const result = await validator.validateMimeType(
|
||||
Buffer.alloc(0),
|
||||
'image/png'
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('empty or corrupted');
|
||||
});
|
||||
|
||||
it('should return error for unrecognized file type', async () => {
|
||||
const unknownBuffer = Buffer.from([0x00, 0x01, 0x02, 0x03]);
|
||||
const result = await validator.validateMimeType(
|
||||
unknownBuffer,
|
||||
'image/png'
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('Unable to detect');
|
||||
});
|
||||
|
||||
it('should validate matching MIME types', async () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d,
|
||||
]);
|
||||
const result = await validator.validateMimeType(pngBuffer, 'image/png');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.detectedMime).toBe('image/png');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject mismatched MIME types', async () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d,
|
||||
]);
|
||||
const result = await validator.validateMimeType(pngBuffer, 'image/jpeg');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.detectedMime).toBe('image/png');
|
||||
expect(result.error).toContain('MIME mismatch');
|
||||
expect(result.error).toContain('image/jpeg');
|
||||
expect(result.error).toContain('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedMimeType', () => {
|
||||
it('should return true for empty allowed list', () => {
|
||||
const result = validator.isAllowedMimeType('image/png', []);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for allowed MIME type', () => {
|
||||
const result = validator.isAllowedMimeType('image/png', [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for disallowed MIME type', () => {
|
||||
const result = validator.isAllowedMimeType('application/exe', [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUpload', () => {
|
||||
it('should validate matching MIME type and allowed list', async () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d,
|
||||
]);
|
||||
const result = await validator.validateUpload(pngBuffer, 'image/png', {
|
||||
allowedMimeTypes: ['image/png', 'image/jpeg'],
|
||||
});
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject MIME type mismatch', async () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d,
|
||||
]);
|
||||
const result = await validator.validateUpload(pngBuffer, 'image/jpeg', {
|
||||
allowedMimeTypes: ['image/png', 'image/jpeg'],
|
||||
});
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('MIME mismatch');
|
||||
});
|
||||
|
||||
it('should reject MIME type not in allowed list', async () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d,
|
||||
]);
|
||||
const result = await validator.validateUpload(pngBuffer, 'image/png', {
|
||||
allowedMimeTypes: ['image/jpeg', 'application/pdf'],
|
||||
});
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('not allowed');
|
||||
});
|
||||
});
|
||||
});
|
||||
import { BufferContentValidator } from '../BufferContentValidator';
|
||||
|
||||
// Mock file-type module
|
||||
jest.mock('file-type', () => ({
|
||||
fileTypeFromBuffer: jest.fn(async (buffer: Buffer) => {
|
||||
// PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
||||
return { mime: 'image/png', ext: 'png' };
|
||||
}
|
||||
// JPEG magic bytes: FF D8 FF
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return { mime: 'image/jpeg', ext: 'jpg' };
|
||||
}
|
||||
// PDF magic bytes: 25 50 44 46 (% P D F)
|
||||
if (buffer[0] === 0x25 && buffer[1] === 0x50 && buffer[2] === 0x44 && buffer[3] === 0x46) {
|
||||
return { mime: 'application/pdf', ext: 'pdf' };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('BufferContentValidator', () => {
|
||||
let validator: BufferContentValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new BufferContentValidator();
|
||||
});
|
||||
|
||||
describe('detectFileType', () => {
|
||||
it('should return null for empty buffer', async () => {
|
||||
const result = await validator.detectFileType(Buffer.alloc(0));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for undefined buffer', async () => {
|
||||
const result = await validator.detectFileType(undefined as any);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should detect PNG file type from magic bytes', async () => {
|
||||
// PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d,
|
||||
]);
|
||||
const result = await validator.detectFileType(pngBuffer);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mime).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should detect JPEG file type from magic bytes', async () => {
|
||||
// JPEG magic bytes: FF D8 FF
|
||||
const jpegBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
|
||||
const result = await validator.detectFileType(jpegBuffer);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mime).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should detect PDF file type from magic bytes', async () => {
|
||||
// PDF magic bytes: 25 50 44 46 (% P D F)
|
||||
const pdfBuffer = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31]);
|
||||
const result = await validator.detectFileType(pdfBuffer);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mime).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('should return null for unrecognized file type', async () => {
|
||||
const unknownBuffer = Buffer.from([0x00, 0x01, 0x02, 0x03]);
|
||||
const result = await validator.detectFileType(unknownBuffer);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMimeType', () => {
|
||||
it('should return error for empty buffer', async () => {
|
||||
const result = await validator.validateMimeType(
|
||||
Buffer.alloc(0),
|
||||
'image/png'
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('empty or corrupted');
|
||||
});
|
||||
|
||||
it('should return error for unrecognized file type', async () => {
|
||||
const unknownBuffer = Buffer.from([0x00, 0x01, 0x02, 0x03]);
|
||||
const result = await validator.validateMimeType(
|
||||
unknownBuffer,
|
||||
'image/png'
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('Unable to detect');
|
||||
});
|
||||
|
||||
it('should validate matching MIME types', async () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d,
|
||||
]);
|
||||
const result = await validator.validateMimeType(pngBuffer, 'image/png');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.detectedMime).toBe('image/png');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject mismatched MIME types', async () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d,
|
||||
]);
|
||||
const result = await validator.validateMimeType(pngBuffer, 'image/jpeg');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.detectedMime).toBe('image/png');
|
||||
expect(result.error).toContain('MIME mismatch');
|
||||
expect(result.error).toContain('image/jpeg');
|
||||
expect(result.error).toContain('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedMimeType', () => {
|
||||
it('should return true for empty allowed list', () => {
|
||||
const result = validator.isAllowedMimeType('image/png', []);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for allowed MIME type', () => {
|
||||
const result = validator.isAllowedMimeType('image/png', [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for disallowed MIME type', () => {
|
||||
const result = validator.isAllowedMimeType('application/exe', [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUpload', () => {
|
||||
it('should validate matching MIME type and allowed list', async () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d,
|
||||
]);
|
||||
const result = await validator.validateUpload(pngBuffer, 'image/png', {
|
||||
allowedMimeTypes: ['image/png', 'image/jpeg'],
|
||||
});
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject MIME type mismatch', async () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d,
|
||||
]);
|
||||
const result = await validator.validateUpload(pngBuffer, 'image/jpeg', {
|
||||
allowedMimeTypes: ['image/png', 'image/jpeg'],
|
||||
});
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('MIME mismatch');
|
||||
});
|
||||
|
||||
it('should reject MIME type not in allowed list', async () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d,
|
||||
]);
|
||||
const result = await validator.validateUpload(pngBuffer, 'image/png', {
|
||||
allowedMimeTypes: ['image/jpeg', 'application/pdf'],
|
||||
});
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('not allowed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,183 +1,183 @@
|
||||
import { ClientSideValidator } from '../ClientSideValidator';
|
||||
|
||||
describe('ClientSideValidator', () => {
|
||||
let validator: ClientSideValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new ClientSideValidator();
|
||||
});
|
||||
|
||||
describe('detectFileType', () => {
|
||||
it('should detect PNG file type from magic bytes', async () => {
|
||||
// PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
|
||||
const pngBuffer = new ArrayBuffer(12);
|
||||
const view = new Uint8Array(pngBuffer);
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
|
||||
|
||||
const result = await validator.detectFileType(pngBuffer);
|
||||
expect(result).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should detect JPEG file type from magic bytes', async () => {
|
||||
// JPEG magic bytes: FF D8 FF
|
||||
const jpegBuffer = new ArrayBuffer(6);
|
||||
const view = new Uint8Array(jpegBuffer);
|
||||
view.set([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
|
||||
|
||||
const result = await validator.detectFileType(jpegBuffer);
|
||||
expect(result).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should detect PDF file type from magic bytes', async () => {
|
||||
// PDF magic bytes: 25 50 44 46 (% P D F)
|
||||
const pdfBuffer = new ArrayBuffer(6);
|
||||
const view = new Uint8Array(pdfBuffer);
|
||||
view.set([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31]);
|
||||
|
||||
const result = await validator.detectFileType(pdfBuffer);
|
||||
expect(result).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('should detect GIF file type from magic bytes', async () => {
|
||||
// GIF magic bytes: 47 49 46 (GIF)
|
||||
const gifBuffer = new ArrayBuffer(6);
|
||||
const view = new Uint8Array(gifBuffer);
|
||||
view.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
|
||||
|
||||
const result = await validator.detectFileType(gifBuffer);
|
||||
expect(result).toBe('image/gif');
|
||||
});
|
||||
|
||||
it('should return null for empty buffer', async () => {
|
||||
const emptyBuffer = new ArrayBuffer(0);
|
||||
const result = await validator.detectFileType(emptyBuffer);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for unrecognized file type', async () => {
|
||||
const unknownBuffer = new ArrayBuffer(4);
|
||||
const view = new Uint8Array(unknownBuffer);
|
||||
view.set([0x00, 0x01, 0x02, 0x03]);
|
||||
|
||||
const result = await validator.detectFileType(unknownBuffer);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFile', () => {
|
||||
it('should validate matching MIME types', async () => {
|
||||
const pngBuffer = new ArrayBuffer(12);
|
||||
const view = new Uint8Array(pngBuffer);
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
|
||||
|
||||
const file = new File([pngBuffer], 'test.png', { type: 'image/png' });
|
||||
const result = await validator.validateFile(file, 'image/png');
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.detectedMime).toBe('image/png');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject mismatched MIME types', async () => {
|
||||
const pngBuffer = new ArrayBuffer(12);
|
||||
const view = new Uint8Array(pngBuffer);
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
|
||||
|
||||
const file = new File([pngBuffer], 'test.png', { type: 'image/jpeg' });
|
||||
const result = await validator.validateFile(file, 'image/jpeg');
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.detectedMime).toBe('image/png');
|
||||
expect(result.error).toContain('File type mismatch');
|
||||
});
|
||||
|
||||
it('should handle unrecognized file types', async () => {
|
||||
const unknownBuffer = new ArrayBuffer(4);
|
||||
const view = new Uint8Array(unknownBuffer);
|
||||
view.set([0x00, 0x01, 0x02, 0x03]);
|
||||
|
||||
const file = new File([unknownBuffer], 'test.bin', { type: 'application/octet-stream' });
|
||||
const result = await validator.validateFile(file, 'application/octet-stream');
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.detectedMime).toBeNull();
|
||||
expect(result.error).toContain('Unable to detect file type');
|
||||
});
|
||||
|
||||
it('should handle missing file', async () => {
|
||||
const result = await validator.validateFile(null as any, 'image/png');
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('No file provided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedMimeType', () => {
|
||||
it('should return true for empty allowed list', () => {
|
||||
const result = validator.isAllowedMimeType('image/png', []);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for allowed MIME type', () => {
|
||||
const result = validator.isAllowedMimeType('image/png', [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for disallowed MIME type', () => {
|
||||
const result = validator.isAllowedMimeType('application/exe', [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUpload', () => {
|
||||
it('should validate matching MIME type and allowed list', async () => {
|
||||
const pngBuffer = new ArrayBuffer(12);
|
||||
const view = new Uint8Array(pngBuffer);
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
|
||||
|
||||
const file = new File([pngBuffer], 'test.png', { type: 'image/png' });
|
||||
const result = await validator.validateUpload(file, 'image/png', [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject MIME type mismatch', async () => {
|
||||
const pngBuffer = new ArrayBuffer(12);
|
||||
const view = new Uint8Array(pngBuffer);
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
|
||||
|
||||
const file = new File([pngBuffer], 'test.png', { type: 'image/jpeg' });
|
||||
const result = await validator.validateUpload(file, 'image/jpeg', [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('File type mismatch');
|
||||
});
|
||||
|
||||
it('should reject MIME type not in allowed list', async () => {
|
||||
const pngBuffer = new ArrayBuffer(12);
|
||||
const view = new Uint8Array(pngBuffer);
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
|
||||
|
||||
const file = new File([pngBuffer], 'test.png', { type: 'image/png' });
|
||||
const result = await validator.validateUpload(file, 'image/png', [
|
||||
'image/jpeg',
|
||||
'application/pdf',
|
||||
]);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('not allowed');
|
||||
});
|
||||
});
|
||||
});
|
||||
import { ClientSideValidator } from '../ClientSideValidator';
|
||||
|
||||
describe('ClientSideValidator', () => {
|
||||
let validator: ClientSideValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new ClientSideValidator();
|
||||
});
|
||||
|
||||
describe('detectFileType', () => {
|
||||
it('should detect PNG file type from magic bytes', async () => {
|
||||
// PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
|
||||
const pngBuffer = new ArrayBuffer(12);
|
||||
const view = new Uint8Array(pngBuffer);
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
|
||||
|
||||
const result = await validator.detectFileType(pngBuffer);
|
||||
expect(result).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should detect JPEG file type from magic bytes', async () => {
|
||||
// JPEG magic bytes: FF D8 FF
|
||||
const jpegBuffer = new ArrayBuffer(6);
|
||||
const view = new Uint8Array(jpegBuffer);
|
||||
view.set([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
|
||||
|
||||
const result = await validator.detectFileType(jpegBuffer);
|
||||
expect(result).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should detect PDF file type from magic bytes', async () => {
|
||||
// PDF magic bytes: 25 50 44 46 (% P D F)
|
||||
const pdfBuffer = new ArrayBuffer(6);
|
||||
const view = new Uint8Array(pdfBuffer);
|
||||
view.set([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31]);
|
||||
|
||||
const result = await validator.detectFileType(pdfBuffer);
|
||||
expect(result).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('should detect GIF file type from magic bytes', async () => {
|
||||
// GIF magic bytes: 47 49 46 (GIF)
|
||||
const gifBuffer = new ArrayBuffer(6);
|
||||
const view = new Uint8Array(gifBuffer);
|
||||
view.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
|
||||
|
||||
const result = await validator.detectFileType(gifBuffer);
|
||||
expect(result).toBe('image/gif');
|
||||
});
|
||||
|
||||
it('should return null for empty buffer', async () => {
|
||||
const emptyBuffer = new ArrayBuffer(0);
|
||||
const result = await validator.detectFileType(emptyBuffer);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for unrecognized file type', async () => {
|
||||
const unknownBuffer = new ArrayBuffer(4);
|
||||
const view = new Uint8Array(unknownBuffer);
|
||||
view.set([0x00, 0x01, 0x02, 0x03]);
|
||||
|
||||
const result = await validator.detectFileType(unknownBuffer);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFile', () => {
|
||||
it('should validate matching MIME types', async () => {
|
||||
const pngBuffer = new ArrayBuffer(12);
|
||||
const view = new Uint8Array(pngBuffer);
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
|
||||
|
||||
const file = new File([pngBuffer], 'test.png', { type: 'image/png' });
|
||||
const result = await validator.validateFile(file, 'image/png');
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.detectedMime).toBe('image/png');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject mismatched MIME types', async () => {
|
||||
const pngBuffer = new ArrayBuffer(12);
|
||||
const view = new Uint8Array(pngBuffer);
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
|
||||
|
||||
const file = new File([pngBuffer], 'test.png', { type: 'image/jpeg' });
|
||||
const result = await validator.validateFile(file, 'image/jpeg');
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.detectedMime).toBe('image/png');
|
||||
expect(result.error).toContain('File type mismatch');
|
||||
});
|
||||
|
||||
it('should handle unrecognized file types', async () => {
|
||||
const unknownBuffer = new ArrayBuffer(4);
|
||||
const view = new Uint8Array(unknownBuffer);
|
||||
view.set([0x00, 0x01, 0x02, 0x03]);
|
||||
|
||||
const file = new File([unknownBuffer], 'test.bin', { type: 'application/octet-stream' });
|
||||
const result = await validator.validateFile(file, 'application/octet-stream');
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.detectedMime).toBeNull();
|
||||
expect(result.error).toContain('Unable to detect file type');
|
||||
});
|
||||
|
||||
it('should handle missing file', async () => {
|
||||
const result = await validator.validateFile(null as any, 'image/png');
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('No file provided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedMimeType', () => {
|
||||
it('should return true for empty allowed list', () => {
|
||||
const result = validator.isAllowedMimeType('image/png', []);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for allowed MIME type', () => {
|
||||
const result = validator.isAllowedMimeType('image/png', [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for disallowed MIME type', () => {
|
||||
const result = validator.isAllowedMimeType('application/exe', [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUpload', () => {
|
||||
it('should validate matching MIME type and allowed list', async () => {
|
||||
const pngBuffer = new ArrayBuffer(12);
|
||||
const view = new Uint8Array(pngBuffer);
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
|
||||
|
||||
const file = new File([pngBuffer], 'test.png', { type: 'image/png' });
|
||||
const result = await validator.validateUpload(file, 'image/png', [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject MIME type mismatch', async () => {
|
||||
const pngBuffer = new ArrayBuffer(12);
|
||||
const view = new Uint8Array(pngBuffer);
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
|
||||
|
||||
const file = new File([pngBuffer], 'test.png', { type: 'image/jpeg' });
|
||||
const result = await validator.validateUpload(file, 'image/jpeg', [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('File type mismatch');
|
||||
});
|
||||
|
||||
it('should reject MIME type not in allowed list', async () => {
|
||||
const pngBuffer = new ArrayBuffer(12);
|
||||
const view = new Uint8Array(pngBuffer);
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
|
||||
|
||||
const file = new File([pngBuffer], 'test.png', { type: 'image/png' });
|
||||
const result = await validator.validateUpload(file, 'image/png', [
|
||||
'image/jpeg',
|
||||
'application/pdf',
|
||||
]);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('not allowed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,249 +1,249 @@
|
||||
import { FileUploadValidator } from '../FileUploadValidator';
|
||||
|
||||
describe('FileUploadValidator', () => {
|
||||
describe('validateFileSize', () => {
|
||||
it('should accept files within size limit', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
|
||||
Object.defineProperty(file, 'size', { value: 1024 * 1024 }); // 1MB
|
||||
|
||||
const result = FileUploadValidator.validateFileSize(file, 50);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject files exceeding size limit', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
|
||||
Object.defineProperty(file, 'size', { value: 100 * 1024 * 1024 }); // 100MB
|
||||
|
||||
const result = FileUploadValidator.validateFileSize(file, 50);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('exceeds maximum allowed size');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFileExtension', () => {
|
||||
it('should reject dangerous extensions', () => {
|
||||
const file = new File(['content'], 'malware.exe', { type: 'application/octet-stream' });
|
||||
|
||||
const result = FileUploadValidator.validateFileExtension(file);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('not allowed for security reasons');
|
||||
});
|
||||
|
||||
it('should accept allowed extensions', () => {
|
||||
const file = new File(['content'], 'image.jpg', { type: 'image/jpeg' });
|
||||
|
||||
const result = FileUploadValidator.validateFileExtension(file, ['jpg', 'png']);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject disallowed extensions', () => {
|
||||
const file = new File(['content'], 'document.exe', { type: 'application/octet-stream' });
|
||||
|
||||
const result = FileUploadValidator.validateFileExtension(file, ['jpg', 'png']);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('not allowed');
|
||||
});
|
||||
|
||||
it('should handle extensions with or without dot', () => {
|
||||
const file = new File(['content'], 'image.jpg', { type: 'image/jpeg' });
|
||||
|
||||
const result1 = FileUploadValidator.validateFileExtension(file, ['.jpg', '.png']);
|
||||
const result2 = FileUploadValidator.validateFileExtension(file, ['jpg', 'png']);
|
||||
|
||||
expect(result1.isValid).toBe(true);
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMimeType', () => {
|
||||
it('should accept allowed MIME types', () => {
|
||||
const file = new File(['content'], 'image.jpg', { type: 'image/jpeg' });
|
||||
|
||||
const result = FileUploadValidator.validateMimeType(file, ['image/jpeg', 'image/png']);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject disallowed MIME types', () => {
|
||||
const file = new File(['content'], 'script.js', { type: 'application/javascript' });
|
||||
|
||||
const result = FileUploadValidator.validateMimeType(file, ['image/jpeg', 'image/png']);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('not allowed');
|
||||
});
|
||||
|
||||
it('should support wildcard MIME types', () => {
|
||||
const file = new File(['content'], 'image.jpg', { type: 'image/jpeg' });
|
||||
|
||||
const result = FileUploadValidator.validateMimeType(file, ['image/*']);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept any MIME type if list is empty', () => {
|
||||
const file = new File(['content'], 'anything.bin', { type: 'application/octet-stream' });
|
||||
|
||||
const result = FileUploadValidator.validateMimeType(file, []);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFileName', () => {
|
||||
it('should reject file names with invalid characters', () => {
|
||||
const file = new File(['content'], 'file<script>.txt', { type: 'text/plain' });
|
||||
|
||||
const result = FileUploadValidator.validateFileName(file);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('suspicious characters');
|
||||
});
|
||||
|
||||
it('should reject file names with javascript protocol', () => {
|
||||
const file = new File(['content'], 'javascript:alert(1).txt', { type: 'text/plain' });
|
||||
|
||||
const result = FileUploadValidator.validateFileName(file);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('suspicious');
|
||||
});
|
||||
|
||||
it('should reject file names with event handlers', () => {
|
||||
const file = new File(['content'], 'onerror=alert(1).txt', { type: 'text/plain' });
|
||||
|
||||
const result = FileUploadValidator.validateFileName(file);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('suspicious');
|
||||
});
|
||||
|
||||
it('should reject file names with null bytes', () => {
|
||||
const file = new File(['content'], 'file\0.txt', { type: 'text/plain' });
|
||||
|
||||
const result = FileUploadValidator.validateFileName(file);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('null bytes');
|
||||
});
|
||||
|
||||
it('should reject excessively long file names', () => {
|
||||
const longName = 'a'.repeat(300) + '.txt';
|
||||
const file = new File(['content'], longName, { type: 'text/plain' });
|
||||
|
||||
const result = FileUploadValidator.validateFileName(file);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('too long');
|
||||
});
|
||||
|
||||
it('should accept valid file names', () => {
|
||||
const file = new File(['content'], 'valid-file_name.txt', { type: 'text/plain' });
|
||||
|
||||
const result = FileUploadValidator.validateFileName(file);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFile', () => {
|
||||
it('should perform comprehensive validation', () => {
|
||||
const file = new File(['content'], 'image.jpg', { type: 'image/jpeg' });
|
||||
Object.defineProperty(file, 'size', { value: 1024 * 1024 }); // 1MB
|
||||
|
||||
const result = FileUploadValidator.validateFile(file, {
|
||||
maxSizeMB: 50,
|
||||
allowedMimeTypes: ['image/jpeg', 'image/png'],
|
||||
allowedExtensions: ['jpg', 'png']
|
||||
});
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject file if any validation fails', () => {
|
||||
const file = new File(['content'], 'image.exe', { type: 'image/jpeg' });
|
||||
Object.defineProperty(file, 'size', { value: 1024 * 1024 }); // 1MB
|
||||
|
||||
const result = FileUploadValidator.validateFile(file, {
|
||||
maxSizeMB: 50,
|
||||
allowedMimeTypes: ['image/jpeg'],
|
||||
allowedExtensions: ['jpg', 'png']
|
||||
});
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('not allowed for security reasons');
|
||||
});
|
||||
|
||||
it('should reject null file', () => {
|
||||
const result = FileUploadValidator.validateFile(null as any);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('No file provided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFiles', () => {
|
||||
it('should validate multiple files', () => {
|
||||
const files = [
|
||||
new File(['content'], 'image1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['content'], 'image2.png', { type: 'image/png' }),
|
||||
];
|
||||
|
||||
const result = FileUploadValidator.validateFiles(files, {
|
||||
allowedMimeTypes: ['image/jpeg', 'image/png']
|
||||
});
|
||||
|
||||
expect(result.valid.length).toBe(2);
|
||||
expect(result.invalid.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should separate valid and invalid files', () => {
|
||||
const files = [
|
||||
new File(['content'], 'image.jpg', { type: 'image/jpeg' }),
|
||||
new File(['content'], 'script.js', { type: 'application/javascript' }),
|
||||
];
|
||||
|
||||
const result = FileUploadValidator.validateFiles(files, {
|
||||
allowedMimeTypes: ['image/jpeg', 'image/png']
|
||||
});
|
||||
|
||||
expect(result.valid.length).toBe(1);
|
||||
expect(result.invalid.length).toBe(1);
|
||||
expect(result.invalid[0].file.name).toBe('script.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileCategory', () => {
|
||||
it('should categorize image files', () => {
|
||||
const file = new File(['content'], 'image.jpg', { type: 'image/jpeg' });
|
||||
expect(FileUploadValidator.getFileCategory(file)).toBe('image');
|
||||
});
|
||||
|
||||
it('should categorize document files', () => {
|
||||
const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' });
|
||||
expect(FileUploadValidator.getFileCategory(file)).toBe('document');
|
||||
});
|
||||
|
||||
it('should categorize archive files', () => {
|
||||
const file = new File(['content'], 'archive.zip', { type: 'application/zip' });
|
||||
expect(FileUploadValidator.getFileCategory(file)).toBe('archive');
|
||||
});
|
||||
|
||||
it('should categorize unknown files as other', () => {
|
||||
const file = new File(['content'], 'unknown.bin', { type: 'application/octet-stream' });
|
||||
expect(FileUploadValidator.getFileCategory(file)).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFileName', () => {
|
||||
it('should remove invalid characters', () => {
|
||||
const result = FileUploadValidator.sanitizeFileName('file<script>.txt');
|
||||
expect(result).toBe('file_script_.txt');
|
||||
});
|
||||
|
||||
it('should remove null bytes', () => {
|
||||
const result = FileUploadValidator.sanitizeFileName('file\0.txt');
|
||||
expect(result).toBe('file.txt');
|
||||
});
|
||||
|
||||
it('should truncate long names', () => {
|
||||
const longName = 'a'.repeat(300) + '.txt';
|
||||
const result = FileUploadValidator.sanitizeFileName(longName);
|
||||
expect(result.length).toBe(255);
|
||||
});
|
||||
|
||||
it('should preserve valid names', () => {
|
||||
const result = FileUploadValidator.sanitizeFileName('valid-file_name.txt');
|
||||
expect(result).toBe('valid-file_name.txt');
|
||||
});
|
||||
});
|
||||
});
|
||||
import { FileUploadValidator } from '../FileUploadValidator';
|
||||
|
||||
describe('FileUploadValidator', () => {
|
||||
describe('validateFileSize', () => {
|
||||
it('should accept files within size limit', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
|
||||
Object.defineProperty(file, 'size', { value: 1024 * 1024 }); // 1MB
|
||||
|
||||
const result = FileUploadValidator.validateFileSize(file, 50);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject files exceeding size limit', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
|
||||
Object.defineProperty(file, 'size', { value: 100 * 1024 * 1024 }); // 100MB
|
||||
|
||||
const result = FileUploadValidator.validateFileSize(file, 50);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('exceeds maximum allowed size');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFileExtension', () => {
|
||||
it('should reject dangerous extensions', () => {
|
||||
const file = new File(['content'], 'malware.exe', { type: 'application/octet-stream' });
|
||||
|
||||
const result = FileUploadValidator.validateFileExtension(file);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('not allowed for security reasons');
|
||||
});
|
||||
|
||||
it('should accept allowed extensions', () => {
|
||||
const file = new File(['content'], 'image.jpg', { type: 'image/jpeg' });
|
||||
|
||||
const result = FileUploadValidator.validateFileExtension(file, ['jpg', 'png']);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject disallowed extensions', () => {
|
||||
const file = new File(['content'], 'document.exe', { type: 'application/octet-stream' });
|
||||
|
||||
const result = FileUploadValidator.validateFileExtension(file, ['jpg', 'png']);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('not allowed');
|
||||
});
|
||||
|
||||
it('should handle extensions with or without dot', () => {
|
||||
const file = new File(['content'], 'image.jpg', { type: 'image/jpeg' });
|
||||
|
||||
const result1 = FileUploadValidator.validateFileExtension(file, ['.jpg', '.png']);
|
||||
const result2 = FileUploadValidator.validateFileExtension(file, ['jpg', 'png']);
|
||||
|
||||
expect(result1.isValid).toBe(true);
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMimeType', () => {
|
||||
it('should accept allowed MIME types', () => {
|
||||
const file = new File(['content'], 'image.jpg', { type: 'image/jpeg' });
|
||||
|
||||
const result = FileUploadValidator.validateMimeType(file, ['image/jpeg', 'image/png']);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject disallowed MIME types', () => {
|
||||
const file = new File(['content'], 'script.js', { type: 'application/javascript' });
|
||||
|
||||
const result = FileUploadValidator.validateMimeType(file, ['image/jpeg', 'image/png']);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('not allowed');
|
||||
});
|
||||
|
||||
it('should support wildcard MIME types', () => {
|
||||
const file = new File(['content'], 'image.jpg', { type: 'image/jpeg' });
|
||||
|
||||
const result = FileUploadValidator.validateMimeType(file, ['image/*']);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept any MIME type if list is empty', () => {
|
||||
const file = new File(['content'], 'anything.bin', { type: 'application/octet-stream' });
|
||||
|
||||
const result = FileUploadValidator.validateMimeType(file, []);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFileName', () => {
|
||||
it('should reject file names with invalid characters', () => {
|
||||
const file = new File(['content'], 'file<script>.txt', { type: 'text/plain' });
|
||||
|
||||
const result = FileUploadValidator.validateFileName(file);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('suspicious characters');
|
||||
});
|
||||
|
||||
it('should reject file names with javascript protocol', () => {
|
||||
const file = new File(['content'], 'javascript:alert(1).txt', { type: 'text/plain' });
|
||||
|
||||
const result = FileUploadValidator.validateFileName(file);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('suspicious');
|
||||
});
|
||||
|
||||
it('should reject file names with event handlers', () => {
|
||||
const file = new File(['content'], 'onerror=alert(1).txt', { type: 'text/plain' });
|
||||
|
||||
const result = FileUploadValidator.validateFileName(file);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('suspicious');
|
||||
});
|
||||
|
||||
it('should reject file names with null bytes', () => {
|
||||
const file = new File(['content'], 'file\0.txt', { type: 'text/plain' });
|
||||
|
||||
const result = FileUploadValidator.validateFileName(file);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('null bytes');
|
||||
});
|
||||
|
||||
it('should reject excessively long file names', () => {
|
||||
const longName = 'a'.repeat(300) + '.txt';
|
||||
const file = new File(['content'], longName, { type: 'text/plain' });
|
||||
|
||||
const result = FileUploadValidator.validateFileName(file);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('too long');
|
||||
});
|
||||
|
||||
it('should accept valid file names', () => {
|
||||
const file = new File(['content'], 'valid-file_name.txt', { type: 'text/plain' });
|
||||
|
||||
const result = FileUploadValidator.validateFileName(file);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFile', () => {
|
||||
it('should perform comprehensive validation', () => {
|
||||
const file = new File(['content'], 'image.jpg', { type: 'image/jpeg' });
|
||||
Object.defineProperty(file, 'size', { value: 1024 * 1024 }); // 1MB
|
||||
|
||||
const result = FileUploadValidator.validateFile(file, {
|
||||
maxSizeMB: 50,
|
||||
allowedMimeTypes: ['image/jpeg', 'image/png'],
|
||||
allowedExtensions: ['jpg', 'png']
|
||||
});
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject file if any validation fails', () => {
|
||||
const file = new File(['content'], 'image.exe', { type: 'image/jpeg' });
|
||||
Object.defineProperty(file, 'size', { value: 1024 * 1024 }); // 1MB
|
||||
|
||||
const result = FileUploadValidator.validateFile(file, {
|
||||
maxSizeMB: 50,
|
||||
allowedMimeTypes: ['image/jpeg'],
|
||||
allowedExtensions: ['jpg', 'png']
|
||||
});
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('not allowed for security reasons');
|
||||
});
|
||||
|
||||
it('should reject null file', () => {
|
||||
const result = FileUploadValidator.validateFile(null as any);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('No file provided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFiles', () => {
|
||||
it('should validate multiple files', () => {
|
||||
const files = [
|
||||
new File(['content'], 'image1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['content'], 'image2.png', { type: 'image/png' }),
|
||||
];
|
||||
|
||||
const result = FileUploadValidator.validateFiles(files, {
|
||||
allowedMimeTypes: ['image/jpeg', 'image/png']
|
||||
});
|
||||
|
||||
expect(result.valid.length).toBe(2);
|
||||
expect(result.invalid.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should separate valid and invalid files', () => {
|
||||
const files = [
|
||||
new File(['content'], 'image.jpg', { type: 'image/jpeg' }),
|
||||
new File(['content'], 'script.js', { type: 'application/javascript' }),
|
||||
];
|
||||
|
||||
const result = FileUploadValidator.validateFiles(files, {
|
||||
allowedMimeTypes: ['image/jpeg', 'image/png']
|
||||
});
|
||||
|
||||
expect(result.valid.length).toBe(1);
|
||||
expect(result.invalid.length).toBe(1);
|
||||
expect(result.invalid[0].file.name).toBe('script.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileCategory', () => {
|
||||
it('should categorize image files', () => {
|
||||
const file = new File(['content'], 'image.jpg', { type: 'image/jpeg' });
|
||||
expect(FileUploadValidator.getFileCategory(file)).toBe('image');
|
||||
});
|
||||
|
||||
it('should categorize document files', () => {
|
||||
const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' });
|
||||
expect(FileUploadValidator.getFileCategory(file)).toBe('document');
|
||||
});
|
||||
|
||||
it('should categorize archive files', () => {
|
||||
const file = new File(['content'], 'archive.zip', { type: 'application/zip' });
|
||||
expect(FileUploadValidator.getFileCategory(file)).toBe('archive');
|
||||
});
|
||||
|
||||
it('should categorize unknown files as other', () => {
|
||||
const file = new File(['content'], 'unknown.bin', { type: 'application/octet-stream' });
|
||||
expect(FileUploadValidator.getFileCategory(file)).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFileName', () => {
|
||||
it('should remove invalid characters', () => {
|
||||
const result = FileUploadValidator.sanitizeFileName('file<script>.txt');
|
||||
expect(result).toBe('file_script_.txt');
|
||||
});
|
||||
|
||||
it('should remove null bytes', () => {
|
||||
const result = FileUploadValidator.sanitizeFileName('file\0.txt');
|
||||
expect(result).toBe('file.txt');
|
||||
});
|
||||
|
||||
it('should truncate long names', () => {
|
||||
const longName = 'a'.repeat(300) + '.txt';
|
||||
const result = FileUploadValidator.sanitizeFileName(longName);
|
||||
expect(result.length).toBe(255);
|
||||
});
|
||||
|
||||
it('should preserve valid names', () => {
|
||||
const result = FileUploadValidator.sanitizeFileName('valid-file_name.txt');
|
||||
expect(result).toBe('valid-file_name.txt');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,185 +1,185 @@
|
||||
import { SecurityLogger } from '../SecurityLogger';
|
||||
import { ValidationLogEntry } from '../types';
|
||||
|
||||
describe('SecurityLogger', () => {
|
||||
let logger: SecurityLogger;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = new SecurityLogger();
|
||||
});
|
||||
|
||||
describe('logValidationEvent', () => {
|
||||
it('should log validation success event', async () => {
|
||||
const entry: ValidationLogEntry = {
|
||||
timestamp: new Date(),
|
||||
fileSize: 1024,
|
||||
declaredMime: 'image/png',
|
||||
detectedMime: 'image/png',
|
||||
validationResult: 'success',
|
||||
module: 'record-management',
|
||||
endpoint: '/record-attachments',
|
||||
};
|
||||
|
||||
await logger.logValidationEvent(entry);
|
||||
const logs = logger.getAllLogs();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].validationResult).toBe('success');
|
||||
expect(logs[0].declaredMime).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should log validation mismatch event', async () => {
|
||||
const entry: ValidationLogEntry = {
|
||||
timestamp: new Date(),
|
||||
fileSize: 1024,
|
||||
declaredMime: 'image/jpeg',
|
||||
detectedMime: 'image/png',
|
||||
validationResult: 'mismatch',
|
||||
errorDetails: 'MIME type mismatch',
|
||||
module: 'user-management',
|
||||
endpoint: '/theme/upload',
|
||||
};
|
||||
|
||||
await logger.logValidationEvent(entry);
|
||||
const logs = logger.getAllLogs();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].validationResult).toBe('mismatch');
|
||||
expect(logs[0].errorDetails).toBe('MIME type mismatch');
|
||||
});
|
||||
|
||||
it('should log validation error event', async () => {
|
||||
const entry: ValidationLogEntry = {
|
||||
timestamp: new Date(),
|
||||
fileSize: 0,
|
||||
declaredMime: 'image/png',
|
||||
validationResult: 'error',
|
||||
errorDetails: 'File buffer is empty',
|
||||
module: 'external-portal',
|
||||
endpoint: '/user-documents',
|
||||
};
|
||||
|
||||
await logger.logValidationEvent(entry);
|
||||
const logs = logger.getAllLogs();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].validationResult).toBe('error');
|
||||
});
|
||||
|
||||
it('should set timestamp if not provided', async () => {
|
||||
const entry: ValidationLogEntry = {
|
||||
fileSize: 1024,
|
||||
declaredMime: 'image/png',
|
||||
detectedMime: 'image/png',
|
||||
validationResult: 'success',
|
||||
module: 'record-management',
|
||||
endpoint: '/record-attachments',
|
||||
} as any;
|
||||
|
||||
await logger.logValidationEvent(entry);
|
||||
const logs = logger.getAllLogs();
|
||||
|
||||
expect(logs[0].timestamp).toBeDefined();
|
||||
expect(logs[0].timestamp instanceof Date).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryValidationLogs', () => {
|
||||
beforeEach(async () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
await logger.logValidationEvent({
|
||||
timestamp: now,
|
||||
userId: 'user1',
|
||||
fileSize: 1024,
|
||||
declaredMime: 'image/png',
|
||||
detectedMime: 'image/png',
|
||||
validationResult: 'success',
|
||||
module: 'record-management',
|
||||
endpoint: '/record-attachments',
|
||||
});
|
||||
|
||||
await logger.logValidationEvent({
|
||||
timestamp: now,
|
||||
userId: 'user2',
|
||||
fileSize: 2048,
|
||||
declaredMime: 'image/jpeg',
|
||||
detectedMime: 'image/png',
|
||||
validationResult: 'mismatch',
|
||||
errorDetails: 'MIME mismatch',
|
||||
module: 'user-management',
|
||||
endpoint: '/theme/upload',
|
||||
});
|
||||
|
||||
await logger.logValidationEvent({
|
||||
timestamp: yesterday,
|
||||
userId: 'user1',
|
||||
fileSize: 512,
|
||||
declaredMime: 'application/pdf',
|
||||
validationResult: 'error',
|
||||
errorDetails: 'File buffer is empty',
|
||||
module: 'external-portal',
|
||||
endpoint: '/user-documents',
|
||||
});
|
||||
});
|
||||
|
||||
it('should query logs by module', async () => {
|
||||
const logs = await logger.queryValidationLogs({
|
||||
module: 'record-management',
|
||||
});
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].module).toBe('record-management');
|
||||
});
|
||||
|
||||
it('should query logs by validation result', async () => {
|
||||
const logs = await logger.queryValidationLogs({
|
||||
validationResult: 'mismatch',
|
||||
});
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].validationResult).toBe('mismatch');
|
||||
});
|
||||
|
||||
it('should query logs by user ID', async () => {
|
||||
const logs = await logger.queryValidationLogs({ userId: 'user1' });
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs.every((log) => log.userId === 'user1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should query logs by date range', async () => {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getTime() - 12 * 60 * 60 * 1000);
|
||||
|
||||
const logs = await logger.queryValidationLogs({
|
||||
startDate: today,
|
||||
endDate: now,
|
||||
});
|
||||
expect(logs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return all logs with no filters', async () => {
|
||||
const logs = await logger.queryValidationLogs({});
|
||||
expect(logs).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearLogs', () => {
|
||||
it('should clear all logs', async () => {
|
||||
await logger.logValidationEvent({
|
||||
timestamp: new Date(),
|
||||
fileSize: 1024,
|
||||
declaredMime: 'image/png',
|
||||
detectedMime: 'image/png',
|
||||
validationResult: 'success',
|
||||
module: 'record-management',
|
||||
endpoint: '/record-attachments',
|
||||
});
|
||||
|
||||
expect(logger.getAllLogs()).toHaveLength(1);
|
||||
|
||||
logger.clearLogs();
|
||||
|
||||
expect(logger.getAllLogs()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
import { SecurityLogger } from '../SecurityLogger';
|
||||
import { ValidationLogEntry } from '../types';
|
||||
|
||||
describe('SecurityLogger', () => {
|
||||
let logger: SecurityLogger;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = new SecurityLogger();
|
||||
});
|
||||
|
||||
describe('logValidationEvent', () => {
|
||||
it('should log validation success event', async () => {
|
||||
const entry: ValidationLogEntry = {
|
||||
timestamp: new Date(),
|
||||
fileSize: 1024,
|
||||
declaredMime: 'image/png',
|
||||
detectedMime: 'image/png',
|
||||
validationResult: 'success',
|
||||
module: 'record-management',
|
||||
endpoint: '/record-attachments',
|
||||
};
|
||||
|
||||
await logger.logValidationEvent(entry);
|
||||
const logs = logger.getAllLogs();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].validationResult).toBe('success');
|
||||
expect(logs[0].declaredMime).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should log validation mismatch event', async () => {
|
||||
const entry: ValidationLogEntry = {
|
||||
timestamp: new Date(),
|
||||
fileSize: 1024,
|
||||
declaredMime: 'image/jpeg',
|
||||
detectedMime: 'image/png',
|
||||
validationResult: 'mismatch',
|
||||
errorDetails: 'MIME type mismatch',
|
||||
module: 'user-management',
|
||||
endpoint: '/theme/upload',
|
||||
};
|
||||
|
||||
await logger.logValidationEvent(entry);
|
||||
const logs = logger.getAllLogs();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].validationResult).toBe('mismatch');
|
||||
expect(logs[0].errorDetails).toBe('MIME type mismatch');
|
||||
});
|
||||
|
||||
it('should log validation error event', async () => {
|
||||
const entry: ValidationLogEntry = {
|
||||
timestamp: new Date(),
|
||||
fileSize: 0,
|
||||
declaredMime: 'image/png',
|
||||
validationResult: 'error',
|
||||
errorDetails: 'File buffer is empty',
|
||||
module: 'external-portal',
|
||||
endpoint: '/user-documents',
|
||||
};
|
||||
|
||||
await logger.logValidationEvent(entry);
|
||||
const logs = logger.getAllLogs();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].validationResult).toBe('error');
|
||||
});
|
||||
|
||||
it('should set timestamp if not provided', async () => {
|
||||
const entry: ValidationLogEntry = {
|
||||
fileSize: 1024,
|
||||
declaredMime: 'image/png',
|
||||
detectedMime: 'image/png',
|
||||
validationResult: 'success',
|
||||
module: 'record-management',
|
||||
endpoint: '/record-attachments',
|
||||
} as any;
|
||||
|
||||
await logger.logValidationEvent(entry);
|
||||
const logs = logger.getAllLogs();
|
||||
|
||||
expect(logs[0].timestamp).toBeDefined();
|
||||
expect(logs[0].timestamp instanceof Date).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryValidationLogs', () => {
|
||||
beforeEach(async () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
await logger.logValidationEvent({
|
||||
timestamp: now,
|
||||
userId: 'user1',
|
||||
fileSize: 1024,
|
||||
declaredMime: 'image/png',
|
||||
detectedMime: 'image/png',
|
||||
validationResult: 'success',
|
||||
module: 'record-management',
|
||||
endpoint: '/record-attachments',
|
||||
});
|
||||
|
||||
await logger.logValidationEvent({
|
||||
timestamp: now,
|
||||
userId: 'user2',
|
||||
fileSize: 2048,
|
||||
declaredMime: 'image/jpeg',
|
||||
detectedMime: 'image/png',
|
||||
validationResult: 'mismatch',
|
||||
errorDetails: 'MIME mismatch',
|
||||
module: 'user-management',
|
||||
endpoint: '/theme/upload',
|
||||
});
|
||||
|
||||
await logger.logValidationEvent({
|
||||
timestamp: yesterday,
|
||||
userId: 'user1',
|
||||
fileSize: 512,
|
||||
declaredMime: 'application/pdf',
|
||||
validationResult: 'error',
|
||||
errorDetails: 'File buffer is empty',
|
||||
module: 'external-portal',
|
||||
endpoint: '/user-documents',
|
||||
});
|
||||
});
|
||||
|
||||
it('should query logs by module', async () => {
|
||||
const logs = await logger.queryValidationLogs({
|
||||
module: 'record-management',
|
||||
});
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].module).toBe('record-management');
|
||||
});
|
||||
|
||||
it('should query logs by validation result', async () => {
|
||||
const logs = await logger.queryValidationLogs({
|
||||
validationResult: 'mismatch',
|
||||
});
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].validationResult).toBe('mismatch');
|
||||
});
|
||||
|
||||
it('should query logs by user ID', async () => {
|
||||
const logs = await logger.queryValidationLogs({ userId: 'user1' });
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs.every((log) => log.userId === 'user1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should query logs by date range', async () => {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getTime() - 12 * 60 * 60 * 1000);
|
||||
|
||||
const logs = await logger.queryValidationLogs({
|
||||
startDate: today,
|
||||
endDate: now,
|
||||
});
|
||||
expect(logs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return all logs with no filters', async () => {
|
||||
const logs = await logger.queryValidationLogs({});
|
||||
expect(logs).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearLogs', () => {
|
||||
it('should clear all logs', async () => {
|
||||
await logger.logValidationEvent({
|
||||
timestamp: new Date(),
|
||||
fileSize: 1024,
|
||||
declaredMime: 'image/png',
|
||||
detectedMime: 'image/png',
|
||||
validationResult: 'success',
|
||||
module: 'record-management',
|
||||
endpoint: '/record-attachments',
|
||||
});
|
||||
|
||||
expect(logger.getAllLogs()).toHaveLength(1);
|
||||
|
||||
logger.clearLogs();
|
||||
|
||||
expect(logger.getAllLogs()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,107 +1,107 @@
|
||||
/**
|
||||
* Error message templates for XSS validation failures
|
||||
* Provides clear, actionable error messages to users
|
||||
*/
|
||||
|
||||
export const ValidationErrorMessages = {
|
||||
// File name validation errors
|
||||
fileName: {
|
||||
suspicious: (fileName: string) =>
|
||||
`File name contains suspicious characters or patterns: "${fileName}". Please rename the file to remove special characters like <, >, :, ", /, \\, |, ?, *.`,
|
||||
nullBytes: 'File name contains null bytes. Please rename the file.',
|
||||
tooLong: (length: number) =>
|
||||
`File name is too long (${length} characters). Maximum allowed is 255 characters. Please rename the file.`,
|
||||
},
|
||||
|
||||
// File extension validation errors
|
||||
extension: {
|
||||
dangerous: (ext: string) =>
|
||||
`File extension ${ext} is not allowed for security reasons. This file type could pose a security risk.`,
|
||||
notAllowed: (ext: string, allowed: string[]) =>
|
||||
`File extension ${ext} is not allowed. Allowed types: ${allowed.join(', ')}. Please select a file with one of the allowed extensions.`,
|
||||
},
|
||||
|
||||
// MIME type validation errors
|
||||
mimeType: {
|
||||
notAllowed: (mimeType: string, allowed: string[]) =>
|
||||
`File type ${mimeType} is not allowed. Allowed types: ${allowed.join(', ')}. Please select a file with one of the allowed types.`,
|
||||
mismatch: (declared: string, detected: string) =>
|
||||
`File type verification failed. The file content does not match its declared type (declared: ${declared}, detected: ${detected}). Please ensure the file matches its extension.`,
|
||||
undetectable: 'Unable to detect file type from buffer. The file may be corrupted. Please try uploading a different file.',
|
||||
},
|
||||
|
||||
// File size validation errors
|
||||
size: {
|
||||
tooLarge: (sizeMB: number, maxMB: number) =>
|
||||
`File size (${sizeMB.toFixed(2)}MB) exceeds maximum allowed size (${maxMB}MB). Please select a smaller file.`,
|
||||
},
|
||||
|
||||
// Server-side validation errors
|
||||
server: {
|
||||
mimeTypeMismatch: (declared: string, detected: string) =>
|
||||
`File type verification failed. The file content does not match its declared type. Please ensure the file matches its extension.`,
|
||||
disallowedType: (mimeType: string) =>
|
||||
`File type ${mimeType} is not allowed for this upload context. Please select a file with an allowed type.`,
|
||||
detectionFailed: 'Unable to verify file type. Please try again or contact support.',
|
||||
uploadFailed: 'File upload failed validation. Please try uploading a different file.',
|
||||
},
|
||||
|
||||
// Generic errors
|
||||
generic: {
|
||||
noFile: 'No file provided. Please select a file to upload.',
|
||||
validationFailed: 'File validation failed. Please check the file and try again.',
|
||||
unknown: 'An unexpected error occurred during file validation. Please try again.',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get user-friendly error message from validation error
|
||||
* @param error - Error message or validation result
|
||||
* @returns User-friendly error message
|
||||
*/
|
||||
export function getUserFriendlyErrorMessage(error: string | undefined): string {
|
||||
if (!error) {
|
||||
return ValidationErrorMessages.generic.validationFailed;
|
||||
}
|
||||
|
||||
// Return the error as-is if it's already a user-friendly message
|
||||
// (it will be one of the messages from ValidationErrorMessages)
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get corrective action suggestion based on error type
|
||||
* @param error - Error message
|
||||
* @returns Suggested corrective action
|
||||
*/
|
||||
export function getCorrectiveAction(error: string | undefined): string {
|
||||
if (!error) {
|
||||
return 'Please check the file and try again.';
|
||||
}
|
||||
|
||||
if (error.includes('suspicious characters')) {
|
||||
return 'Rename the file to remove special characters.';
|
||||
}
|
||||
|
||||
if (error.includes('not allowed')) {
|
||||
return 'Select a file with an allowed type.';
|
||||
}
|
||||
|
||||
if (error.includes('too long')) {
|
||||
return 'Rename the file to make it shorter.';
|
||||
}
|
||||
|
||||
if (error.includes('exceeds maximum')) {
|
||||
return 'Select a smaller file.';
|
||||
}
|
||||
|
||||
if (error.includes('type verification failed')) {
|
||||
return 'Ensure the file matches its extension.';
|
||||
}
|
||||
|
||||
if (error.includes('corrupted')) {
|
||||
return 'Try uploading a different file.';
|
||||
}
|
||||
|
||||
return 'Please try again or contact support.';
|
||||
}
|
||||
/**
|
||||
* Error message templates for XSS validation failures
|
||||
* Provides clear, actionable error messages to users
|
||||
*/
|
||||
|
||||
export const ValidationErrorMessages = {
|
||||
// File name validation errors
|
||||
fileName: {
|
||||
suspicious: (fileName: string) =>
|
||||
`File name contains suspicious characters or patterns: "${fileName}". Please rename the file to remove special characters like <, >, :, ", /, \\, |, ?, *.`,
|
||||
nullBytes: 'File name contains null bytes. Please rename the file.',
|
||||
tooLong: (length: number) =>
|
||||
`File name is too long (${length} characters). Maximum allowed is 255 characters. Please rename the file.`,
|
||||
},
|
||||
|
||||
// File extension validation errors
|
||||
extension: {
|
||||
dangerous: (ext: string) =>
|
||||
`File extension ${ext} is not allowed for security reasons. This file type could pose a security risk.`,
|
||||
notAllowed: (ext: string, allowed: string[]) =>
|
||||
`File extension ${ext} is not allowed. Allowed types: ${allowed.join(', ')}. Please select a file with one of the allowed extensions.`,
|
||||
},
|
||||
|
||||
// MIME type validation errors
|
||||
mimeType: {
|
||||
notAllowed: (mimeType: string, allowed: string[]) =>
|
||||
`File type ${mimeType} is not allowed. Allowed types: ${allowed.join(', ')}. Please select a file with one of the allowed types.`,
|
||||
mismatch: (declared: string, detected: string) =>
|
||||
`File type verification failed. The file content does not match its declared type (declared: ${declared}, detected: ${detected}). Please ensure the file matches its extension.`,
|
||||
undetectable: 'Unable to detect file type from buffer. The file may be corrupted. Please try uploading a different file.',
|
||||
},
|
||||
|
||||
// File size validation errors
|
||||
size: {
|
||||
tooLarge: (sizeMB: number, maxMB: number) =>
|
||||
`File size (${sizeMB.toFixed(2)}MB) exceeds maximum allowed size (${maxMB}MB). Please select a smaller file.`,
|
||||
},
|
||||
|
||||
// Server-side validation errors
|
||||
server: {
|
||||
mimeTypeMismatch: (declared: string, detected: string) =>
|
||||
`File type verification failed. The file content does not match its declared type. Please ensure the file matches its extension.`,
|
||||
disallowedType: (mimeType: string) =>
|
||||
`File type ${mimeType} is not allowed for this upload context. Please select a file with an allowed type.`,
|
||||
detectionFailed: 'Unable to verify file type. Please try again or contact support.',
|
||||
uploadFailed: 'File upload failed validation. Please try uploading a different file.',
|
||||
},
|
||||
|
||||
// Generic errors
|
||||
generic: {
|
||||
noFile: 'No file provided. Please select a file to upload.',
|
||||
validationFailed: 'File validation failed. Please check the file and try again.',
|
||||
unknown: 'An unexpected error occurred during file validation. Please try again.',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get user-friendly error message from validation error
|
||||
* @param error - Error message or validation result
|
||||
* @returns User-friendly error message
|
||||
*/
|
||||
export function getUserFriendlyErrorMessage(error: string | undefined): string {
|
||||
if (!error) {
|
||||
return ValidationErrorMessages.generic.validationFailed;
|
||||
}
|
||||
|
||||
// Return the error as-is if it's already a user-friendly message
|
||||
// (it will be one of the messages from ValidationErrorMessages)
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get corrective action suggestion based on error type
|
||||
* @param error - Error message
|
||||
* @returns Suggested corrective action
|
||||
*/
|
||||
export function getCorrectiveAction(error: string | undefined): string {
|
||||
if (!error) {
|
||||
return 'Please check the file and try again.';
|
||||
}
|
||||
|
||||
if (error.includes('suspicious characters')) {
|
||||
return 'Rename the file to remove special characters.';
|
||||
}
|
||||
|
||||
if (error.includes('not allowed')) {
|
||||
return 'Select a file with an allowed type.';
|
||||
}
|
||||
|
||||
if (error.includes('too long')) {
|
||||
return 'Rename the file to make it shorter.';
|
||||
}
|
||||
|
||||
if (error.includes('exceeds maximum')) {
|
||||
return 'Select a smaller file.';
|
||||
}
|
||||
|
||||
if (error.includes('type verification failed')) {
|
||||
return 'Ensure the file matches its extension.';
|
||||
}
|
||||
|
||||
if (error.includes('corrupted')) {
|
||||
return 'Try uploading a different file.';
|
||||
}
|
||||
|
||||
return 'Please try again or contact support.';
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
/**
|
||||
* Central validation module for file uploads
|
||||
* Provides centralized buffer content validation and security logging
|
||||
*/
|
||||
|
||||
export { BufferContentValidator, bufferContentValidator } from './BufferContentValidator';
|
||||
export { SecurityLogger, securityLogger } from './SecurityLogger';
|
||||
export { ClientSideValidator } from './ClientSideValidator';
|
||||
export { FileUploadValidator } from './FileUploadValidator';
|
||||
export { XSSUploadValidator } from './XSSUploadValidator';
|
||||
export { SVGContentValidator } from './SVGContentValidator';
|
||||
export { EmbeddedContentValidator } from './EmbeddedContentValidator';
|
||||
export { MagicByteDetector } from './MagicByteDetector';
|
||||
export {
|
||||
recordManagementContext,
|
||||
externalPortalContext,
|
||||
incomingRecordContext,
|
||||
outgoingRecordContext,
|
||||
signatureContext,
|
||||
getUploadContext,
|
||||
secureDefaultContext,
|
||||
} from './uploadContexts';
|
||||
export {
|
||||
ValidationErrorMessages,
|
||||
getUserFriendlyErrorMessage,
|
||||
getCorrectiveAction,
|
||||
} from './errorMessages';
|
||||
export type {
|
||||
FileTypeDetectionResult,
|
||||
ValidationResult,
|
||||
ValidationLogEntry,
|
||||
UploadValidationOptions,
|
||||
FileValidationState,
|
||||
XSSUploadValidationContext,
|
||||
XSSUploadValidationResult,
|
||||
ClientValidationDetails,
|
||||
ServerValidationDetails,
|
||||
XSSValidationLogEntry,
|
||||
} from './types';
|
||||
/**
|
||||
* Central validation module for file uploads
|
||||
* Provides centralized buffer content validation and security logging
|
||||
*/
|
||||
|
||||
export { BufferContentValidator, bufferContentValidator } from './BufferContentValidator';
|
||||
export { SecurityLogger, securityLogger } from './SecurityLogger';
|
||||
export { ClientSideValidator } from './ClientSideValidator';
|
||||
export { FileUploadValidator } from './FileUploadValidator';
|
||||
export { XSSUploadValidator } from './XSSUploadValidator';
|
||||
export { SVGContentValidator } from './SVGContentValidator';
|
||||
export { EmbeddedContentValidator } from './EmbeddedContentValidator';
|
||||
export { MagicByteDetector } from './MagicByteDetector';
|
||||
export {
|
||||
recordManagementContext,
|
||||
externalPortalContext,
|
||||
incomingRecordContext,
|
||||
outgoingRecordContext,
|
||||
signatureContext,
|
||||
getUploadContext,
|
||||
secureDefaultContext,
|
||||
} from './uploadContexts';
|
||||
export {
|
||||
ValidationErrorMessages,
|
||||
getUserFriendlyErrorMessage,
|
||||
getCorrectiveAction,
|
||||
} from './errorMessages';
|
||||
export type {
|
||||
FileTypeDetectionResult,
|
||||
ValidationResult,
|
||||
ValidationLogEntry,
|
||||
UploadValidationOptions,
|
||||
FileValidationState,
|
||||
XSSUploadValidationContext,
|
||||
XSSUploadValidationResult,
|
||||
ClientValidationDetails,
|
||||
ServerValidationDetails,
|
||||
XSSValidationLogEntry,
|
||||
} from './types';
|
||||
|
||||
@@ -1,129 +1,129 @@
|
||||
/**
|
||||
* File type detection result from magic byte analysis
|
||||
*/
|
||||
export interface FileTypeDetectionResult {
|
||||
mime: string;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of MIME type validation
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
detectedMime: string;
|
||||
declaredMime: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation log entry for security auditing
|
||||
*/
|
||||
export interface ValidationLogEntry {
|
||||
timestamp: Date;
|
||||
userId?: string;
|
||||
fileSize: number;
|
||||
fileName?: string;
|
||||
declaredMime: string;
|
||||
detectedMime?: string;
|
||||
validationResult: 'success' | 'mismatch' | 'error';
|
||||
errorDetails?: string;
|
||||
module: 'record-management' | 'user-management' | 'external-portal';
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for file upload validation
|
||||
*/
|
||||
export interface UploadValidationOptions {
|
||||
allowedMimeTypes: string[];
|
||||
maxFileSize?: number;
|
||||
logValidationEvents?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* File validation state for tracking uploads
|
||||
*/
|
||||
export interface FileValidationState {
|
||||
fileId: string;
|
||||
uploadedAt: Date;
|
||||
declaredMimeType: string;
|
||||
detectedMimeType?: string;
|
||||
validationStatus: 'pending' | 'valid' | 'invalid' | 'error';
|
||||
validationError?: string;
|
||||
userId?: string;
|
||||
module: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for XSS upload validation
|
||||
*/
|
||||
export interface XSSUploadValidationContext {
|
||||
allowedMimeTypes?: string[];
|
||||
allowedExtensions?: string[];
|
||||
maxSizeMB?: number;
|
||||
uploadContext: 'record' | 'external-portal' | 'incoming' | 'outgoing' | 'signature';
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side validation details
|
||||
*/
|
||||
export interface ClientValidationDetails {
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
fileName?: {
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
};
|
||||
extension?: {
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
};
|
||||
mimeType?: {
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
};
|
||||
size?: {
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side validation details
|
||||
*/
|
||||
export interface ServerValidationDetails {
|
||||
passed: boolean;
|
||||
detectedMime: string;
|
||||
declaredMime: string;
|
||||
mimeMatch: boolean;
|
||||
allowedTypeMatch: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of XSS upload validation
|
||||
*/
|
||||
export interface XSSUploadValidationResult {
|
||||
isValid: boolean;
|
||||
clientValidation: ClientValidationDetails;
|
||||
serverValidation?: ServerValidationDetails;
|
||||
sanitizedFileName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* XSS validation log entry for security auditing
|
||||
*/
|
||||
export interface XSSValidationLogEntry {
|
||||
timestamp: Date;
|
||||
userId?: string;
|
||||
fileName: string;
|
||||
sanitizedFileName: string;
|
||||
fileSize: number;
|
||||
declaredMimeType: string;
|
||||
detectedMimeType?: string;
|
||||
validationResult: 'success' | 'client-failed' | 'server-failed' | 'error';
|
||||
failureReason?: string;
|
||||
uploadContext: 'record' | 'external-portal' | 'incoming' | 'outgoing' | 'signature';
|
||||
module: 'record-management' | 'user-management' | 'external-portal';
|
||||
}
|
||||
/**
|
||||
* File type detection result from magic byte analysis
|
||||
*/
|
||||
export interface FileTypeDetectionResult {
|
||||
mime: string;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of MIME type validation
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
detectedMime: string;
|
||||
declaredMime: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation log entry for security auditing
|
||||
*/
|
||||
export interface ValidationLogEntry {
|
||||
timestamp: Date;
|
||||
userId?: string;
|
||||
fileSize: number;
|
||||
fileName?: string;
|
||||
declaredMime: string;
|
||||
detectedMime?: string;
|
||||
validationResult: 'success' | 'mismatch' | 'error';
|
||||
errorDetails?: string;
|
||||
module: 'record-management' | 'user-management' | 'external-portal';
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for file upload validation
|
||||
*/
|
||||
export interface UploadValidationOptions {
|
||||
allowedMimeTypes: string[];
|
||||
maxFileSize?: number;
|
||||
logValidationEvents?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* File validation state for tracking uploads
|
||||
*/
|
||||
export interface FileValidationState {
|
||||
fileId: string;
|
||||
uploadedAt: Date;
|
||||
declaredMimeType: string;
|
||||
detectedMimeType?: string;
|
||||
validationStatus: 'pending' | 'valid' | 'invalid' | 'error';
|
||||
validationError?: string;
|
||||
userId?: string;
|
||||
module: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for XSS upload validation
|
||||
*/
|
||||
export interface XSSUploadValidationContext {
|
||||
allowedMimeTypes?: string[];
|
||||
allowedExtensions?: string[];
|
||||
maxSizeMB?: number;
|
||||
uploadContext: 'record' | 'external-portal' | 'incoming' | 'outgoing' | 'signature';
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side validation details
|
||||
*/
|
||||
export interface ClientValidationDetails {
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
fileName?: {
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
};
|
||||
extension?: {
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
};
|
||||
mimeType?: {
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
};
|
||||
size?: {
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side validation details
|
||||
*/
|
||||
export interface ServerValidationDetails {
|
||||
passed: boolean;
|
||||
detectedMime: string;
|
||||
declaredMime: string;
|
||||
mimeMatch: boolean;
|
||||
allowedTypeMatch: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of XSS upload validation
|
||||
*/
|
||||
export interface XSSUploadValidationResult {
|
||||
isValid: boolean;
|
||||
clientValidation: ClientValidationDetails;
|
||||
serverValidation?: ServerValidationDetails;
|
||||
sanitizedFileName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* XSS validation log entry for security auditing
|
||||
*/
|
||||
export interface XSSValidationLogEntry {
|
||||
timestamp: Date;
|
||||
userId?: string;
|
||||
fileName: string;
|
||||
sanitizedFileName: string;
|
||||
fileSize: number;
|
||||
declaredMimeType: string;
|
||||
detectedMimeType?: string;
|
||||
validationResult: 'success' | 'client-failed' | 'server-failed' | 'error';
|
||||
failureReason?: string;
|
||||
uploadContext: 'record' | 'external-portal' | 'incoming' | 'outgoing' | 'signature';
|
||||
module: 'record-management' | 'user-management' | 'external-portal';
|
||||
}
|
||||
|
||||
@@ -1,272 +1,272 @@
|
||||
import { XSSUploadValidationContext } from "./types";
|
||||
|
||||
/**
|
||||
* Upload context definitions for all entry points
|
||||
* Defines allowed MIME types, extensions, and size limits per context
|
||||
*/
|
||||
|
||||
/**
|
||||
* Record management upload context
|
||||
* For user records and document uploads
|
||||
*/
|
||||
export const recordManagementContext: XSSUploadValidationContext = {
|
||||
uploadContext: "record",
|
||||
allowedMimeTypes: [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"image/jpeg",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"text/plain",
|
||||
"application/acad",
|
||||
"application/x-autocad",
|
||||
"application/x-dwg",
|
||||
"image/vnd.dwg",
|
||||
"application/dxf",
|
||||
"application/x-dxf",
|
||||
"image/vnd.dxf",
|
||||
"application/x-autocad-dwt",
|
||||
"application/x-autocad-dws",
|
||||
"application/x-esri-map",
|
||||
"application/x-esri-arcgis-pro-project",
|
||||
"application/x-rar-compressed",
|
||||
"application/vnd.rar",
|
||||
"application/zip",
|
||||
"application/octet-stream",
|
||||
"application/x-shapefile",
|
||||
"application/x-dbf",
|
||||
"application/x-cpg",
|
||||
"application/x-shx",
|
||||
"application/x-qmd",
|
||||
"application/vnd.shp",
|
||||
"application/vnd.dbf",
|
||||
"application/vnd.cpg",
|
||||
"application/vnd.shx",
|
||||
"application/vnd.qmd",
|
||||
],
|
||||
allowedExtensions: [
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".txt",
|
||||
".cpg",
|
||||
".dbf",
|
||||
".qmd",
|
||||
".shp",
|
||||
".shx",
|
||||
],
|
||||
maxSizeMB: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* External portal upload context
|
||||
* For external user document uploads
|
||||
*/
|
||||
export const externalPortalContext: XSSUploadValidationContext = {
|
||||
uploadContext: "external-portal",
|
||||
allowedMimeTypes: [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"application/acad",
|
||||
"application/x-autocad",
|
||||
"application/x-dwg",
|
||||
"image/vnd.dwg",
|
||||
"application/dxf",
|
||||
"application/x-dxf",
|
||||
"image/vnd.dxf",
|
||||
"application/x-autocad-dwt",
|
||||
"application/x-autocad-dws",
|
||||
"application/x-esri-map",
|
||||
"application/x-esri-arcgis-pro-project",
|
||||
"application/x-rar-compressed",
|
||||
"application/vnd.rar",
|
||||
"application/zip",
|
||||
"application/octet-stream",
|
||||
"application/x-shapefile",
|
||||
"application/x-dbf",
|
||||
"application/x-cpg",
|
||||
"application/x-shx",
|
||||
"application/x-qmd",
|
||||
"application/vnd.shp",
|
||||
"application/vnd.dbf",
|
||||
"application/vnd.cpg",
|
||||
"application/vnd.shx",
|
||||
"application/vnd.qmd",
|
||||
],
|
||||
allowedExtensions: [
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".dwg",
|
||||
".dxf",
|
||||
".dwt",
|
||||
".bak",
|
||||
".sv$",
|
||||
".dws",
|
||||
".mxd",
|
||||
".aprx",
|
||||
".rar",
|
||||
".zip",
|
||||
".cpg",
|
||||
".dbf",
|
||||
".qmd",
|
||||
".shp",
|
||||
".shx",
|
||||
],
|
||||
maxSizeMB: 25,
|
||||
};
|
||||
|
||||
/**
|
||||
* Incoming record upload context
|
||||
* For incoming record attachments
|
||||
*/
|
||||
export const incomingRecordContext: XSSUploadValidationContext = {
|
||||
uploadContext: "incoming",
|
||||
allowedMimeTypes: [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"text/plain",
|
||||
"application/acad",
|
||||
"application/x-autocad",
|
||||
"application/x-dwg",
|
||||
"image/vnd.dwg",
|
||||
"application/dxf",
|
||||
"application/x-dxf",
|
||||
"image/vnd.dxf",
|
||||
"application/x-autocad-dwt",
|
||||
"application/x-autocad-dws",
|
||||
"application/x-esri-map",
|
||||
"application/x-esri-arcgis-pro-project",
|
||||
"application/x-rar-compressed",
|
||||
"application/vnd.rar",
|
||||
"application/zip",
|
||||
"application/octet-stream",
|
||||
"application/x-shapefile",
|
||||
"application/x-dbf",
|
||||
"application/x-cpg",
|
||||
"application/x-shx",
|
||||
"application/x-qmd",
|
||||
"application/vnd.shp",
|
||||
"application/vnd.dbf",
|
||||
"application/vnd.cpg",
|
||||
"application/vnd.shx",
|
||||
"application/vnd.qmd",
|
||||
],
|
||||
allowedExtensions: [".pdf", ".doc", ".docx", ".jpg", ".jpeg", ".png", ".txt", ".cpg", ".dbf", ".qmd", ".shp", ".shx"],
|
||||
maxSizeMB: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* Outgoing record upload context
|
||||
* For outgoing record attachments
|
||||
*/
|
||||
export const outgoingRecordContext: XSSUploadValidationContext = {
|
||||
uploadContext: "outgoing",
|
||||
allowedMimeTypes: [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"text/plain",
|
||||
"application/acad",
|
||||
"application/x-autocad",
|
||||
"application/x-dwg",
|
||||
"image/vnd.dwg",
|
||||
"application/dxf",
|
||||
"application/x-dxf",
|
||||
"image/vnd.dxf",
|
||||
"application/x-autocad-dwt",
|
||||
"application/x-autocad-dws",
|
||||
"application/x-esri-map",
|
||||
"application/x-esri-arcgis-pro-project",
|
||||
"application/x-rar-compressed",
|
||||
"application/vnd.rar",
|
||||
"application/zip",
|
||||
"application/octet-stream",
|
||||
"application/x-shapefile",
|
||||
"application/x-dbf",
|
||||
"application/x-cpg",
|
||||
"application/x-shx",
|
||||
"application/x-qmd",
|
||||
"application/vnd.shp",
|
||||
"application/vnd.dbf",
|
||||
"application/vnd.cpg",
|
||||
"application/vnd.shx",
|
||||
"application/vnd.qmd",
|
||||
],
|
||||
allowedExtensions: [".pdf", ".doc", ".docx", ".jpg", ".jpeg", ".png", ".txt", ".cpg", ".dbf", ".qmd", ".shp", ".shx"],
|
||||
maxSizeMB: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* Signature and seal upload context
|
||||
* For digital signatures and official seals
|
||||
*/
|
||||
export const signatureContext: XSSUploadValidationContext = {
|
||||
uploadContext: "signature",
|
||||
allowedMimeTypes: ["image/jpeg", "image/png", "image/gif"],
|
||||
allowedExtensions: [".jpg", ".jpeg", ".png", ".gif"],
|
||||
maxSizeMB: 10,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get upload context by name
|
||||
* @param contextName - Name of the upload context
|
||||
* @returns Upload context configuration
|
||||
*/
|
||||
export function getUploadContext(
|
||||
contextName:
|
||||
| "record"
|
||||
| "external-portal"
|
||||
| "incoming"
|
||||
| "outgoing"
|
||||
| "signature",
|
||||
): XSSUploadValidationContext {
|
||||
const contexts: Record<string, XSSUploadValidationContext> = {
|
||||
record: recordManagementContext,
|
||||
"external-portal": externalPortalContext,
|
||||
incoming: incomingRecordContext,
|
||||
outgoing: outgoingRecordContext,
|
||||
signature: signatureContext,
|
||||
};
|
||||
|
||||
return contexts[contextName] || recordManagementContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secure default context
|
||||
* Used when no specific context is provided
|
||||
* Blocks dangerous file types by default
|
||||
*/
|
||||
export const secureDefaultContext: XSSUploadValidationContext = {
|
||||
uploadContext: "record",
|
||||
allowedMimeTypes: [
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"text/plain",
|
||||
],
|
||||
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png", ".txt"],
|
||||
maxSizeMB: 500,
|
||||
};
|
||||
import { XSSUploadValidationContext } from "./types";
|
||||
|
||||
/**
|
||||
* Upload context definitions for all entry points
|
||||
* Defines allowed MIME types, extensions, and size limits per context
|
||||
*/
|
||||
|
||||
/**
|
||||
* Record management upload context
|
||||
* For user records and document uploads
|
||||
*/
|
||||
export const recordManagementContext: XSSUploadValidationContext = {
|
||||
uploadContext: "record",
|
||||
allowedMimeTypes: [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"image/jpeg",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"text/plain",
|
||||
"application/acad",
|
||||
"application/x-autocad",
|
||||
"application/x-dwg",
|
||||
"image/vnd.dwg",
|
||||
"application/dxf",
|
||||
"application/x-dxf",
|
||||
"image/vnd.dxf",
|
||||
"application/x-autocad-dwt",
|
||||
"application/x-autocad-dws",
|
||||
"application/x-esri-map",
|
||||
"application/x-esri-arcgis-pro-project",
|
||||
"application/x-rar-compressed",
|
||||
"application/vnd.rar",
|
||||
"application/zip",
|
||||
"application/octet-stream",
|
||||
"application/x-shapefile",
|
||||
"application/x-dbf",
|
||||
"application/x-cpg",
|
||||
"application/x-shx",
|
||||
"application/x-qmd",
|
||||
"application/vnd.shp",
|
||||
"application/vnd.dbf",
|
||||
"application/vnd.cpg",
|
||||
"application/vnd.shx",
|
||||
"application/vnd.qmd",
|
||||
],
|
||||
allowedExtensions: [
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".txt",
|
||||
".cpg",
|
||||
".dbf",
|
||||
".qmd",
|
||||
".shp",
|
||||
".shx",
|
||||
],
|
||||
maxSizeMB: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* External portal upload context
|
||||
* For external user document uploads
|
||||
*/
|
||||
export const externalPortalContext: XSSUploadValidationContext = {
|
||||
uploadContext: "external-portal",
|
||||
allowedMimeTypes: [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"application/acad",
|
||||
"application/x-autocad",
|
||||
"application/x-dwg",
|
||||
"image/vnd.dwg",
|
||||
"application/dxf",
|
||||
"application/x-dxf",
|
||||
"image/vnd.dxf",
|
||||
"application/x-autocad-dwt",
|
||||
"application/x-autocad-dws",
|
||||
"application/x-esri-map",
|
||||
"application/x-esri-arcgis-pro-project",
|
||||
"application/x-rar-compressed",
|
||||
"application/vnd.rar",
|
||||
"application/zip",
|
||||
"application/octet-stream",
|
||||
"application/x-shapefile",
|
||||
"application/x-dbf",
|
||||
"application/x-cpg",
|
||||
"application/x-shx",
|
||||
"application/x-qmd",
|
||||
"application/vnd.shp",
|
||||
"application/vnd.dbf",
|
||||
"application/vnd.cpg",
|
||||
"application/vnd.shx",
|
||||
"application/vnd.qmd",
|
||||
],
|
||||
allowedExtensions: [
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".dwg",
|
||||
".dxf",
|
||||
".dwt",
|
||||
".bak",
|
||||
".sv$",
|
||||
".dws",
|
||||
".mxd",
|
||||
".aprx",
|
||||
".rar",
|
||||
".zip",
|
||||
".cpg",
|
||||
".dbf",
|
||||
".qmd",
|
||||
".shp",
|
||||
".shx",
|
||||
],
|
||||
maxSizeMB: 25,
|
||||
};
|
||||
|
||||
/**
|
||||
* Incoming record upload context
|
||||
* For incoming record attachments
|
||||
*/
|
||||
export const incomingRecordContext: XSSUploadValidationContext = {
|
||||
uploadContext: "incoming",
|
||||
allowedMimeTypes: [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"text/plain",
|
||||
"application/acad",
|
||||
"application/x-autocad",
|
||||
"application/x-dwg",
|
||||
"image/vnd.dwg",
|
||||
"application/dxf",
|
||||
"application/x-dxf",
|
||||
"image/vnd.dxf",
|
||||
"application/x-autocad-dwt",
|
||||
"application/x-autocad-dws",
|
||||
"application/x-esri-map",
|
||||
"application/x-esri-arcgis-pro-project",
|
||||
"application/x-rar-compressed",
|
||||
"application/vnd.rar",
|
||||
"application/zip",
|
||||
"application/octet-stream",
|
||||
"application/x-shapefile",
|
||||
"application/x-dbf",
|
||||
"application/x-cpg",
|
||||
"application/x-shx",
|
||||
"application/x-qmd",
|
||||
"application/vnd.shp",
|
||||
"application/vnd.dbf",
|
||||
"application/vnd.cpg",
|
||||
"application/vnd.shx",
|
||||
"application/vnd.qmd",
|
||||
],
|
||||
allowedExtensions: [".pdf", ".doc", ".docx", ".jpg", ".jpeg", ".png", ".txt", ".cpg", ".dbf", ".qmd", ".shp", ".shx"],
|
||||
maxSizeMB: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* Outgoing record upload context
|
||||
* For outgoing record attachments
|
||||
*/
|
||||
export const outgoingRecordContext: XSSUploadValidationContext = {
|
||||
uploadContext: "outgoing",
|
||||
allowedMimeTypes: [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"text/plain",
|
||||
"application/acad",
|
||||
"application/x-autocad",
|
||||
"application/x-dwg",
|
||||
"image/vnd.dwg",
|
||||
"application/dxf",
|
||||
"application/x-dxf",
|
||||
"image/vnd.dxf",
|
||||
"application/x-autocad-dwt",
|
||||
"application/x-autocad-dws",
|
||||
"application/x-esri-map",
|
||||
"application/x-esri-arcgis-pro-project",
|
||||
"application/x-rar-compressed",
|
||||
"application/vnd.rar",
|
||||
"application/zip",
|
||||
"application/octet-stream",
|
||||
"application/x-shapefile",
|
||||
"application/x-dbf",
|
||||
"application/x-cpg",
|
||||
"application/x-shx",
|
||||
"application/x-qmd",
|
||||
"application/vnd.shp",
|
||||
"application/vnd.dbf",
|
||||
"application/vnd.cpg",
|
||||
"application/vnd.shx",
|
||||
"application/vnd.qmd",
|
||||
],
|
||||
allowedExtensions: [".pdf", ".doc", ".docx", ".jpg", ".jpeg", ".png", ".txt", ".cpg", ".dbf", ".qmd", ".shp", ".shx"],
|
||||
maxSizeMB: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* Signature and seal upload context
|
||||
* For digital signatures and official seals
|
||||
*/
|
||||
export const signatureContext: XSSUploadValidationContext = {
|
||||
uploadContext: "signature",
|
||||
allowedMimeTypes: ["image/jpeg", "image/png", "image/gif"],
|
||||
allowedExtensions: [".jpg", ".jpeg", ".png", ".gif"],
|
||||
maxSizeMB: 10,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get upload context by name
|
||||
* @param contextName - Name of the upload context
|
||||
* @returns Upload context configuration
|
||||
*/
|
||||
export function getUploadContext(
|
||||
contextName:
|
||||
| "record"
|
||||
| "external-portal"
|
||||
| "incoming"
|
||||
| "outgoing"
|
||||
| "signature",
|
||||
): XSSUploadValidationContext {
|
||||
const contexts: Record<string, XSSUploadValidationContext> = {
|
||||
record: recordManagementContext,
|
||||
"external-portal": externalPortalContext,
|
||||
incoming: incomingRecordContext,
|
||||
outgoing: outgoingRecordContext,
|
||||
signature: signatureContext,
|
||||
};
|
||||
|
||||
return contexts[contextName] || recordManagementContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secure default context
|
||||
* Used when no specific context is provided
|
||||
* Blocks dangerous file types by default
|
||||
*/
|
||||
export const secureDefaultContext: XSSUploadValidationContext = {
|
||||
uploadContext: "record",
|
||||
allowedMimeTypes: [
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"text/plain",
|
||||
],
|
||||
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png", ".txt"],
|
||||
maxSizeMB: 500,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user