This commit is contained in:
natib21
2026-07-10 11:25:59 +00:00
parent 2696ca7498
commit e6e44e773b
1146 changed files with 200266 additions and 90527 deletions

View File

@@ -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
}

View File

@@ -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)
},
})

View File

@@ -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();
}
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}`);
};

View File

@@ -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,
};
}
},
};

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -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() }
);
}

View File

@@ -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;

View File

@@ -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(),
});
};

View File

@@ -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,
});
};

View File

@@ -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;

View File

@@ -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(),
});
};

View File

@@ -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;
});

View File

@@ -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;

View File

@@ -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)}`);
}

View File

@@ -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;
}

View File

@@ -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 },
});
},
};

View File

@@ -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();

View File

@@ -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();

View File

@@ -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');
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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');
}
}

View File

@@ -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();

View File

@@ -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 };
}
}

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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.';
}

View File

@@ -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';

View File

@@ -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';
}

View File

@@ -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,
};