base
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
// api service
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
/**
|
||||
* Service class for handling file upload operations
|
||||
* Handles file uploads
|
||||
* @extends {APIService}
|
||||
*/
|
||||
export class FileUploadService extends APIService {
|
||||
private cancelSource: any;
|
||||
|
||||
constructor() {
|
||||
super("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to the specified signed URL
|
||||
* @param {string} url - The URL to upload the file to
|
||||
* @param {FormData} data - The form data to upload
|
||||
* @returns {Promise<void>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async uploadFile(url: string, data: FormData): Promise<void> {
|
||||
this.cancelSource = axios.CancelToken.source();
|
||||
return this.post(url, data, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
cancelToken: this.cancelSource.token,
|
||||
withCredentials: false,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
if (axios.isCancel(error)) {
|
||||
console.log(error.message);
|
||||
} else {
|
||||
throw error?.response?.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the upload
|
||||
*/
|
||||
cancelUpload() {
|
||||
this.cancelSource.cancel("Upload canceled");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
// api service
|
||||
import type { TDuplicateAssetData, TDuplicateAssetResponse } from "@plane/types";
|
||||
import { APIService } from "../api.service";
|
||||
// helpers
|
||||
import { getAssetIdFromUrl } from "./helper";
|
||||
|
||||
/**
|
||||
* Service class for managing file operations within plane applications.
|
||||
* Extends APIService to handle HTTP requests to the file-related endpoints.
|
||||
* @extends {APIService}
|
||||
*/
|
||||
export class FileService extends APIService {
|
||||
/**
|
||||
* Creates an instance of FileService
|
||||
* @param {string} BASE_URL - The base URL for API requests
|
||||
*/
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a new asset
|
||||
* @param {string} assetPath - The asset path
|
||||
* @returns {Promise<void>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async deleteNewAsset(assetPath: string): Promise<void> {
|
||||
return this.delete(assetPath)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an old editor asset
|
||||
* @param {string} workspaceId - The workspace identifier
|
||||
* @param {string} src - The asset source
|
||||
* @returns {Promise<any>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async deleteOldEditorAsset(workspaceId: string, src: string): Promise<any> {
|
||||
const assetKey = getAssetIdFromUrl(src);
|
||||
return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/`)
|
||||
.then((response) => response?.status)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores an old editor asset
|
||||
* @param {string} workspaceId - The workspace identifier
|
||||
* @param {string} src - The asset source
|
||||
* @returns {Promise<void>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async restoreOldEditorAsset(workspaceId: string, src: string): Promise<void> {
|
||||
const assetKey = getAssetIdFromUrl(src);
|
||||
return this.post(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/restore/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates assets
|
||||
* @param {string} workspaceSlug - The workspace slug
|
||||
* @param {TDuplicateAssetData} data - The data for the duplicate assets
|
||||
* @returns {Promise<TDuplicateAssetResponse>} Promise resolving to a record of asset IDs
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async duplicateAssets(workspaceSlug: string, data: TDuplicateAssetData): Promise<TDuplicateAssetResponse> {
|
||||
return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/duplicate-assets/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// external imports
|
||||
import { fileTypeFromBuffer } from "file-type";
|
||||
// plane imports
|
||||
import type { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
|
||||
import { DANGEROUS_EXTENSIONS } from "@plane/constants";
|
||||
|
||||
/**
|
||||
* @description Filename validation - checks for double extensions and dangerous patterns
|
||||
* @param {string} filename
|
||||
* @returns {string | null} Error message if invalid, null if valid
|
||||
*/
|
||||
const validateFilename = (filename: string): string | null => {
|
||||
if (!filename || filename.trim().length === 0) {
|
||||
return "Filename cannot be empty";
|
||||
}
|
||||
|
||||
// Check for dot files (e.g., .htaccess, .env)
|
||||
if (filename.startsWith(".")) {
|
||||
return "Hidden files (starting with dot) are not allowed";
|
||||
}
|
||||
|
||||
// Check for path separators
|
||||
if (filename.includes("/") || filename.includes("\\")) {
|
||||
return "Filename cannot contain path separators";
|
||||
}
|
||||
|
||||
const parts = filename.split(".");
|
||||
|
||||
// Check for double extensions with dangerous patterns
|
||||
if (parts.length >= 3) {
|
||||
const secondLastExt = parts[parts.length - 2]?.toLowerCase() || "";
|
||||
if (DANGEROUS_EXTENSIONS.includes(secondLastExt)) {
|
||||
return "File has suspicious double extension";
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the actual extension is dangerous
|
||||
const extension = parts[parts.length - 1]?.toLowerCase() || "";
|
||||
if (DANGEROUS_EXTENSIONS.includes(extension)) {
|
||||
return `File extension '${extension}' is not allowed`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description from the provided signed URL response, generate a payload to be used to upload the file
|
||||
* @param {TFileSignedURLResponse} signedURLResponse
|
||||
* @param {File} file
|
||||
* @returns {FormData} file upload request payload
|
||||
*/
|
||||
export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value));
|
||||
formData.append("file", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Detect MIME type from file signature using file-type library
|
||||
* @param {File} file
|
||||
* @returns {Promise<string>} detected MIME type or empty string if unknown
|
||||
*/
|
||||
const detectMimeTypeFromSignature = async (file: File): Promise<string> => {
|
||||
try {
|
||||
// Read first 4KB which is usually sufficient for most file type detection
|
||||
const chunk = file.slice(0, 4096);
|
||||
const buffer = await chunk.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(buffer);
|
||||
|
||||
const fileType = await fileTypeFromBuffer(uint8Array);
|
||||
return fileType?.mime || "";
|
||||
} catch (_error) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Validate and detect the MIME type of a file using signature detection
|
||||
* Also performs basic security checks on filename
|
||||
* @param {File} file
|
||||
* @returns {Promise<string>} validated and detected MIME type
|
||||
*/
|
||||
const validateAndDetectFileType = async (file: File): Promise<string> => {
|
||||
// Basic filename validation
|
||||
const filenameError = validateFilename(file.name);
|
||||
if (filenameError) {
|
||||
console.warn(`File validation warning: ${filenameError}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const signatureType = await detectMimeTypeFromSignature(file);
|
||||
if (signatureType) {
|
||||
return signatureType;
|
||||
}
|
||||
} catch (_error) {
|
||||
console.warn("Error detecting file type from signature:", _error);
|
||||
}
|
||||
|
||||
// fallback for unknown files
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* @description returns the necessary file meta data to upload a file
|
||||
* @param {File} file
|
||||
* @returns {Promise<TFileMetaDataLite>} payload with file info
|
||||
*/
|
||||
export const getFileMetaDataForUpload = async (file: File): Promise<TFileMetaDataLite> => {
|
||||
const fileType = await validateAndDetectFileType(file);
|
||||
return {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: fileType,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @description this function returns the assetId from the asset source
|
||||
* @param {string} src
|
||||
* @returns {string} assetId
|
||||
*/
|
||||
export const getAssetIdFromUrl = (src: string): string => {
|
||||
const sourcePaths = src.split("/");
|
||||
const assetUrl = sourcePaths[sourcePaths.length - 1];
|
||||
return assetUrl ?? "";
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./file-upload.service";
|
||||
export * from "./sites-file.service";
|
||||
export * from "./file.service";
|
||||
export * from "./helper";
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
// local services
|
||||
import type { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
|
||||
import { FileUploadService } from "./file-upload.service";
|
||||
// helpers
|
||||
import { FileService } from "./file.service";
|
||||
import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "./helper";
|
||||
|
||||
/**
|
||||
* Service class for managing file operations within plane sites application.
|
||||
* Extends FileService to manage file-related operations.
|
||||
* @extends {FileService}
|
||||
* @remarks This service is only available for plane sites
|
||||
*/
|
||||
export class SitesFileService extends FileService {
|
||||
private cancelSource: any;
|
||||
fileUploadService: FileUploadService;
|
||||
|
||||
/**
|
||||
* Creates an instance of SitesFileService
|
||||
* @param {string} BASE_URL - The base URL for API requests
|
||||
*/
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
this.cancelUpload = this.cancelUpload.bind(this);
|
||||
// services
|
||||
this.fileUploadService = new FileUploadService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the upload status of an asset
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} assetId - The asset identifier
|
||||
* @returns {Promise<void>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
private async updateAssetUploadStatus(anchor: string, assetId: string): Promise<void> {
|
||||
return this.patch(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the upload status of multiple assets
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} entityId - The entity identifier
|
||||
* @param {Object} data - The data payload
|
||||
* @returns {Promise<void>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async updateBulkAssetsUploadStatus(
|
||||
anchor: string,
|
||||
entityId: string,
|
||||
data: {
|
||||
asset_ids: string[];
|
||||
}
|
||||
): Promise<void> {
|
||||
return this.post(`/api/public/assets/v2/anchor/${anchor}/${entityId}/bulk/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to the specified anchor
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {TFileEntityInfo} data - The data payload
|
||||
* @param {File} file - The file to upload
|
||||
* @returns {Promise<TFileSignedURLResponse>} Promise resolving to the signed URL response
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async uploadAsset(anchor: string, data: TFileEntityInfo, file: File): Promise<TFileSignedURLResponse> {
|
||||
const fileMetaData = await getFileMetaDataForUpload(file);
|
||||
return this.post(`/api/public/assets/v2/anchor/${anchor}/`, {
|
||||
...data,
|
||||
...fileMetaData,
|
||||
})
|
||||
.then(async (response) => {
|
||||
const signedURLResponse: TFileSignedURLResponse = response?.data;
|
||||
const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
|
||||
await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload);
|
||||
await this.updateAssetUploadStatus(anchor, signedURLResponse.asset_id);
|
||||
return signedURLResponse;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a new asset
|
||||
* @param {string} workspaceSlug - The workspace slug
|
||||
* @param {string} src - The asset source
|
||||
* @returns {Promise<void>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async restoreNewAsset(anchor: string, src: string): Promise<void> {
|
||||
// remove the last slash and get the asset id
|
||||
const assetId = getAssetIdFromUrl(src);
|
||||
return this.post(`/api/public/assets/v2/anchor/${anchor}/restore/${assetId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the upload
|
||||
*/
|
||||
cancelUpload() {
|
||||
this.cancelSource.cancelUpload();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user