From 0ade106fd7fe1018acbdb6b07baa3f1847e40372 Mon Sep 17 00:00:00 2001 From: "atharva.dev" Date: Thu, 13 Nov 2025 04:43:54 +0000 Subject: [PATCH] (PS-378) Seclore custom n8n node (#1) Reviewed-on: https://repo.seclore.com/public/n8n-nodes-seclore/pulls/1 --- .gitignore | 5 + .prettierrc.js | 51 ++ CHANGELOG.md | 0 CODE_OF_CONDUCT.md | 76 +++ CONTRIBUTING.md | 58 ++ LICENSE.md | 19 + README.md | 135 ++++ credentials/SecloreProtectApi.credentials.ts | 55 ++ eslint.config.mjs | 3 + icons/seclore.svg | 11 + nodes/SecloreProtect/SecloreProtect.node.json | 4 + nodes/SecloreProtect/SecloreProtect.node.ts | 172 +++++ .../Services/Interfaces/ErrorInterfaces.ts | 4 + .../Interfaces/FileStorageInterfaces.ts | 19 + .../Services/Interfaces/LoginInterfaces.ts | 14 + .../Services/Interfaces/ProtectInterfaces.ts | 40 ++ .../Interfaces/UnprotectInterfaces.ts | 8 + .../Services/SecloreDRMApiService.ts | 610 ++++++++++++++++++ .../Services/SecloreDRMFileService.ts | 388 +++++++++++ nodes/SecloreProtect/Services/Utils.ts | 60 ++ .../operations/protectWithHotFolder.ts | 314 +++++++++ nodes/SecloreProtect/operations/unprotect.ts | 298 +++++++++ package.json | 51 ++ tsconfig.json | 26 + 24 files changed, 2421 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierrc.js create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 credentials/SecloreProtectApi.credentials.ts create mode 100644 eslint.config.mjs create mode 100644 icons/seclore.svg create mode 100644 nodes/SecloreProtect/SecloreProtect.node.json create mode 100644 nodes/SecloreProtect/SecloreProtect.node.ts create mode 100644 nodes/SecloreProtect/Services/Interfaces/ErrorInterfaces.ts create mode 100644 nodes/SecloreProtect/Services/Interfaces/FileStorageInterfaces.ts create mode 100644 nodes/SecloreProtect/Services/Interfaces/LoginInterfaces.ts create mode 100644 nodes/SecloreProtect/Services/Interfaces/ProtectInterfaces.ts create mode 100644 nodes/SecloreProtect/Services/Interfaces/UnprotectInterfaces.ts create mode 100644 nodes/SecloreProtect/Services/SecloreDRMApiService.ts create mode 100644 nodes/SecloreProtect/Services/SecloreDRMFileService.ts create mode 100644 nodes/SecloreProtect/Services/Utils.ts create mode 100644 nodes/SecloreProtect/operations/protectWithHotFolder.ts create mode 100644 nodes/SecloreProtect/operations/unprotect.ts create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d79ed6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +dist +node_modules +package-lock.json +.n8n +.devcontainer \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..ebf28d8 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,51 @@ +module.exports = { + /** + * https://prettier.io/docs/en/options.html#semicolons + */ + semi: true, + + /** + * https://prettier.io/docs/en/options.html#trailing-commas + */ + trailingComma: 'all', + + /** + * https://prettier.io/docs/en/options.html#bracket-spacing + */ + bracketSpacing: true, + + /** + * https://prettier.io/docs/en/options.html#tabs + */ + useTabs: true, + + /** + * https://prettier.io/docs/en/options.html#tab-width + */ + tabWidth: 2, + + /** + * https://prettier.io/docs/en/options.html#arrow-function-parentheses + */ + arrowParens: 'always', + + /** + * https://prettier.io/docs/en/options.html#quotes + */ + singleQuote: true, + + /** + * https://prettier.io/docs/en/options.html#quote-props + */ + quoteProps: 'as-needed', + + /** + * https://prettier.io/docs/en/options.html#end-of-line + */ + endOfLine: 'lf', + + /** + * https://prettier.io/docs/en/options.html#print-width + */ + printWidth: 100, +}; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3b0de04 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at jan@n8n.io. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d3e8d39 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,58 @@ +# Contributing + +## Workflow + +A **feature branch workflow** is followed for this repository. All development work is done on ticket branches that are merged directly into the `main` branch. + +## Pre-requisite + +- Verify that you have local git setup as mentioned in **[this guide](https://repo.seclore.com/common/user_guides/src/branch/main/docs/coding_processes.md#setting-up-base-machine)** +- A JIRA ticket should be created for the work you plan to contribute + +## Contribution Process + +### Branch Structure + +- **`main`**: Production-ready code. This branch should always be stable and deployable. All ticket branches are created from and merged into this branch. + +### Branch Naming Convention + +Create branches using the following naming structure: + +- **Format**: `ticket/` + +Examples: +- `ticket/PS-378` +- `ticket/PS-379` + +### Step-by-Step Contribution Process + +1. **Create a branch from `main`** + ```bash + git checkout main + git pull origin main + git checkout -b ticket/ + ``` + +2. **Follow standard development process**: + - Effort Estimates + - Design Documentation (if required) + - Test Cases + - Code Implementation (following project coding standards) + +3. **Raise a Pull Request** + - Create a PR **from your ticket branch into the `main` branch** + - PR title format: `() ` + - Add relevant documentation links in the PR description + - Update the PR link in the JIRA ticket comments + +4. **Code Review** + - Address review comments + - Ensure all CI checks pass + - Get required approvals + +5. **Merge to Main** + - Upon PR approval, **squash and merge** the ticket branch into `main` + - Delete the ticket branch after successful merge + +--- \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..1e4b3a6 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,19 @@ +Copyright 2022 n8n + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 7e6e78e..d12132f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,137 @@ # n8n-nodes-seclore +This is an n8n community node. It lets you use Seclore DRM Server in your n8n workflows. + +[Seclore](https://www.seclore.com) is a leading data-centric security platform that provides enterprise-grade Digital Rights Management (DRM) capabilities to protect sensitive files and documents throughout their lifecycle. + +[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/reference/license/) workflow automation platform. + +[Installation](#installation) +[Operations](#operations) +[Credentials](#credentials) +[Compatibility](#compatibility) +[Usage](#usage) +[Resources](#resources) + +## Installation + +Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation. + +Use `n8n-nodes-seclore` as the package name. + +## Operations + +This node supports the following operations with Seclore DRM Server: + +### DRM Protection +#### Protect using Policy +- **Description**: Protects files using Seclore's HotFolder ID configuration and policy-based protection +- **Required Parameters**: + - **HotFolder ID**: The ID of the HotFolder configuration to use for protection + - **Input Binary Property**: Name of the binary property containing the file to protect (default: 'data') + - **Retry Count**: Number of retry attempts for failed requests (default: 3) +- **Output**: Protected file with DRM protection applied according to the specified policy + + +### DRM Unprotection +#### Unprotect +- **Description**: Removes DRM protection from Seclore-protected files +- **Required Parameters**: + - **Input Binary Property**: Name of the binary property containing the protected file (default: 'data') + - **Retry Count**: Number of retry attempts for failed requests (default: 3) +- **Output**: Unprotected original file content + + +## Credentials + +To use this node, you need to authenticate with your Seclore DRM Server. The following credentials are required: + +### Prerequisites +- Access to a Seclore DRM Server instance +- Valid tenant credentials (Tenant ID and Tenant Secret) +- Appropriate permissions for file protection/unprotection operations + +### Authentication Setup +1. **Base URL**: The base URL of your Seclore DRM Server (e.g., `https://api.seclore.com`) +2. **Tenant ID**: Your unique Seclore tenant identifier +3. **Tenant Secret**: Your tenant's secret key for API authentication + +### Configuration Steps +1. In n8n, go to **Credentials** → **Create New** +2. Search for "Seclore API" and select it +3. Fill in your Seclore DRM Server details: + - **Base URL**: Enter your Seclore DRM Server base URL + - **Tenant ID**: Provide your unique tenant identifier + - **Tenant Secret**: Enter your tenant's secret key (will be masked for security) +4. Test the connection to ensure credentials are valid (tests against `/seclore/drm/1.0/auth/login` endpoint) +5. Save the credentials for use in your workflows + +## Compatibility + +- **Minimum n8n version**: 1.0.0 +- **Tested with n8n versions**: 1.0.0+ +- **Node.js version**: 18.x or higher +- **Seclore API version**: Compatible with Seclore DRM API v1.0 + +## Usage + +### Basic Workflow Examples + +#### Protecting Files with Policy +1. Use a trigger node to receive files (HTTP Request, File Trigger, etc.) +2. Add the **Seclore** node to your workflow +3. Configure the node: + - **Resource**: Select "DRM Protection" + - **Operation**: Select "Protect using Policy" + - **HotFolder ID**: Enter your HotFolder configuration ID + - **Input Binary Property**: Specify the binary property name (default: 'data') + - **Retry Count**: Set retry attempts (default: 3) +4. Configure your Seclore API credentials +5. The node will return the protected file with DRM encryption applied according to your policy + +#### Unprotecting Files +1. Receive protected Seclore files in your workflow +2. Add the **Seclore** node to your workflow +3. Configure the node: + - **Resource**: Select "DRM Unprotection" + - **Operation**: Select "Unprotect" + - **Input Binary Property**: Specify the binary property containing the protected file (default: 'data') + - **Retry Count**: Set retry attempts (default: 3) +4. Configure your Seclore API credentials +5. The node will return the original unprotected file content + +### Important Notes +- Ensure your Seclore DRM Server is accessible from your n8n instance +- File operations may take time depending on file size and server performance +- Always handle errors appropriately in your workflows +- Protected files can only be unprotected by authorized users with proper credentials +- HotFolder ID must be configured in your Seclore Policy Server before using protection operations +- The node supports binary data processing for both input and output files +- Retry mechanism is built-in for handling temporary network or server issues + +### Error Handling +The node includes comprehensive error handling and logging: +- Connection failures to Seclore server +- Authentication errors +- File processing errors +- Automatic cleanup of temporary files + +## Resources + +* [n8n community nodes documentation](https://docs.n8n.io/integrations/#community-nodes) +* [Seclore Official Documentation](https://docs.seclore.com/) +* [Seclore Support](https://support.seclore.com/) + +## License + +This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. + +## Support + +For issues related to this n8n node: +- Create an issue in this repository +- Contact: support@seclore.com + +For Seclore platform support: +- Visit [Seclore Support Portal](https://support.seclore.com/) +- Check [Seclore Documentation](https://docs.seclore.com/) diff --git a/credentials/SecloreProtectApi.credentials.ts b/credentials/SecloreProtectApi.credentials.ts new file mode 100644 index 0000000..fcce8cb --- /dev/null +++ b/credentials/SecloreProtectApi.credentials.ts @@ -0,0 +1,55 @@ +import { ICredentialTestRequest, ICredentialType, INodeProperties, Icon } from 'n8n-workflow'; + +export class SecloreProtectApi implements ICredentialType { + name = 'secloreProtectApi'; + displayName = 'Seclore API'; + documentationUrl = 'https://docs.seclore.com/'; + icon: Icon = 'file:../icons/seclore.svg'; + properties: INodeProperties[] = [ + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'string', + default: '', + placeholder: 'https://api.seclore.com', + description: 'The base URL of your Seclore DRM Server', + required: true, + }, + { + displayName: 'Tenant ID', + name: 'tenantId', + type: 'string', + default: '', + placeholder: 'your-tenant-id', + description: 'Your Seclore tenant ID', + required: true, + }, + { + displayName: 'Tenant Secret', + name: 'tenantSecret', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + description: 'Your Seclore tenant secret', + required: true, + }, + ]; + + // Optional: Add credential test + test: ICredentialTestRequest = { + request: { + baseURL: '={{$credentials.baseUrl}}', + url: '/seclore/drm/1.0/auth/login', + method: 'POST', + body: { + tenantId: '={{$credentials.tenantId}}', + tenantSecret: '={{$credentials.tenantSecret}}', + }, + headers: { + 'Content-Type': 'application/json', + }, + }, + }; +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..ad811a0 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@n8n/node-cli/eslint'; + +export default config; diff --git a/icons/seclore.svg b/icons/seclore.svg new file mode 100644 index 0000000..58541ba --- /dev/null +++ b/icons/seclore.svg @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/nodes/SecloreProtect/SecloreProtect.node.json b/nodes/SecloreProtect/SecloreProtect.node.json new file mode 100644 index 0000000..b88e26f --- /dev/null +++ b/nodes/SecloreProtect/SecloreProtect.node.json @@ -0,0 +1,4 @@ +{ + "node": "dist/nodes/SecloreProtect/SecloreProtect.node.js", + "credentials": "dist/credentials/SecloreProtectApi.credentials.js" +} diff --git a/nodes/SecloreProtect/SecloreProtect.node.ts b/nodes/SecloreProtect/SecloreProtect.node.ts new file mode 100644 index 0000000..d8e3672 --- /dev/null +++ b/nodes/SecloreProtect/SecloreProtect.node.ts @@ -0,0 +1,172 @@ +import { + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { protectWithHotFolder } from './operations/protectWithHotFolder'; +import { unprotect } from './operations/unprotect'; + +export class SecloreProtect implements INodeType { + description: INodeTypeDescription = { + displayName: 'Seclore', + name: 'secloreProtect', + icon: 'file:../../icons/seclore.svg', + usableAsTool: true, + group: ['transform'], + version: 1, + subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}', + description: 'Protect files using Seclore DRM', + defaults: { + name: 'Seclore', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'secloreProtectApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'DRM Protection', + value: 'drmProtection', + description: 'DRM file protection operations', + }, + { + name: 'DRM Unprotection', + value: 'drmUnprotection', + description: 'DRM file unprotection operations', + }, + ], + default: 'drmProtection', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['drmProtection'], + }, + }, + options: [ + { + name: 'Protect Using Policy', + value: 'protectWithHotFolder', + description: 'Protect a file using HotFolder ID configuration', + action: 'Protect file using policy', + }, + ], + default: 'protectWithHotFolder', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['drmUnprotection'], + }, + }, + options: [ + { + name: 'Unprotect', + value: 'unprotect', + description: 'Unprotect a protected file', + action: 'Unprotect file', + }, + ], + default: 'unprotect', + }, + { + displayName: 'HotFolder ID', + name: 'hotfolderId', + type: 'string', + required: true, + default: '', + placeholder: '', + description: 'The ID of the HotFolder configuration to use for protection', + displayOptions: { + show: { + resource: ['drmProtection'], + operation: ['protectWithHotFolder'], + }, + }, + }, + { + displayName: 'Input Binary Property', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + description: 'Name of the binary property that contains the file to protect', + displayOptions: { + show: { + resource: ['drmProtection'], + operation: ['protectWithHotFolder'], + }, + }, + }, + { + displayName: 'Retry Count', + name: 'retryCount', + type: 'number', + default: 3, + description: 'Number of retry attempts for failed requests', + displayOptions: { + show: { + resource: ['drmProtection'], + operation: ['protectWithHotFolder'], + }, + }, + }, + // Unprotect operation parameters + { + displayName: 'Input Binary Property', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + description: 'Name of the binary property that contains the protected file to unprotect', + displayOptions: { + show: { + resource: ['drmUnprotection'], + operation: ['unprotect'], + }, + }, + }, + { + displayName: 'Retry Count', + name: 'retryCount', + type: 'number', + default: 3, + description: 'Number of retry attempts for failed requests', + displayOptions: { + show: { + resource: ['drmUnprotection'], + operation: ['unprotect'], + }, + }, + }, + ], + }; + + customOperations = { + drmProtection: { + protectWithHotFolder, + }, + drmUnprotection: { + unprotect, + }, + }; + +} diff --git a/nodes/SecloreProtect/Services/Interfaces/ErrorInterfaces.ts b/nodes/SecloreProtect/Services/Interfaces/ErrorInterfaces.ts new file mode 100644 index 0000000..11200d2 --- /dev/null +++ b/nodes/SecloreProtect/Services/Interfaces/ErrorInterfaces.ts @@ -0,0 +1,4 @@ +export interface IErrorResponse { + errorCode: string; + errorMessage: string; +} \ No newline at end of file diff --git a/nodes/SecloreProtect/Services/Interfaces/FileStorageInterfaces.ts b/nodes/SecloreProtect/Services/Interfaces/FileStorageInterfaces.ts new file mode 100644 index 0000000..e82dee9 --- /dev/null +++ b/nodes/SecloreProtect/Services/Interfaces/FileStorageInterfaces.ts @@ -0,0 +1,19 @@ +export interface IFileUploadResponse { + fileStorageId: string; + fileName: string; + downloadUrl: string; + fileType: string; + fileSize: number; + secloreFileId: string; + protected: boolean; + headers?: { [key: string]: string }; +} + +export interface IFileDownloadResponse { + data: Uint8Array; + headers?: { [key: string]: string }; +} + +export interface IFileDeleteResponse { + headers?: { [key: string]: string }; +} diff --git a/nodes/SecloreProtect/Services/Interfaces/LoginInterfaces.ts b/nodes/SecloreProtect/Services/Interfaces/LoginInterfaces.ts new file mode 100644 index 0000000..ad54ca9 --- /dev/null +++ b/nodes/SecloreProtect/Services/Interfaces/LoginInterfaces.ts @@ -0,0 +1,14 @@ +export interface ILoginRequest { + tenantId: string; + tenantSecret: string; +} + +export interface ILoginResponse { + accessToken: string; + refreshToken: string; + headers?: { [key: string]: string }; +} + +export interface IRefreshTokenRequest { + refreshToken: string; +} diff --git a/nodes/SecloreProtect/Services/Interfaces/ProtectInterfaces.ts b/nodes/SecloreProtect/Services/Interfaces/ProtectInterfaces.ts new file mode 100644 index 0000000..e9da0b3 --- /dev/null +++ b/nodes/SecloreProtect/Services/Interfaces/ProtectInterfaces.ts @@ -0,0 +1,40 @@ +export interface IExtRefProtectionDetail { + externalReferenceId: string; + externalReferenceName?: string; + externalReferenceData?: string; + externalAppId?: string; +} + +export interface IProtectWithExternalRefIdRequest { + hotfolderExternalReference: IExtRefProtectionDetail; + fileExternalReference?: IExtRefProtectionDetail; + fileStorageId: string; +} + +export interface IProtectWithExternalRefIdResponse { + fileStorageId: string; + secloreFileId: string; + headers?: { [key: string]: string }; +} + +export interface IProtectWithFileIdRequest { + existingProtectedFileId: string; + fileStorageId: string; +} + +export interface IProtectWithFileIdResponse { + fileStorageId: string; + secloreFileId: string; + headers?: { [key: string]: string }; +} + +export interface IProtectWithHotFolderRequest { + hotfolderId: string; + fileStorageId: string; +} + +export interface IProtectWithHotFolderResponse { + fileStorageId: string; + secloreFileId: string; + headers?: { [key: string]: string }; +} diff --git a/nodes/SecloreProtect/Services/Interfaces/UnprotectInterfaces.ts b/nodes/SecloreProtect/Services/Interfaces/UnprotectInterfaces.ts new file mode 100644 index 0000000..11a4b01 --- /dev/null +++ b/nodes/SecloreProtect/Services/Interfaces/UnprotectInterfaces.ts @@ -0,0 +1,8 @@ +export interface IUnprotectRequest { + fileStorageId: string; +} + +export interface IUnprotectResponse { + fileStorageId: string; + headers?: { [key: string]: string }; +} diff --git a/nodes/SecloreProtect/Services/SecloreDRMApiService.ts b/nodes/SecloreProtect/Services/SecloreDRMApiService.ts new file mode 100644 index 0000000..e0cf464 --- /dev/null +++ b/nodes/SecloreProtect/Services/SecloreDRMApiService.ts @@ -0,0 +1,610 @@ +import { IExecuteFunctions, IHttpRequestOptions, NodeApiError, LoggerProxy as Logger } from 'n8n-workflow'; +import { IErrorResponse } from './Interfaces/ErrorInterfaces'; +import { IFileUploadResponse, IFileDownloadResponse, IFileDeleteResponse } from './Interfaces/FileStorageInterfaces'; +import { ILoginRequest, ILoginResponse, IRefreshTokenRequest } from './Interfaces/LoginInterfaces'; +import { + IProtectWithExternalRefIdRequest, + IProtectWithExternalRefIdResponse, + IProtectWithFileIdRequest, + IProtectWithFileIdResponse, + IProtectWithHotFolderRequest, + IProtectWithHotFolderResponse, +} from './Interfaces/ProtectInterfaces'; +import { IUnprotectRequest, IUnprotectResponse } from './Interfaces/UnprotectInterfaces'; + +export class SecloreDRMApiService { + constructor( + private context: IExecuteFunctions, + private baseUrl: string, + ) { } + + /** + * Common error handler for HTTP responses + * @param error - The error object from httpRequest + * @param customMessages - Optional custom error messages for specific status codes + */ + private handleHttpError( + error: NodeApiError, + customMessages?: { [statusCode: number]: string }, + ): never { + const statusCode: number = parseInt(error.httpCode ?? '0'); + const errorResponse = error.errorResponse as unknown as IErrorResponse; + + if (customMessages && customMessages[statusCode]) { + throw new Error( + `${customMessages[statusCode]}: ${errorResponse?.errorMessage || 'Unknown error'}`, + ); + } + + // Default error handling + switch (statusCode) { + case 400: + throw new Error(`Bad Request: ${errorResponse?.errorMessage || 'Invalid request data'}`); + case 401: + throw new Error(`Unauthorized: ${errorResponse?.errorMessage || 'Invalid credentials'}`); + case 413: + throw new Error( + `Payload Too Large: ${errorResponse?.errorMessage || 'File size exceeds limit'}`, + ); + case 500: + throw new Error(`Server Error: ${errorResponse?.errorMessage || 'Internal server error'}`); + default: + throw error; + } + } + + /** + * Login Endpoint to generate Access Token and Refresh Token for JWT Authorization. + * Upon successful login, all the existing previous tokens for that tenant will be invalidated. + * + * @param tenantId - The tenant ID + * @param tenantSecret - The tenant secret + * @param correlationId - Optional request ID for logging purpose + * @returns Promise - Access token and refresh token + * @throws Error on authentication failure or server error + */ + async login( + tenantId: string, + tenantSecret: string, + correlationId?: string, + ): Promise { + const who = "SecloreDRMApiService::login:: "; + try { + Logger.debug(who + 'Attempting login', { tenantId, correlationId }); + + const requestBody: ILoginRequest = { + tenantId, + tenantSecret, + }; + + const headers: { [key: string]: string } = { + 'Content-Type': 'application/json', + }; + + // Add correlation ID if provided + if (correlationId) { + headers['X-SECLORE-CORRELATION-ID'] = correlationId; + } + + const options: IHttpRequestOptions = { + method: 'POST', + url: `${this.baseUrl}/seclore/drm/1.0/auth/login`, + headers, + body: requestBody, + json: true, + }; + + Logger.debug(who + 'Making HTTP request', { url: options.url, method: options.method, correlationId }); + const response = await this.context.helpers.httpRequest({ ...options, returnFullResponse: true }); + Logger.debug(who + 'Login successful', { tenantId, correlationId }); + + const loginResponse: ILoginResponse = { + ...(response.body as ILoginResponse), + headers: response.headers as { [key: string]: string } + }; + + return loginResponse; + } catch (error: unknown) { + Logger.error(who + 'Login failed', { error, tenantId, correlationId }); + this.handleHttpError(error as NodeApiError); + } + } + + /** + * Endpoint for generating new Access Token and Refresh Token using an existing valid Refresh Token. + * Upon successful response, all the previous existing Access Tokens and Refresh Tokens of that tenant will be invalidated. + * + * @param refreshToken - The existing valid refresh token + * @param correlationId - Optional request ID for logging purpose + * @returns Promise - New access token and refresh token + * @throws Error on authentication failure or server error + */ + async refreshToken(refreshToken: string, correlationId?: string): Promise { + const who = "SecloreDRMApiService::refreshToken:: "; + try { + Logger.debug(who + 'Attempting token refresh', { correlationId }); + + const requestBody: IRefreshTokenRequest = { + refreshToken, + }; + + const headers: { [key: string]: string } = { + 'Content-Type': 'application/json', + }; + + // Add correlation ID if provided + if (correlationId) { + headers['X-SECLORE-CORRELATION-ID'] = correlationId; + } + + const options: IHttpRequestOptions = { + method: 'POST', + url: `${this.baseUrl}/seclore/drm/1.0/auth/refresh`, + headers, + body: requestBody, + json: true, + }; + + Logger.debug(who + 'Making HTTP request', { url: options.url, method: options.method, correlationId }); + const response = await this.context.helpers.httpRequest({ ...options, returnFullResponse: true }); + Logger.debug(who + 'Token refresh successful', { correlationId }); + + const refreshResponse: ILoginResponse = { + ...(response.body as ILoginResponse), + headers: response.headers as { [key: string]: string } + }; + + return refreshResponse; + } catch (error: unknown) { + Logger.error(who + 'Token refresh failed', { error, correlationId }); + this.handleHttpError(error as NodeApiError, { 401: 'Unauthorized' }); + } + } + + /** + * Protect file using external identifier of protected File and HotFolder with PS configured against the logged in Tenant in application. + * + * @param protectRequest - The protection request details + * @param accessToken - JWT access token for authorization + * @param correlationId - Optional request ID for logging purpose + * @returns Promise - File storage ID and Seclore file ID + * @throws Error on bad request, authentication failure or server error + */ + async protectWithExternalRefId( + protectRequest: IProtectWithExternalRefIdRequest, + accessToken: string, + correlationId?: string, + ): Promise { + const who = "SecloreDRMApiService::protectWithExternalRefId:: "; + try { + Logger.debug(who + 'Protecting file with external ref ID', { + fileStorageId: protectRequest.fileStorageId, + hotfolderExternalReferenceId: protectRequest.hotfolderExternalReference.externalReferenceId, + correlationId + }); + + const headers: { [key: string]: string } = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }; + + // Add correlation ID if provided + if (correlationId) { + headers['X-SECLORE-CORRELATION-ID'] = correlationId; + } + + const options: IHttpRequestOptions = { + method: 'POST', + url: `${this.baseUrl}/seclore/drm/1.0/protect/externalref`, + headers, + body: protectRequest, + json: true, + }; + + Logger.debug(who + 'Making HTTP request', { url: options.url, method: options.method, correlationId }); + const response = await this.context.helpers.httpRequest({ ...options, returnFullResponse: true }); + Logger.debug(who + 'Protection with external ref ID successful', { + fileStorageId: protectRequest.fileStorageId, + secloreFileId: (response.body as IProtectWithExternalRefIdResponse).secloreFileId, + correlationId + }); + + const protectResponse: IProtectWithExternalRefIdResponse = { + ...(response.body as IProtectWithExternalRefIdResponse), + headers: response.headers as { [key: string]: string } + }; + + return protectResponse; + } catch (error: unknown) { + Logger.error(who + 'Protection with external ref ID failed', { + error, + fileStorageId: protectRequest.fileStorageId, + correlationId + }); + this.handleHttpError(error as NodeApiError); + } + } + + /** + * Protects file using File ID of already protected file with PS configured against the logged in Tenant in application. + * + * @param protectRequest - The protection request details with existing protected file ID + * @param accessToken - JWT access token for authorization + * @param correlationId - Optional request ID for logging purpose + * @returns Promise - File storage ID and Seclore file ID + * @throws Error on bad request, authentication failure or server error + */ + async protectWithFileId( + protectRequest: IProtectWithFileIdRequest, + accessToken: string, + correlationId?: string, + ): Promise { + const who = "SecloreDRMApiService::protectWithFileId:: "; + try { + Logger.debug(who + 'Protecting file with file ID', { + existingProtectedFileId: protectRequest.existingProtectedFileId, + fileStorageId: protectRequest.fileStorageId, + correlationId + }); + + const headers: { [key: string]: string } = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }; + + // Add correlation ID if provided + if (correlationId) { + headers['X-SECLORE-CORRELATION-ID'] = correlationId; + } + + const options: IHttpRequestOptions = { + method: 'POST', + url: `${this.baseUrl}/seclore/drm/1.0/protect/fileid`, + headers, + body: protectRequest, + json: true, + }; + + Logger.debug(who + 'Making HTTP request', { url: options.url, method: options.method, correlationId }); + const response = await this.context.helpers.httpRequest({ ...options, returnFullResponse: true }); + Logger.debug(who + 'Protection with file ID successful', { + existingProtectedFileId: protectRequest.existingProtectedFileId, + secloreFileId: (response.body as IProtectWithFileIdResponse).secloreFileId, + correlationId + }); + + const protectResponse: IProtectWithFileIdResponse = { + ...(response.body as IProtectWithFileIdResponse), + headers: response.headers as { [key: string]: string } + }; + + return protectResponse; + } catch (error: unknown) { + Logger.error(who + 'Protection with file ID failed', { + error, + existingProtectedFileId: protectRequest.existingProtectedFileId, + correlationId + }); + this.handleHttpError(error as NodeApiError); + } + } + + /** + * Protects file using HotFolder ID with PS configured against the logged in Tenant in application. + * + * @param protectRequest - The protection request details with hotfolder ID + * @param accessToken - JWT access token for authorization + * @param correlationId - Optional request ID for logging purpose + * @returns Promise - File storage ID and Seclore file ID + * @throws Error on bad request, authentication failure or server error + */ + async protectWithHotFolder( + protectRequest: IProtectWithHotFolderRequest, + accessToken: string, + correlationId?: string, + ): Promise { + const who = "SecloreDRMApiService::protectWithHotFolder:: "; + try { + Logger.debug(who + 'Protecting file with hot folder', { + hotfolderId: protectRequest.hotfolderId, + fileStorageId: protectRequest.fileStorageId, + correlationId + }); + + const headers: { [key: string]: string } = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }; + + // Add correlation ID if provided + if (correlationId) { + headers['X-SECLORE-CORRELATION-ID'] = correlationId; + } + + const options: IHttpRequestOptions = { + method: 'POST', + url: `${this.baseUrl}/seclore/drm/1.0/protect/hf`, + headers, + body: protectRequest, + json: true, + }; + + Logger.debug(who + 'Making HTTP request', { url: options.url, method: options.method, correlationId }); + const response = await this.context.helpers.httpRequest({ ...options, returnFullResponse: true }); + Logger.debug(who + 'Protection with hot folder successful', { + hotfolderId: protectRequest.hotfolderId, + secloreFileId: (response.body as IProtectWithHotFolderResponse).secloreFileId, + correlationId + }); + + const protectResponse: IProtectWithHotFolderResponse = { + ...(response.body as IProtectWithHotFolderResponse), + headers: response.headers as { [key: string]: string } + }; + + return protectResponse; + } catch (error: unknown) { + Logger.error(who + 'Protection with hot folder failed', { + error, + hotfolderId: protectRequest.hotfolderId, + fileStorageId: protectRequest.fileStorageId, + correlationId + }); + this.handleHttpError(error as NodeApiError); + } + } + + /** + * Unprotects file with PS configured against the logged in Tenant in application. + * + * @param unprotectRequest - The unprotect request details with file storage ID + * @param accessToken - JWT access token for authorization + * @param correlationId - Optional request ID for logging purpose + * @returns Promise - File storage ID of unprotected file + * @throws Error on bad request, authentication failure or server error + */ + async unprotect( + unprotectRequest: IUnprotectRequest, + accessToken: string, + correlationId?: string, + ): Promise { + const who = "SecloreDRMApiService::unprotect:: "; + try { + Logger.debug(who + 'Unprotecting file', { + fileStorageId: unprotectRequest.fileStorageId, + correlationId + }); + + const headers: { [key: string]: string } = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }; + + // Add correlation ID if provided + if (correlationId) { + headers['X-SECLORE-CORRELATION-ID'] = correlationId; + } + + const options: IHttpRequestOptions = { + method: 'POST', + url: `${this.baseUrl}/seclore/drm/1.0/unprotect`, + headers, + body: unprotectRequest, + json: true, + }; + + Logger.debug(who + 'Making HTTP request', { url: options.url, method: options.method, correlationId }); + const response = await this.context.helpers.httpRequest({ ...options, returnFullResponse: true }); + Logger.debug(who + 'Unprotection successful', { + originalFileStorageId: unprotectRequest.fileStorageId, + unprotectedFileStorageId: (response.body as IUnprotectResponse).fileStorageId, + correlationId + }); + + const unprotectResponse: IUnprotectResponse = { + ...(response.body as IUnprotectResponse), + headers: response.headers as { [key: string]: string } + }; + + return unprotectResponse; + } catch (error: unknown) { + Logger.error(who + 'Unprotection failed', { + error, + fileStorageId: unprotectRequest.fileStorageId, + correlationId + }); + this.handleHttpError(error as NodeApiError); + } + } + + /** + * Adds a new file to the file storage for currently logged in Tenant. + * + * @param fileBuffer - The file buffer data + * @param fileName - The name of the file + * @param accessToken - JWT access token for authorization + * @param correlationId - Optional request ID for logging purpose + * @returns Promise - File storage details including file ID and metadata + * @throws Error on authentication failure, payload too large, or server error + */ + async uploadFile( + fileBuffer: Uint8Array, + fileName: string, + accessToken: string, + correlationId?: string, + ): Promise { + const who = "SecloreDRMApiService::uploadFile:: "; + try { + Logger.debug(who + 'Uploading file', { + fileName, + fileSize: fileBuffer.length, + correlationId + }); + + const headers: { [key: string]: string } = { + Authorization: `Bearer ${accessToken}`, + }; + + // Add correlation ID if provided + if (correlationId) { + headers['X-SECLORE-CORRELATION-ID'] = correlationId; + } + + // Create FormData for multipart/form-data upload + const formData = new FormData(); + const file = new Blob([fileBuffer], { type: 'application/octet-stream' }); + formData.append('file', file, fileName); + + const options: IHttpRequestOptions = { + method: 'POST', + url: `${this.baseUrl}/seclore/drm/filestorage/1.0/upload`, + headers, + body: formData, + }; + + Logger.debug(who + 'Making HTTP request', { url: options.url, method: options.method, fileName, correlationId }); + const response = await this.context.helpers.httpRequest({ ...options, returnFullResponse: true }); + Logger.debug(who + 'File upload successful', { + fileName, + fileStorageId: (response.body as IFileUploadResponse).fileStorageId, + correlationId + }); + + const uploadResponse: IFileUploadResponse = { + ...(response.body as IFileUploadResponse), + headers: response.headers as { [key: string]: string } + }; + + return uploadResponse; + } catch (error: unknown) { + Logger.error(who + 'File upload failed', { + error, + fileName, + fileSize: fileBuffer.length, + correlationId + }); + this.handleHttpError(error as NodeApiError); + } + } + + /** + * Downloads file with fileStorageId from file storage of currently logged in Tenant. + * NOTE: Files whose fileStorageId has 'DL_' prefix will be deleted from the file storage after download. + * + * @param fileStorageId - Storage ID of the file to be retrieved + * @param accessToken - JWT access token for authorization + * @param correlationId - Optional request ID for logging purpose + * @returns Promise - The downloaded file data with headers + * @throws Error on authentication failure or server error + */ + async downloadFile( + fileStorageId: string, + accessToken: string, + correlationId?: string, + ): Promise { + const who = "SecloreDRMApiService::downloadFile:: "; + try { + Logger.debug(who + 'Downloading file', { + fileStorageId, + correlationId + }); + + const headers: { [key: string]: string } = { + Authorization: `Bearer ${accessToken}`, + }; + + // Add correlation ID if provided + if (correlationId) { + headers['X-SECLORE-CORRELATION-ID'] = correlationId; + } + + const options: IHttpRequestOptions = { + method: 'GET', + url: `${this.baseUrl}/seclore/drm/filestorage/1.0/download/${fileStorageId}`, + headers, + encoding: 'arraybuffer', + }; + + Logger.debug(who + 'Making HTTP request', { url: options.url, method: options.method, fileStorageId, correlationId }); + const response = await this.context.helpers.httpRequest({ ...options, returnFullResponse: true }); + const fileData = new Uint8Array(response.body as ArrayBuffer); + Logger.debug(who + 'File download successful', { + fileStorageId, + fileSize: fileData.length, + correlationId + }); + + const downloadResponse: IFileDownloadResponse = { + data: fileData, + headers: response.headers as { [key: string]: string } + }; + + return downloadResponse; + } catch (error: unknown) { + Logger.error(who + 'File download failed', { + error, + fileStorageId, + correlationId + }); + this.handleHttpError(error as NodeApiError); + } + } + + /** + * Deletes a file with fileStorageId from file storage of currently logged in Tenant. + * + * @param fileStorageId - Storage ID of the file to be deleted + * @param accessToken - JWT access token for authorization + * @param correlationId - Optional request ID for logging purpose + * @returns Promise - Response headers on successful deletion + * @throws Error on authentication failure or server error + */ + async deleteFile( + fileStorageId: string, + accessToken: string, + correlationId?: string, + ): Promise { + const who = "SecloreDRMApiService::deleteFile:: "; + try { + Logger.debug(who + 'Deleting file', { + fileStorageId, + correlationId + }); + + const headers: { [key: string]: string } = { + Authorization: `Bearer ${accessToken}`, + }; + + // Add correlation ID if provided + if (correlationId) { + headers['X-SECLORE-CORRELATION-ID'] = correlationId; + } + + const options: IHttpRequestOptions = { + method: 'DELETE', + url: `${this.baseUrl}/seclore/drm/filestorage/1.0/${fileStorageId}`, + headers, + }; + + Logger.debug(who + 'Making HTTP request', { url: options.url, method: options.method, fileStorageId, correlationId }); + const response = await this.context.helpers.httpRequest({ ...options, returnFullResponse: true }); + Logger.debug(who + 'File deletion successful', { + fileStorageId, + correlationId + }); + + const deleteResponse: IFileDeleteResponse = { + headers: response.headers as { [key: string]: string } + }; + + return deleteResponse; + } catch (error: unknown) { + Logger.error(who + 'File deletion failed', { + error, + fileStorageId, + correlationId + }); + this.handleHttpError(error as NodeApiError); + } + } +} diff --git a/nodes/SecloreProtect/Services/SecloreDRMFileService.ts b/nodes/SecloreProtect/Services/SecloreDRMFileService.ts new file mode 100644 index 0000000..f5b62dc --- /dev/null +++ b/nodes/SecloreProtect/Services/SecloreDRMFileService.ts @@ -0,0 +1,388 @@ +import { IExecuteFunctions, LoggerProxy as Logger } from 'n8n-workflow'; +import { IFileDownloadResponse, IFileUploadResponse } from './Interfaces/FileStorageInterfaces'; +import { + IProtectWithExternalRefIdRequest, + IProtectWithExternalRefIdResponse, + IProtectWithFileIdRequest, + IProtectWithFileIdResponse, + IProtectWithHotFolderRequest, + IProtectWithHotFolderResponse, +} from './Interfaces/ProtectInterfaces'; +import { IUnprotectRequest, IUnprotectResponse } from './Interfaces/UnprotectInterfaces'; +import { SecloreDRMApiService } from './SecloreDRMApiService'; + +export class SecloreDRMFileService { + private apiService: SecloreDRMApiService; + private tenantId: string; + private tenantSecret: string; + private accessToken?: string; + private refreshToken?: string; + private tokenExpiry?: Date; + private refreshPromise?: Promise; + private loginPromise?: Promise; + + constructor( + context: IExecuteFunctions, + baseUrl: string, + tenantId: string, + tenantSecret: string, + private defaultRetryCount: number = 3, + ) { + this.apiService = new SecloreDRMApiService(context, baseUrl); + this.tenantId = tenantId; + this.tenantSecret = tenantSecret; + } + + /** + * Ensures we have a valid access token, logging in if necessary + * @param correlationId - Optional correlation ID for logging + */ + private async ensureAuthenticated(correlationId?: string): Promise { + // If we don't have a token or it's expired, login + if (!this.accessToken || (this.tokenExpiry && new Date() >= this.tokenExpiry)) { + // If there's already a login in progress, wait for it + if (this.loginPromise) { + await this.loginPromise; + return; + } + + // Start login and store the promise + this.loginPromise = this.login(correlationId); + try { + await this.loginPromise; + } finally { + this.loginPromise = undefined; + } + } + } + + /** + * Performs login and stores tokens + * @param correlationId - Optional correlation ID for logging + */ + private async login(correlationId?: string): Promise { + const who = "SecloreDRMFileService::login:: "; + try { + Logger.debug(who + 'Attempting login', { tenantId: this.tenantId, correlationId }); + const loginResponse = await this.apiService.login( + this.tenantId, + this.tenantSecret, + correlationId, + ); + + this.accessToken = loginResponse.accessToken; + this.refreshToken = loginResponse.refreshToken; + + // Set token expiry to 50 minutes from now (assuming 1 hour token life) + this.tokenExpiry = new Date(Date.now() + 50 * 60 * 1000); + Logger.info(who + 'Login successful', { tenantId: this.tenantId, correlationId }); + } catch (error) { + Logger.error(who + 'Login failed', { error, tenantId: this.tenantId, correlationId }); + this.clearTokens(); + throw error; + } + } + + /** + * Attempts to refresh the access token with concurrency protection + * @param correlationId - Optional correlation ID for logging + */ + private async refreshAccessToken(correlationId?: string): Promise { + // If there's already a refresh in progress, wait for it + if (this.refreshPromise) { + await this.refreshPromise; + return; + } + + if (!this.refreshToken) { + throw new Error('No refresh token available'); + } + + // Start refresh and store the promise + this.refreshPromise = this.performTokenRefresh(correlationId); + try { + await this.refreshPromise; + } finally { + this.refreshPromise = undefined; + } + } + + /** + * Performs the actual token refresh + * @param correlationId - Optional correlation ID for logging + */ + private async performTokenRefresh(correlationId?: string): Promise { + const who = "SecloreDRMFileService::performTokenRefresh:: "; + try { + Logger.debug(who + 'Attempting token refresh', { correlationId }); + const refreshResponse = await this.apiService.refreshToken(this.refreshToken!, correlationId); + + this.accessToken = refreshResponse.accessToken; + this.refreshToken = refreshResponse.refreshToken; + + // Set token expiry to 50 minutes from now + this.tokenExpiry = new Date(Date.now() + 50 * 60 * 1000); + Logger.info(who + 'Token refresh successful', { correlationId }); + } catch (error) { + Logger.error(who + 'Token refresh failed', { error, correlationId }); + this.clearTokens(); + throw error; + } + } + + /** + * Clears stored tokens and pending promises + */ + private clearTokens(): void { + this.accessToken = undefined; + this.refreshToken = undefined; + this.tokenExpiry = undefined; + this.refreshPromise = undefined; + this.loginPromise = undefined; + } + + /** + * Executes an API call with automatic authentication and retry logic + * @param apiCall - The API call function to execute + * @param retryCount - Number of retries (defaults to class default) + * @param correlationId - Optional correlation ID for logging + */ + private async executeWithRetry( + apiCall: (accessToken: string) => Promise, + retryCount: number = this.defaultRetryCount, + correlationId?: string, + ): Promise { + let lastError: Error; + + for (let attempt = 0; attempt <= retryCount; attempt++) { + try { + // Ensure we have a valid token + await this.ensureAuthenticated(correlationId); + + if (!this.accessToken) { + throw new Error('Failed to obtain access token'); + } + + // Execute the API call + return await apiCall(this.accessToken); + } catch (error: unknown) { + lastError = error as Error; + // If it's an authentication error and we have retries left, try to refresh token + if ((error as Error).message.includes('Unauthorized') && attempt < retryCount) { + try { + await this.refreshAccessToken(correlationId); + continue; // Retry with new token + } catch (refreshError: unknown) { + Logger.error('SecloreDRMFileService::executeWithRetry:: Token refresh failed', { refreshError, correlationId }); + // If refresh fails, clear tokens and try full login on next attempt + this.clearTokens(); + } + } + + // If it's the last attempt or not an auth error, throw + if (attempt === retryCount) { + throw lastError; + } + + // Wait before retry (exponential backoff) + await new Promise((resolve) => { + const delay = Math.pow(2, attempt) * 1000; + // Use a simple delay implementation + const start = Date.now(); + while (Date.now() - start < delay) { + // Busy wait for delay + } + resolve(undefined); + }); + } + } + + throw lastError!; + } + + /** + * Protect file using external identifier with automatic authentication and retry + */ + async protectWithExternalRefId( + protectRequest: IProtectWithExternalRefIdRequest, + correlationId?: string, + retryCount?: number, + ): Promise { + const who = "SecloreDRMFileService::protectWithExternalRefId:: "; + try { + Logger.debug(who + 'Protecting file with external ref ID', { fileStorageId: protectRequest.fileStorageId, hotfolderExternalReferenceId: protectRequest.hotfolderExternalReference.externalReferenceId, correlationId }); + const result = await this.executeWithRetry( + (accessToken) => + this.apiService.protectWithExternalRefId(protectRequest, accessToken, correlationId), + retryCount, + correlationId, + ); + Logger.info(who + 'File protected with external ref ID successfully', { fileStorageId: protectRequest.fileStorageId, secloreFileId: result.secloreFileId, correlationId }); + return result; + } catch (error) { + Logger.error(who + 'Protect with external ref ID failed', { error, fileStorageId: protectRequest.fileStorageId, correlationId }); + throw error; + } + } + + /** + * Protect file using existing protected file ID with automatic authentication and retry + */ + async protectWithFileId( + protectRequest: IProtectWithFileIdRequest, + correlationId?: string, + retryCount?: number, + ): Promise { + const who = "SecloreDRMFileService::protectWithFileId:: "; + try { + Logger.debug(who + 'Protecting file with file ID', { existingProtectedFileId: protectRequest.existingProtectedFileId, fileStorageId: protectRequest.fileStorageId, correlationId }); + const result = await this.executeWithRetry( + (accessToken) => + this.apiService.protectWithFileId(protectRequest, accessToken, correlationId), + retryCount, + correlationId, + ); + Logger.info(who + 'File protected with file ID successfully', { existingProtectedFileId: protectRequest.existingProtectedFileId, secloreFileId: result.secloreFileId, correlationId }); + return result; + } catch (error) { + Logger.error(who + 'Protect with file ID failed', { error, existingProtectedFileId: protectRequest.existingProtectedFileId, correlationId }); + throw error; + } + } + + /** + * Protect file using HotFolder ID with automatic authentication and retry + */ + async protectWithHotFolder( + protectRequest: IProtectWithHotFolderRequest, + correlationId?: string, + retryCount?: number, + ): Promise { + const who = "SecloreDRMFileService::protectWithHotFolder:: "; + try { + Logger.debug(who + 'Protecting file with hot folder', { hotfolderId: protectRequest.hotfolderId, fileStorageId: protectRequest.fileStorageId, correlationId }); + const result = await this.executeWithRetry( + (accessToken) => + this.apiService.protectWithHotFolder(protectRequest, accessToken, correlationId), + retryCount, + correlationId, + ); + Logger.info(who + 'File protected with hot folder successfully', { hotfolderId: protectRequest.hotfolderId, fileStorageId: protectRequest.fileStorageId, secloreFileId: result.secloreFileId, correlationId }); + return result; + } catch (error) { + Logger.error(who + 'Protect with hot folder failed', { error, hotfolderId: protectRequest.hotfolderId, fileStorageId: protectRequest.fileStorageId, correlationId }); + throw error; + } + } + + /** + * Unprotect file with automatic authentication and retry + */ + async unprotect( + unprotectRequest: IUnprotectRequest, + correlationId?: string, + retryCount?: number, + ): Promise { + const who = "SecloreDRMFileService::unprotect:: "; + try { + Logger.debug(who + 'Unprotecting file', { fileStorageId: unprotectRequest.fileStorageId, correlationId }); + const result = await this.executeWithRetry( + (accessToken) => this.apiService.unprotect(unprotectRequest, accessToken, correlationId), + retryCount, + correlationId, + ); + Logger.info(who + 'File unprotected successfully', { originalFileStorageId: unprotectRequest.fileStorageId, unprotectedFileStorageId: result.fileStorageId, correlationId }); + return result; + } catch (error) { + Logger.error(who + 'Unprotect file failed', { error, fileStorageId: unprotectRequest.fileStorageId, correlationId }); + throw error; + } + } + + /** + * Upload file with automatic authentication and retry + */ + async uploadFile( + fileBuffer: Uint8Array, + fileName: string, + correlationId?: string, + retryCount?: number, + ): Promise { + const who = "SecloreDRMFileService::uploadFile:: "; + try { + Logger.debug(who + 'Uploading file', { fileName, correlationId }); + const result = await this.executeWithRetry( + (accessToken) => this.apiService.uploadFile(fileBuffer, fileName, accessToken, correlationId), + retryCount, + correlationId, + ); + Logger.info(who + 'File uploaded successfully', { fileName, correlationId }); + return result; + } catch (error) { + Logger.error(who + 'Upload file failed', { error, fileName, correlationId }); + throw error; + } + } + + /** + * Download file with automatic authentication and retry + * NOTE: Files whose fileStorageId has 'DL_' prefix will be deleted from the file storage after download. + */ + async downloadFile( + fileStorageId: string, + correlationId?: string, + retryCount?: number, + ): Promise { + const who = "SecloreDRMFileService::downloadFile:: "; + try { + Logger.debug(who + 'Downloading file', { fileStorageId, correlationId }); + const result = await this.executeWithRetry( + (accessToken) => this.apiService.downloadFile(fileStorageId, accessToken, correlationId), + retryCount, + correlationId, + ); + Logger.info(who + 'File downloaded successfully', { fileStorageId, fileSize: result.data.length, correlationId }); + return result; + } catch (error) { + Logger.error(who + 'Download file failed', { error, fileStorageId, correlationId }); + throw error; + } + } + + /** + * Delete file with automatic authentication and retry + */ + async deleteFile( + fileStorageId: string, + correlationId?: string, + retryCount?: number, + ): Promise { + const who = "SecloreDRMFileService::deleteFile:: "; + try { + Logger.debug(who + 'Deleting file', { fileStorageId, correlationId }); + await this.executeWithRetry( + (accessToken) => this.apiService.deleteFile(fileStorageId, accessToken, correlationId), + retryCount, + correlationId, + ); + Logger.info(who + 'File deleted successfully', { fileStorageId, correlationId }); + } catch (error) { + Logger.error(who + 'Delete file failed', { error, fileStorageId, correlationId }); + throw error; + } + } + + /** + * Get current access token (for debugging/monitoring) + */ + getAccessToken(): string | undefined { + return this.accessToken; + } + + /** + * Check if currently authenticated + */ + isAuthenticated(): boolean { + return !!this.accessToken && (!this.tokenExpiry || new Date() < this.tokenExpiry); + } +} diff --git a/nodes/SecloreProtect/Services/Utils.ts b/nodes/SecloreProtect/Services/Utils.ts new file mode 100644 index 0000000..c11dd4a --- /dev/null +++ b/nodes/SecloreProtect/Services/Utils.ts @@ -0,0 +1,60 @@ +import { LoggerProxy as Logger } from 'n8n-workflow'; + +/** + * Extracts filename from Content-Disposition header + * @param headers - Response headers object + * @returns The extracted filename or null if not found + */ +export function getFileNameFromHeaders(headers?: { [key: string]: string }): string | null { + const who = "Utils::getFileNameFromHeaders:: "; + + if (!headers) { + Logger.debug(who + 'No headers provided'); + return null; + } + + // Look for content-disposition header (case-insensitive) + const contentDisposition = Object.keys(headers).find(key => + key.toLowerCase() === 'content-disposition' + ); + + if (!contentDisposition || !headers[contentDisposition]) { + Logger.debug(who + 'Content-Disposition header not found'); + return null; + } + + const headerValue = headers[contentDisposition]; + Logger.debug(who + 'Found Content-Disposition header', { headerValue }); + + // Handle different filename formats in Content-Disposition header + // Format 1: filename*=UTF-8''encoded-filename + const utf8Match = headerValue.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match) { + try { + const decodedFilename = decodeURIComponent(utf8Match[1]); + Logger.debug(who + 'Extracted filename from UTF-8 format', { filename: decodedFilename }); + return decodedFilename; + } catch (error) { + Logger.error(who + 'Failed to decode UTF-8 filename', { error, encodedFilename: utf8Match[1] }); + } + } + + // Format 2: filename="quoted-filename" + const quotedMatch = headerValue.match(/filename="([^"]+)"/i); + if (quotedMatch) { + Logger.debug(who + 'Extracted filename from quoted format', { filename: quotedMatch[1] }); + return quotedMatch[1]; + } + + // Format 3: filename=unquoted-filename + const unquotedMatch = headerValue.match(/filename=([^;]+)/i); + if (unquotedMatch) { + const filename = unquotedMatch[1].trim(); + Logger.debug(who + 'Extracted filename from unquoted format', { filename }); + return filename; + } + + Logger.debug(who + 'Could not extract filename from Content-Disposition header', { headerValue }); + return null; +} + diff --git a/nodes/SecloreProtect/operations/protectWithHotFolder.ts b/nodes/SecloreProtect/operations/protectWithHotFolder.ts new file mode 100644 index 0000000..202b3ee --- /dev/null +++ b/nodes/SecloreProtect/operations/protectWithHotFolder.ts @@ -0,0 +1,314 @@ +import { + IExecuteFunctions, + INodeExecutionData, + NodeOperationError, + NodeOutput, + LoggerProxy as Logger, +} from 'n8n-workflow'; +import crypto from 'node:crypto'; +import { SecloreDRMFileService } from '../Services/SecloreDRMFileService'; +import { getFileNameFromHeaders } from '../Services/Utils'; + +/** + * Deletes a file from storage with error handling (does not throw errors) + * @param fileService - The SecloreDRMFileService instance + * @param fileStorageId - The file storage ID to delete + * @param correlationId - Optional correlation ID for tracking + * @param retryCount - Number of retries for operations + */ +async function deleteFile( + fileService: SecloreDRMFileService, + fileStorageId: string, + correlationId?: string, + retryCount: number = 3, +): Promise { + const who = "protectWithHotFolder::deleteFile:: "; + try { + Logger.debug(who + 'Attempting to delete file', { fileStorageId, correlationId, retryCount }); + + await fileService.deleteFile( + fileStorageId, + correlationId, + retryCount, + ); + + Logger.debug(who + 'File deleted successfully', { fileStorageId, correlationId }); + } catch (error) { + // Log error but don't throw - this is for cleanup operations + Logger.error(who + 'File deletion failed, continuing operation', { + error, + fileStorageId, + correlationId, + message: 'This is a cleanup operation, continuing despite deletion failure' + }); + } +} + +/** + * Uploads a file, protects it with hot folder, and downloads the protected version + * @param fileService - The SecloreDRMFileService instance + * @param fileBuffer - The file buffer to upload + * @param fileName - The name of the file + * @param hotfolderId - The hot folder ID to use for protection + * @param correlationId - Optional correlation ID for tracking + * @param retryCount - Number of retries for operations + * @returns Promise containing the protected file data and metadata + */ +async function protectFileWithHotFolder( + fileService: SecloreDRMFileService, + fileBuffer: Buffer, + fileName: string, + hotfolderId: string, + correlationId?: string, + retryCount: number = 3, +): Promise<{ + protectedFileData: Uint8Array; + originalFileStorageId: string; + protectedFileStorageId: string; + secloreFileId: string; + fileName: string; + fileSize: number; +}> { + const who = "protectWithHotFolder::protectFileWithHotFolder:: "; + let originalFileStorageId: string = ''; + try { + Logger.debug(who + 'Starting protect file with hot folder operation', { fileName, fileSize: fileBuffer.length, hotfolderId, correlationId, retryCount }); + + // Upload the file first + Logger.debug(who + 'Uploading file', { fileName, fileSize: fileBuffer.length, correlationId }); + const uploadResult = await fileService.uploadFile( + new Uint8Array(fileBuffer), + fileName, + correlationId, + retryCount, + ); + + Logger.debug(who + 'File uploaded successfully', { fileStorageId: uploadResult.fileStorageId, fileName, correlationId }); + + // check if the file is already protected + if (uploadResult.protected) { + throw new Error('File is already protected'); + } + + originalFileStorageId = uploadResult.fileStorageId; + + // Protect the uploaded file with HotFolder + Logger.debug(who + 'Protecting file with hot folder', { fileStorageId: uploadResult.fileStorageId, hotfolderId, fileName, correlationId }); + const protectResult = await fileService.protectWithHotFolder( + { + hotfolderId, + fileStorageId: uploadResult.fileStorageId, + }, + correlationId, + retryCount, + ); + + Logger.debug(who + 'File protected successfully', { + originalFileStorageId: uploadResult.fileStorageId, + protectedFileStorageId: protectResult.fileStorageId, + secloreFileId: protectResult.secloreFileId, + hotfolderId, + fileName, + correlationId + }); + + // Download the protected file + Logger.debug(who + 'Downloading protected file', { fileStorageId: protectResult.fileStorageId, fileName, correlationId }); + const protectedFileData = await fileService.downloadFile( + protectResult.fileStorageId, + correlationId, + retryCount, + ); + + // Try to get the actual filename from response headers, fallback to constructed name + const actualFileName = getFileNameFromHeaders(protectedFileData.headers) || fileName; + + Logger.debug(who + 'Protected file downloaded successfully', { + fileStorageId: protectResult.fileStorageId, + fileSize: protectedFileData.data.length, + originalFileName: fileName, + actualFileName, + correlationId + }); + + const result = { + protectedFileData: protectedFileData.data, + originalFileStorageId: uploadResult.fileStorageId, + protectedFileStorageId: protectResult.fileStorageId, + secloreFileId: protectResult.secloreFileId, + fileName: actualFileName, + fileSize: protectedFileData.data.length, + }; + + Logger.debug(who + 'Protect file with hot folder operation completed successfully', { + fileName: result.fileName, + originalFileStorageId: result.originalFileStorageId, + protectedFileStorageId: result.protectedFileStorageId, + secloreFileId: result.secloreFileId, + fileSize: result.fileSize, + hotfolderId, + correlationId + }); + + return result; + } catch (error) { + Logger.error(who + 'Protect file with hot folder operation failed', { error, fileName, hotfolderId, correlationId }); + throw error; + } finally { + if (originalFileStorageId !== '') { + await deleteFile(fileService, originalFileStorageId, correlationId, retryCount); + } + } +} + +export async function protectWithHotFolder(this: IExecuteFunctions): Promise { + const who = "protectWithHotFolder::protectWithHotFolder:: "; + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + + // Initialize logger with the current execution context + Logger.init(this.logger); + + Logger.debug(who + 'Seclore Protect with HotFolder operation started', { itemCount: items.length }); + + // Get credentials + Logger.debug(who + 'Getting credentials', {}); + const credentials = await this.getCredentials('secloreProtectApi'); + const baseUrl = credentials.baseUrl as string; + const tenantId = credentials.tenantId as string; + const tenantSecret = credentials.tenantSecret as string; + + // Initialize the file service + Logger.debug(who + 'Initializing file service', { baseUrl, tenantId }); + const fileService = new SecloreDRMFileService(this, baseUrl, tenantId, tenantSecret); + + for (let i = 0; i < items.length; i++) { + Logger.debug(who + 'Processing item', { itemIndex: i }); + try { + // Get parameters for this item + Logger.debug(who + 'Getting node parameters', { itemIndex: i }); + const hotfolderId = this.getNodeParameter('hotfolderId', i) as string; + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string; + const correlationId = crypto.randomUUID(); + const retryCount = this.getNodeParameter('retryCount', i) as number; + + // Validate required parameters + if (!hotfolderId) { + throw new NodeOperationError(this.getNode(), 'HotFolder ID is required', { + itemIndex: i, + }); + } + + Logger.debug(who + 'Asserting binary data', { binaryPropertyName, itemIndex: i }); + // Get input binary data + const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); + + Logger.debug(who + 'Getting binary data buffer', { binaryPropertyName, itemIndex: i }); + const fileBuffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + + Logger.debug(who + 'Binary data retrieved', { + fileName: binaryData.fileName, + fileSize: fileBuffer.length, + mimeType: binaryData.mimeType, + itemIndex: i, + correlationId, + retryCount + }); + + // Use the combined upload, protect, and download function + try { + Logger.debug(who + 'Starting protect file with hot folder operation', { + fileName: binaryData.fileName, + hotfolderId, + correlationId, + retryCount, + itemIndex: i + }); + + const result = await protectFileWithHotFolder( + fileService, + fileBuffer, + binaryData.fileName || 'file', + hotfolderId, + correlationId, + retryCount, + ); + + Logger.debug(who + 'Protect file with hot folder operation completed successfully', { + fileName: result.fileName, + originalFileStorageId: result.originalFileStorageId, + protectedFileStorageId: result.protectedFileStorageId, + secloreFileId: result.secloreFileId, + fileSize: result.fileSize, + hotfolderId, + itemIndex: i, + correlationId + }); + + // Create output binary data + Logger.debug(who + 'Preparing binary data for output', { + fileName: result.fileName, + mimeType: binaryData.mimeType, + fileSize: result.fileSize, + itemIndex: i + }); + const outputBinaryData = await this.helpers.prepareBinaryData( + Buffer.from(result.protectedFileData), + result.fileName, + binaryData.mimeType, + ); + + // Create return item with binary data and metadata + const returnItem: INodeExecutionData = { + json: { + success: true, + originalFileStorageId: result.originalFileStorageId, + protectedFileStorageId: result.protectedFileStorageId, + secloreFileId: result.secloreFileId, + hotfolderId, + fileName: result.fileName, + fileSize: result.fileSize, + correlationId: correlationId, + }, + binary: { + data: outputBinaryData, + }, + }; + + Logger.debug(who + 'Adding result to return data', { itemIndex: i, success: true }); + returnData.push(returnItem); + } catch (protectError) { + Logger.error(who + 'Protect file with hot folder operation failed', { protectError, itemIndex: i }); + + // Re-throw the error to be handled by the outer catch block + throw protectError; + } + } catch (error) { + // Handle errors gracefully + Logger.error(who + 'Item processing failed', { error, itemIndex: i }); + if (this.continueOnFail()) { + Logger.debug(who + 'Continuing on fail, adding error item', { itemIndex: i, errorMessage: error.message }); + const returnItem: INodeExecutionData = { + json: { + success: false, + error: error.message, + itemIndex: i, + }, + }; + returnData.push(returnItem); + } else { + Logger.error(who + 'Throwing NodeOperationError', { error: error.message, itemIndex: i }); + throw new NodeOperationError(this.getNode(), error.message, { + itemIndex: i, + }); + } + } + } + + Logger.debug(who + 'Seclore Protect with HotFolder operation completed', { + processedItems: returnData.length, + successfulItems: returnData.filter(item => item.json.success).length + }); + + return [returnData]; +} diff --git a/nodes/SecloreProtect/operations/unprotect.ts b/nodes/SecloreProtect/operations/unprotect.ts new file mode 100644 index 0000000..75c0809 --- /dev/null +++ b/nodes/SecloreProtect/operations/unprotect.ts @@ -0,0 +1,298 @@ +import { + IExecuteFunctions, + INodeExecutionData, + NodeOperationError, + NodeOutput, + LoggerProxy as Logger, +} from 'n8n-workflow'; +import crypto from 'node:crypto'; +import { SecloreDRMFileService } from '../Services/SecloreDRMFileService'; +import { getFileNameFromHeaders } from '../Services/Utils'; + +/** + * Deletes a file from storage with error handling (does not throw errors) + * @param fileService - The SecloreDRMFileService instance + * @param fileStorageId - The file storage ID to delete + * @param correlationId - Optional correlation ID for tracking + * @param retryCount - Number of retries for operations + */ +async function deleteFile( + fileService: SecloreDRMFileService, + fileStorageId: string, + correlationId?: string, + retryCount: number = 3, +): Promise { + const who = "unprotect::deleteFileWithErrorHandling:: "; + try { + Logger.debug(who + 'Attempting to delete file', { fileStorageId, correlationId, retryCount }); + + await fileService.deleteFile( + fileStorageId, + correlationId, + retryCount, + ); + + Logger.debug(who + 'File deleted successfully', { fileStorageId, correlationId }); + } catch (error) { + // Log error but don't throw - this is for cleanup operations + Logger.error(who + 'File deletion failed, continuing operation', { + error, + fileStorageId, + correlationId, + message: 'This is a cleanup operation, continuing despite deletion failure' + }); + } +} + +/** + * Uploads a file, unprotects it, and downloads the unprotected version + * @param fileService - The SecloreDRMFileService instance + * @param fileBuffer - The file buffer to upload + * @param fileName - The name of the file + * @param correlationId - Optional correlation ID for tracking + * @param retryCount - Number of retries for operations + * @returns Promise containing the unprotected file data and metadata + */ +async function unprotectFile( + fileService: SecloreDRMFileService, + fileBuffer: Buffer, + fileName: string, + correlationId?: string, + retryCount: number = 3, +): Promise<{ + unprotectedFileData: Uint8Array; + originalFileStorageId: string; + unprotectedFileStorageId: string; + fileName: string; + fileSize: number; +}> { + const who = "unprotect::unprotectFile:: "; + let originalFileStorageId: string = ''; + try { + Logger.debug(who + 'Starting unprotect file operation', { fileName, fileSize: fileBuffer.length, correlationId, retryCount }); + + // Upload the protected file + Logger.debug(who + 'Uploading protected file', { fileName, fileSize: fileBuffer.length, correlationId }); + const uploadResult = await fileService.uploadFile( + new Uint8Array(fileBuffer), + fileName, + correlationId, + retryCount, + ); + + Logger.debug(who + 'File uploaded successfully', { fileStorageId: uploadResult.fileStorageId, fileName, correlationId }); + + originalFileStorageId = uploadResult.fileStorageId; + + // check if the file is already unprotected + if (!uploadResult.protected) { + Logger.debug(who + 'File is already unprotected', { fileStorageId: uploadResult.fileStorageId, fileName, correlationId }); + return { + unprotectedFileData: fileBuffer, + originalFileStorageId: uploadResult.fileStorageId, + unprotectedFileStorageId: uploadResult.fileStorageId, + fileName, + fileSize: fileBuffer.length, + }; + } + + // Unprotect the uploaded file + Logger.debug(who + 'Unprotecting file', { fileStorageId: uploadResult.fileStorageId, fileName, correlationId }); + const unprotectResult = await fileService.unprotect( + { + fileStorageId: uploadResult.fileStorageId, + }, + correlationId, + retryCount, + ); + + Logger.debug(who + 'File unprotected successfully', { + originalFileStorageId: uploadResult.fileStorageId, + unprotectedFileStorageId: unprotectResult.fileStorageId, + fileName, + correlationId + }); + + // Download the unprotected file + Logger.debug(who + 'Downloading unprotected file', { fileStorageId: unprotectResult.fileStorageId, fileName, correlationId }); + const unprotectedFileData = await fileService.downloadFile( + unprotectResult.fileStorageId, + correlationId, + retryCount, + ); + + // Try to get the actual filename from response headers, fallback to original filename + const actualFileName = getFileNameFromHeaders(unprotectedFileData.headers) || fileName; + + Logger.debug(who + 'Unprotected file downloaded successfully', { + fileStorageId: unprotectResult.fileStorageId, + fileSize: unprotectedFileData.data.length, + originalFileName: fileName, + actualFileName: actualFileName, + correlationId + }); + + const result = { + unprotectedFileData: unprotectedFileData.data, + originalFileStorageId: uploadResult.fileStorageId, + unprotectedFileStorageId: unprotectResult.fileStorageId, + fileName: actualFileName, + fileSize: unprotectedFileData.data.length, + }; + + Logger.debug(who + 'Unprotect file operation completed successfully', { + fileName: result.fileName, + originalFileStorageId: result.originalFileStorageId, + unprotectedFileStorageId: result.unprotectedFileStorageId, + fileSize: result.fileSize, + correlationId + }); + + return result; + } catch (error) { + Logger.error(who + 'Unprotect file operation failed', { error, fileName, correlationId }); + throw error; + } finally { + if (originalFileStorageId !== '') { + await deleteFile(fileService, originalFileStorageId, correlationId, retryCount); + } + } +} + +export async function unprotect(this: IExecuteFunctions): Promise { + const who = "unprotect::unprotect:: "; + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + + // Initialize logger with the current execution context + Logger.init(this.logger); + + Logger.debug(who + 'Seclore Unprotect operation started', { itemCount: items.length }); + + // Get credentials + Logger.debug(who + 'Getting credentials', {}); + const credentials = await this.getCredentials('secloreProtectApi'); + const baseUrl = credentials.baseUrl as string; + const tenantId = credentials.tenantId as string; + const tenantSecret = credentials.tenantSecret as string; + + // Initialize the file service + Logger.debug(who + 'Initializing file service', { baseUrl, tenantId }); + const fileService = new SecloreDRMFileService(this, baseUrl, tenantId, tenantSecret); + + for (let i = 0; i < items.length; i++) { + Logger.debug(who + 'Processing item', { itemIndex: i }); + try { + // Get parameters for this item + Logger.debug(who + 'Getting node parameters', { itemIndex: i }); + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string; + const correlationId = crypto.randomUUID(); + const retryCount = this.getNodeParameter('retryCount', i) as number; + + Logger.debug(who + 'Asserting binary data', { binaryPropertyName, itemIndex: i }); + // Get input binary data + const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); + + Logger.debug(who + 'Getting binary data buffer', { binaryPropertyName, itemIndex: i }); + const fileBuffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + + Logger.debug(who + 'Binary data retrieved', { + fileName: binaryData.fileName, + fileSize: fileBuffer.length, + mimeType: binaryData.mimeType, + itemIndex: i, + correlationId, + retryCount + }); + + // Use the combined upload, unprotect, and download function + try { + Logger.debug(who + 'Starting unprotect file operation', { + fileName: binaryData.fileName, + correlationId, + retryCount, + itemIndex: i + }); + + const result = await unprotectFile( + fileService, + fileBuffer, + binaryData.fileName || 'protected_file', + correlationId, + retryCount, + ); + + Logger.debug(who + 'Unprotect file operation completed successfully', { + fileName: result.fileName, + originalFileStorageId: result.originalFileStorageId, + unprotectedFileStorageId: result.unprotectedFileStorageId, + fileSize: result.fileSize, + itemIndex: i, + correlationId + }); + + // Create output binary data + Logger.debug(who + 'Preparing binary data for output', { + fileName: binaryData.fileName, + mimeType: binaryData.mimeType, + fileSize: result.fileSize, + itemIndex: i + }); + const outputBinaryData = await this.helpers.prepareBinaryData( + Buffer.from(result.unprotectedFileData), + result.fileName, + binaryData.mimeType, + ); + + // Create return item with binary data and metadata + const returnItem: INodeExecutionData = { + json: { + success: true, + originalFileStorageId: result.originalFileStorageId, + unprotectedFileStorageId: result.unprotectedFileStorageId, + fileName: result.fileName, + fileSize: result.fileSize, + correlationId: correlationId, + }, + binary: { + data: outputBinaryData, + }, + }; + + Logger.debug(who + 'Adding result to return data', { itemIndex: i, success: true }); + returnData.push(returnItem); + } catch (unprotectError) { + Logger.error(who + 'Unprotect file operation failed', { unprotectError, itemIndex: i }); + + // Re-throw the error to be handled by the outer catch block + throw unprotectError; + } + } catch (error) { + // Handle errors gracefully + Logger.error(who + 'Item processing failed', { error, itemIndex: i }); + if (this.continueOnFail()) { + Logger.debug(who + 'Continuing on fail, adding error item', { itemIndex: i, errorMessage: error.message }); + const returnItem: INodeExecutionData = { + json: { + success: false, + error: error.message, + itemIndex: i, + }, + }; + returnData.push(returnItem); + } else { + Logger.error(who + 'Throwing NodeOperationError', { error: error.message, itemIndex: i }); + throw new NodeOperationError(this.getNode(), error.message, { + itemIndex: i, + }); + } + } + } + + Logger.debug(who + 'Seclore Unprotect operation completed', { + processedItems: returnData.length, + successfulItems: returnData.filter(item => item.json.success).length + }); + + return [returnData]; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8fe543a --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "@seclore/n8n-nodes-seclore", + "version": "0.1.0", + "description": "n8n community node for Seclore Protect - secure file protection using DRM technology", + "license": "MIT", + "homepage": "", + "keywords": [ + "n8n-community-node-package" + ], + "author": { + "name": "Seclore Technology", + "email": "support@seclore.com" + }, + "repository": { + "type": "git", + "url": "https://repo.seclore.com/public/n8n-nodes-seclore.git" + }, + "scripts": { + "build": "n8n-node build", + "build:watch": "tsc --watch", + "dev": "n8n-node dev", + "lint": "n8n-node lint", + "lint:fix": "n8n-node lint --fix", + "release": "n8n-node release", + "prepublishOnly": "n8n-node prerelease" + }, + "files": [ + "dist" + ], + "n8n": { + "n8nNodesApiVersion": 1, + "strict": true, + "credentials": [ + "dist/credentials/SecloreProtectApi.credentials.js" + ], + "nodes": [ + "dist/nodes/SecloreProtect/SecloreProtect.node.js" + ] + }, + "devDependencies": { + "@n8n/node-cli": "*", + "@types/node": "^24.9.1", + "eslint": "9.32.0", + "prettier": "3.6.2", + "release-it": "^19.0.4", + "typescript": "5.9.2" + }, + "peerDependencies": { + "n8n-workflow": "*" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e084103 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "strict": true, + "module": "commonjs", + "moduleResolution": "node", + "target": "es2019", + "lib": ["es2019", "es2020", "es2022.error"], + "removeComments": true, + "useUnknownInCatchVariables": false, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "strictNullChecks": true, + "preserveConstEnums": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "incremental": true, + "declaration": true, + "sourceMap": true, + "skipLibCheck": true, + "outDir": "./dist/", + "types": ["node"] + }, + "include": ["credentials/**/*", "nodes/**/*", "nodes/**/*.json", "package.json"] +}