diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..473e451
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,16 @@
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[{package.json,*.yml}]
+indent_style = space
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..1521c8b
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1 @@
+dist
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..53bf401
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,37 @@
+name: Node CI
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ pack:
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: ⬇️ Check out code
+ uses: actions/checkout@v4
+
+ - name: 🟢 Enable Corepack
+ run: corepack enable
+
+ - name: 🟢 Set up Node 20
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20.x
+ cache: yarn
+
+ - name: 📦 Install deps, build, pack
+ run: |
+ yarn install --frozen-lockfile
+ yarn build
+ yarn pack --out %s-%v.tgz
+ env:
+ CI: true
+
+ - name: 📤 Upload package artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: strapi-plugin-imagekit-package
+ path: strapi-plugin-imagekit-*.tgz
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..3a99421
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,51 @@
+name: Publish Package to npmjs
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ steps:
+ - name: ⬇️ Check out code
+ uses: actions/checkout@v4
+
+ - name: 🟢 Enable Corepack
+ run: corepack enable
+
+ - name: 🟢 Set up Node 20
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20.x
+ cache: yarn
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Build and Publish
+ run: |
+ yarn install --frozen-lockfile
+
+ yarn build
+
+ # print the NPM user name for validation
+ yarn npm whoami
+
+ VERSION=$(node -p "require('./package.json').version" )
+
+ # Only publish stable versions to the latest tag
+ if [[ "$VERSION" =~ ^[^-]+$ ]]; then
+ NPM_TAG="latest"
+ else
+ NPM_TAG="beta"
+ fi
+
+ echo "Publishing $VERSION with $NPM_TAG tag."
+
+ yarn npm publish --tag $NPM_TAG --provenance
+
+ env:
+ NODE_AUTH_TOKEN: ${{secrets.npm_token}}
+ CI: true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..faa1ed2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,138 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+############################
+# OS X
+############################
+
+.DS_Store
+.AppleDouble
+.LSOverride
+Icon
+.Spotlight-V100
+.Trashes
+._*
+
+
+############################
+# Linux
+############################
+
+*~
+
+
+############################
+# Windows
+############################
+
+Thumbs.db
+ehthumbs.db
+Desktop.ini
+$RECYCLE.BIN/
+*.cab
+*.msi
+*.msm
+*.msp
+
+
+############################
+# Packages
+############################
+
+*.7z
+*.csv
+*.dat
+*.dmg
+*.gz
+*.iso
+*.jar
+*.rar
+*.tar
+*.zip
+*.com
+*.class
+*.dll
+*.exe
+*.o
+*.seed
+*.so
+*.swo
+*.swp
+*.swn
+*.swm
+*.out
+*.pid
+
+
+############################
+# Logs and databases
+############################
+
+.tmp
+*.log
+*.sql
+*.sqlite
+*.sqlite3
+
+
+############################
+# Misc.
+############################
+
+*#
+ssl
+.idea
+nbproject
+.tsbuildinfo
+.eslintcache
+.env
+
+
+############################
+# Strapi
+############################
+
+public/uploads/*
+!public/uploads/.gitkeep
+
+
+############################
+# Build
+############################
+
+dist
+build
+
+
+############################
+# Node.js
+############################
+
+lib-cov
+lcov.info
+pids
+logs
+results
+node_modules
+.node_history
+
+
+############################
+# Package managers
+############################
+
+.yarn/*
+!.yarn/cache
+!.yarn/unplugged
+!.yarn/patches
+!.yarn/releases
+!.yarn/sdks
+!.yarn/versions
+.pnp.*
+yarn-error.log
+
+
+############################
+# Tests
+############################
+
+coverage
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..2789c26
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v20.13.0
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..009af54
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,2 @@
+dist
+coverage
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..a6a6978
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "endOfLine": "lf",
+ "tabWidth": 2,
+ "printWidth": 100,
+ "singleQuote": true,
+ "trailingComma": "es5"
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..aaf1d26
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,13 @@
+{
+ "editor.tabSize": 2,
+ "typescript.preferences.importModuleSpecifier": "relative",
+ "prettier.bracketSpacing": false,
+ "editor.formatOnSave": true,
+ "editor.formatOnSaveMode": "file",
+ "editor.codeActionsOnSave": {
+ "source.addMissingImports.ts": "always",
+ "source.fixAll.eslint": "always",
+ "source.organizeImports": "always"
+ },
+ "typescript.tsdk": "node_modules/typescript/lib"
+}
diff --git a/.yarnrc.yml b/.yarnrc.yml
new file mode 100644
index 0000000..3186f3f
--- /dev/null
+++ b/.yarnrc.yml
@@ -0,0 +1 @@
+nodeLinker: node-modules
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..6f3f9db
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,47 @@
+# Contributing to strapi-plugin-imagekit
+
+Thank you for your interest in contributing to the ImageKit Strapi Plugin! Your help is greatly appreciated. Please follow the guidelines below to ensure a smooth contribution process.
+
+## Getting Started
+
+1. **Fork the repository** and clone your fork locally.
+2. **Install dependencies**:
+ ```bash
+ npm install
+ # or
+ yarn install
+ ```
+3. **Create a new branch** for your feature or bugfix:
+ ```bash
+ git checkout -b my-feature
+ ```
+
+## Development
+
+- Keep your code clean and readable.
+- Follow existing code style and conventions (TypeScript, Prettier, etc.).
+- Document new features or changes in the README if needed.
+
+## Pull Requests
+
+- Ensure your branch is up to date with the latest `main` branch.
+- Open a pull request with a clear description of your changes.
+- Reference any related issues in your PR description.
+- Be responsive to code review feedback.
+
+## Commit Messages
+
+- Use clear, descriptive commit messages.
+- Follow the [Conventional Commits](https://www.conventionalcommits.org/) style if possible.
+
+## Code of Conduct
+
+Please be respectful and considerate in all interactions.
+
+## Reporting Issues
+
+If you find a bug or have a feature request, please open an issue on GitHub with as much detail as possible.
+
+---
+
+Thank you for helping make this project better!
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..61c90a9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 ImageKit Pvt Ltd
+
+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 973a185..5dea0cb 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,249 @@
-# strapi-plugin-imagekit
+[
](https://imagekit.io)
+
+# Strapi Plugin for ImageKit.io
+
+[](https://github.com/imagekit-developer/strapi-plugin-imagekit/actions/workflows/ci.yaml)
+[](https://www.npmjs.com/package/strapi-plugin-imagekit)
+[](https://opensource.org/licenses/MIT)
+[](https://twitter.com/ImagekitIo)
+
+A Strapi plugin that provides seamless integration with [ImageKit.io](https://imagekit.io/), enabling you to browse, manage, and deliver optimized media directly from your Strapi admin panel.
+
+ImageKit is a complete media storage, optimization, and transformation solution with an image and video CDN. It integrates with your existing infrastructure (AWS S3, web servers, CDN, custom domains) to deliver optimized images in minutes with minimal code changes.
+
+## Table of Contents
+
+1. [Features](#features)
+2. [Prerequisites](#prerequisites)
+3. [Installation](#installation)
+4. [Configuration](#configuration)
+ - [Configure in Admin UI](#configure-in-admin-ui)
+ - [Advanced: Programmatic Configuration](#advanced-programmatic-configuration-configpluginsjs)
+ - [Configure Security Middleware (CSP)](#configure-security-middleware-csp)
+5. [Contributing](#contributing)
+6. [License](#license)
+7. [Support](#support)
+
+## Features
+
+- **Media Library Integration**: Browse and manage your ImageKit media library directly in Strapi
+- **Bulk Import**: Import existing ImageKit assets into Strapi with a single click
+- **Optimized Delivery**: Serve optimized images and videos through ImageKit
+- **Upload**: Upload new files to ImageKit directly from the Strapi media library
+- **Signed URLs**: Deliver signed URLs for your media assets
+
+## Prerequisites
+
+Before you begin, you need:
+
+- A Strapi project (v5 or later)
+- Node.js and npm/yarn installed
+- Administrator access to your Strapi instance
+- An [ImageKit account](https://imagekit.io/registration/) (sign up if you don't have one)
+
+You can refer to Strapi's [official documentation](https://docs.strapi.io/cms/quick-start) to understand the prerequisites for running your Strapi instance.
+
+## Installation
+
+To install the ImageKit plugin in your Strapi instance, run one of the following commands from your project's root directory:
+
+```bash
+# Using NPM
+npm install strapi-plugin-imagekit --save
+
+# Using Yarn (recommended)
+yarn add strapi-plugin-imagekit
+```
+
+Once installed, you must rebuild your Strapi instance:
+
+```bash
+# Using NPM
+npm run build
+npm run develop
+
+# Using Yarn
+yarn build
+yarn develop
+
+# OR development mode with auto-reload for admin panel
+yarn develop --watch-admin
+```
+
+The **ImageKit** plugin will appear in the sidebar and Settings section after the app rebuilds.
+
+| | |
+| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
+|  |  |
+
+## Configuration
+
+### Configure in Admin UI
+
+You can configure the ImageKit plugin from within the Strapi admin dashboard. Follow these steps:
+
+1. Go to **Settings** in the main sidebar
+2. Find the **ImageKit Plugin** section and click on **Configuration**.
+
+
+
+You'll see three configuration sections that you should complete in order:
+
+#### 1. Base Configuration
+
+This section contains the essential credentials to connect with your ImageKit account:
+
+1. **Public Key**: Obtain your public key (prefixed with `public_`) from the [API Keys section](https://imagekit.io/dashboard/developer/api-keys) of your ImageKit dashboard.
+2. **Private Key**: Copy your private key (prefixed with `private_`) from the same dashboard page. Note: Keep your private key confidential as it grants full access to your ImageKit account.
+3. **URL Endpoint**: Get your endpoint URL (formatted as `https://ik.imagekit.io/your_imagekit_id`) from the same dashboard page.
+
+
+
+#### 2. Configure Media Delivery
+
+After adding your credentials, set up how your media will be served:
+
+1. **Configure Web Folder Origin**: Add Strapi as a web folder origin in your ImageKit dashboard (ignore if already done). Follow the [Web Server Integration Documentation](https://imagekit.io/docs/integration/web-server) for detailed steps.
+2. **Enable Integration**: Toggle **Enable Plugin** to ON to activate ImageKit integration for media handling. When OFF, Strapi will use the default provider for uploads.
+3. **Enable Transformations**: Toggle **Use Transform URLs** to ON to leverage ImageKit's real-time transformations, generating responsive URLs with automatic format detection and image optimization capabilities. When OFF, original images are served without transformations.
+4. **Configure Secure Access** (recommended):
+ - Enable **Use Signed URLs**
+ - Set an appropriate **Expiry** time (0 for URLs that never expire, or a duration in seconds)
+
+#### 3. Configure Upload Options
+
+Decide how uploads should work:
+
+1. **Enable Uploads**: Toggle this option ON to upload the files uploaded in Strapi to your ImageKit media library. When OFF, files will be uploaded to the default Strapi storage location. Enabling this option does not upload existing files in Strapi to ImageKit.
+2. **Set Upload Properties**:
+ - **Upload Folder**: Specify a base directory path in ImageKit for organizing your uploads (e.g., `/strapi-uploads/`)
+ - **Tags**: Add comma-separated tags to categorize and filter media assets (e.g., `strapi,media`)
+ - **Overwrite Tags**: Choose whether to replace existing tags or append new ones
+3. **Configure Security & Validation**:
+ - **File Checks**: Define validation rules for uploads such as size limits or allowed file types. See [Upload API Checks](https://imagekit.io/docs/api-reference/upload-file/upload-file) for available options.
+ - **Mark as Private**: Toggle ON to restrict public access to uploaded files (requires signed URLs to access)
+
+#### 4. Save Your Configuration
+
+Click the **Save** button in the top-right corner to apply your settings.
+
+> **Note**: Some changes may require restarting your Strapi server to take full effect.
+
+### Advanced: Programmatic Configuration (config/plugins.js)
+
+While the primary way to configure the ImageKit plugin is through the Strapi admin settings page, you can also provide default values in your Strapi project's configuration file. This is particularly useful for setting up initial configurations in development or deployment environments.
+
+Settings defined in `config/plugins.js` serve as default values that are copied to the dashboard on the first run of your Strapi application. After this initial setup, any changes made through the admin UI will be stored in the database and will be used instead of the values in the configuration file.
+
+Follow these steps:
+
+1. Create or update your `config/plugins.js` file with ImageKit configuration:
+
+```js
+module.exports = ({ env }) => ({
+ imagekit: {
+ enabled: true,
+ config: {
+ // Basic Configuration
+ publicKey: env('IMAGEKIT_PUBLIC_KEY'),
+ privateKey: env('IMAGEKIT_PRIVATE_KEY'),
+ urlEndpoint: env('IMAGEKIT_URL_ENDPOINT'),
+
+ // Delivery Configuration
+ enabled: true,
+ useTransformUrls: true,
+ useSignedUrls: false,
+ expiry: 3600, // URL expiry time in seconds when useSignedUrls is true
+
+ // Upload Configuration
+ uploadEnabled: true,
+
+ // Upload Options
+ uploadOptions: {
+ folder: '/strapi-uploads/',
+ tags: ['strapi', 'media'],
+ overwriteTags: false,
+ checks: '', // Example: '"file.size" <= "5MB"'
+ isPrivateFile: false,
+ },
+ },
+ },
+});
+```
+
+2. Add these variables to your `.env` file:
+
+```env
+IMAGEKIT_PUBLIC_KEY=your_public_key_here
+IMAGEKIT_PRIVATE_KEY=your_private_key_here
+IMAGEKIT_URL_ENDPOINT=https://ik.imagekit.io/your_imagekit_id
+```
+
+You can, of course, add more environment variables if you choose to configure other optional settings (like `IMAGEKIT_UPLOAD_FOLDER`, `IMAGEKIT_USE_SIGNED_URLS`, etc.) through `env()` calls in your `config/plugins.js`.
+
+3. Restart your Strapi server for changes to take effect:
+
+```bash
+yarn develop
+```
+
+### Configure Security Middleware (CSP)
+
+To ensure your Strapi application can securely load assets and interact with ImageKit services, you need to update your Content Security Policy (CSP) settings. This is configured in the `strapi::security` middleware.
+
+Modify your `config/middlewares.js` file as follows. This configuration allows your Strapi admin panel and frontend (if applicable) to load images, videos, and potentially embeddable ImageKit frames, while maintaining a secure policy:
+
+```js
+// config/middlewares.js
+module.exports = [
+ {
+ name: 'strapi::security',
+ config: {
+ contentSecurityPolicy: {
+ useDefaults: true,
+ directives: {
+ 'connect-src': ["'self'", 'https:'],
+ 'img-src': [
+ "'self'",
+ 'data:',
+ 'blob:',
+ 'ik.imagekit.io', // Add ImageKit domain for images
+ // Add your custom domain if you use one with ImageKit:
+ // 'images.yourdomain.com',
+ ],
+ 'media-src': [
+ "'self'",
+ 'data:',
+ 'blob:',
+ 'ik.imagekit.io', // Add ImageKit domain for videos/audio
+ // Add your custom domain if you use one:
+ // 'media.yourdomain.com',
+ ],
+ 'frame-src': [
+ "'self'",
+ 'data:',
+ 'blob:',
+ 'eml.imagekit.io', // For ImageKit UI components
+ ],
+ upgradeInsecureRequests: null,
+ },
+ },
+ },
+ },
+ // Keep your other middleware entries here
+];
+```
+
+> **Important**: If you use a custom domain with ImageKit, uncomment and update the relevant lines with your domain.
+
+## Contributing
+
+Contributions are welcome! Please read our [contributing guidelines](CONTRIBUTING.md) before submitting pull requests.
+
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+## Support
+
+For support, please contact [ImageKit Support](https://imagekit.io/contact/) or open an issue in the GitHub repository.
diff --git a/admin/custom.d.ts b/admin/custom.d.ts
new file mode 100644
index 0000000..f5d1b28
--- /dev/null
+++ b/admin/custom.d.ts
@@ -0,0 +1,2 @@
+declare module '@strapi/design-system/*';
+declare module '@strapi/design-system';
diff --git a/admin/src/components/Code.tsx b/admin/src/components/Code.tsx
new file mode 100644
index 0000000..3a788f6
--- /dev/null
+++ b/admin/src/components/Code.tsx
@@ -0,0 +1,17 @@
+import styled from 'styled-components';
+
+export const Code = styled.code`
+ font-weight: 500;
+ background-color: rgba(255, 255, 255, 0.2);
+ border: 1px solid #eaeaea;
+ font-size: 12px;
+ padding: 2px 4px;
+ margin: 0 1px;
+ border-radius: 4px;
+ overflow-wrap: break-word;
+
+ @media (prefers-color-scheme: dark) {
+ background-color: rgba(255, 255, 255, 0.01);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ }
+`;
diff --git a/admin/src/components/Field.tsx b/admin/src/components/Field.tsx
new file mode 100644
index 0000000..5cb8835
--- /dev/null
+++ b/admin/src/components/Field.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+
+import { Field as NativeField } from '@strapi/design-system';
+import { getTranslated, MessageInput } from '../utils/getTranslation';
+
+type FieldProps = {
+ error?: string;
+ label: MessageInput;
+ hint?: string | React.ReactNode;
+ children: React.ReactNode;
+};
+
+const getError = (error?: string) => {
+ if (!error) {
+ return '';
+ }
+
+ return getTranslated(error as MessageInput);
+};
+
+const Field = ({ error, label, hint, children }: FieldProps) => (
+
+ {getTranslated(label)}
+ {children}
+ {error && }
+ {hint && }
+
+);
+
+export default Field;
diff --git a/admin/src/components/HeaderLink.tsx b/admin/src/components/HeaderLink.tsx
new file mode 100644
index 0000000..f69208d
--- /dev/null
+++ b/admin/src/components/HeaderLink.tsx
@@ -0,0 +1,11 @@
+import styled from 'styled-components';
+
+export const HeaderLink = styled.span`
+ a {
+ gap: 6px;
+
+ span {
+ font-size: 1.6rem;
+ }
+ }
+`;
diff --git a/admin/src/components/HintLink.tsx b/admin/src/components/HintLink.tsx
new file mode 100644
index 0000000..7945791
--- /dev/null
+++ b/admin/src/components/HintLink.tsx
@@ -0,0 +1,16 @@
+import styled from 'styled-components';
+
+export const HintLink = styled.span`
+ a {
+ gap: 4px;
+
+ span {
+ font-size: 1.33rem;
+ }
+
+ svg {
+ width: 1.33rem;
+ height: 1.33rem;
+ }
+ }
+`;
diff --git a/admin/src/components/ImageKitLogo.tsx b/admin/src/components/ImageKitLogo.tsx
new file mode 100644
index 0000000..3b07eaf
--- /dev/null
+++ b/admin/src/components/ImageKitLogo.tsx
@@ -0,0 +1,30 @@
+import type { SVGProps } from 'react';
+import { Ref, forwardRef } from 'react';
+
+interface IconProps extends Omit, 'fill'> {
+ /**
+ * @default "currentColor"
+ */
+ fill?: string;
+}
+
+const ImageKitLogo = ({ fill = 'currentColor', ...props }: IconProps, ref: Ref) => {
+ return (
+
+ );
+};
+
+export default forwardRef(ImageKitLogo);
diff --git a/admin/src/components/Initializer.tsx b/admin/src/components/Initializer.tsx
new file mode 100644
index 0000000..8c955e5
--- /dev/null
+++ b/admin/src/components/Initializer.tsx
@@ -0,0 +1,18 @@
+import { useEffect, useRef } from 'react';
+import { PLUGIN_ID } from '../../../common';
+
+type InitializerProps = {
+ setPlugin: (id: string) => void;
+};
+
+const Initializer = ({ setPlugin }: InitializerProps) => {
+ const ref = useRef(setPlugin);
+
+ useEffect(() => {
+ ref.current(PLUGIN_ID);
+ }, []);
+
+ return null;
+};
+
+export { Initializer };
diff --git a/admin/src/errors/HttpError.ts b/admin/src/errors/HttpError.ts
new file mode 100644
index 0000000..d9758de
--- /dev/null
+++ b/admin/src/errors/HttpError.ts
@@ -0,0 +1,13 @@
+export type HttpErrorDetails = {
+ message: string;
+ path: string;
+};
+export class HttpError extends Error {
+ response?: T;
+
+ constructor(message: string, response?: T) {
+ super();
+ this.message = message;
+ this.response = response;
+ }
+}
diff --git a/admin/src/hooks/index.ts b/admin/src/hooks/index.ts
new file mode 100644
index 0000000..4f97ed0
--- /dev/null
+++ b/admin/src/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useHttp';
diff --git a/admin/src/hooks/useHttp.ts b/admin/src/hooks/useHttp.ts
new file mode 100644
index 0000000..3667bb3
--- /dev/null
+++ b/admin/src/hooks/useHttp.ts
@@ -0,0 +1,60 @@
+import { FetchResponse, useFetchClient } from '@strapi/admin/strapi-admin';
+import { isNil, kebabCase } from 'lodash';
+import { PLUGIN_ID } from '../../../common';
+import { HttpError } from '../errors/HttpError';
+import { removeLeadingSlash, removeTrailingSlash } from '../utils/url';
+
+type URLString = `/${string}`;
+
+export const useHTTP = () => {
+ const { get, del, put, post } = useFetchClient();
+
+ const getURL = (url: string) =>
+ [
+ removeTrailingSlash(process.env.STRAPI_ADMIN_BACKEND_URL ?? ''),
+ removeLeadingSlash(kebabCase(PLUGIN_ID)),
+ removeLeadingSlash(url),
+ ].join('/');
+
+ const isOk = (response: FetchResponse) => !isNil(response.data);
+
+ const extractError = (response: FetchResponse) => response.data.error;
+
+ return {
+ async delete(url: URLString): Promise {
+ const response = await del(getURL(url));
+ if (isOk(response)) {
+ return response.data;
+ }
+ const errorResponse = extractError(response);
+ return Promise.reject(new HttpError('Failed to fetch', errorResponse.details));
+ },
+ async get(url: URLString): Promise {
+ const response = await get(getURL(url), {});
+ if (isOk(response)) {
+ return response.data;
+ }
+ const errorResponse = extractError(response);
+ const error = new HttpError('Failed to fetch', errorResponse.details);
+ return Promise.reject(error);
+ },
+ async post(url: URLString, body: Body): Promise {
+ const response = await post(getURL(url), body);
+ if (isOk(response)) {
+ return response.data;
+ }
+ const errorResponse = extractError(response);
+ const error = new HttpError('Failed to save/update data', errorResponse.details);
+ return Promise.reject(error);
+ },
+ async put(url: URLString, body: Body): Promise {
+ const response = await put(getURL(url), body);
+ if (isOk(response)) {
+ return response.data;
+ }
+ const errorResponse = extractError(response);
+ const error = new HttpError('Failed to save/update data', errorResponse.details);
+ return Promise.reject(error);
+ },
+ };
+};
diff --git a/admin/src/index.ts b/admin/src/index.ts
new file mode 100644
index 0000000..ecc9887
--- /dev/null
+++ b/admin/src/index.ts
@@ -0,0 +1,74 @@
+import { PLUGIN_ID, permissions } from '../../common';
+import ImageKitLogo from './components/ImageKitLogo';
+import { Initializer } from './components/Initializer';
+import trads from './translations';
+
+export default {
+ register(app: any) {
+ app.addMenuLink({
+ to: `plugins/${PLUGIN_ID}`,
+ icon: ImageKitLogo,
+ intlLabel: {
+ id: `${PLUGIN_ID}.name`,
+ defaultMessage: PLUGIN_ID,
+ },
+ position: 3,
+ permissions: [
+ { action: `plugin::${PLUGIN_ID}.${permissions.mediaLibrary.read}`, subject: null },
+ { action: 'plugin::upload.read', subject: null },
+ { action: 'plugin::upload.assets.create', subject: null },
+ { action: 'plugin::upload.assets.update', subject: null },
+ ],
+ Component: async () => {
+ const { MediaLibraryPageWrapper } = await import('./pages/MediaLibrary');
+
+ return MediaLibraryPageWrapper;
+ },
+ });
+
+ app.createSettingSection(
+ {
+ id: PLUGIN_ID,
+ intlLabel: {
+ id: `${PLUGIN_ID}.plugin.section`,
+ defaultMessage: `ImageKit plugin`,
+ },
+ },
+ [
+ {
+ id: `${PLUGIN_ID}.plugin.section.item`,
+ intlLabel: {
+ id: `${PLUGIN_ID}.plugin.section.item`,
+ defaultMessage: 'Configuration',
+ },
+ to: `/settings/${PLUGIN_ID}`,
+ Component: () =>
+ import('./pages/Settings').then((mod) => ({ default: mod.SettingsPageWrapper })),
+ },
+ ]
+ );
+ app.registerPlugin({
+ id: PLUGIN_ID,
+ initializer: Initializer,
+ isReady: false,
+ name: PLUGIN_ID,
+ });
+ },
+
+ async registerTrads({ locales }: { locales: string[] }) {
+ return Promise.all(
+ locales.map(async (locale) => {
+ if (locale in trads) {
+ const typedLocale = locale as keyof typeof trads;
+ return trads[typedLocale]().then(({ default: trad }) => {
+ return { data: trad, locale };
+ });
+ }
+ return {
+ data: {},
+ locale,
+ };
+ })
+ );
+ },
+};
diff --git a/admin/src/pages/MediaLibrary/MediaLibrary.tsx b/admin/src/pages/MediaLibrary/MediaLibrary.tsx
new file mode 100644
index 0000000..5399481
--- /dev/null
+++ b/admin/src/pages/MediaLibrary/MediaLibrary.tsx
@@ -0,0 +1,193 @@
+import { Box, Flex, Typography } from '@strapi/design-system';
+import { Page, useNotification } from '@strapi/strapi/admin';
+import {
+ ImagekitMediaLibraryWidget,
+ MediaLibraryWidgetOptions,
+} from 'imagekit-media-library-widget';
+import { camelCase } from 'lodash';
+import { useCallback, useEffect, useRef } from 'react';
+import { useIntl } from 'react-intl';
+import { PLUGIN_ID } from '../../../../common';
+import { useHTTP } from '../../hooks';
+
+const MediaLibraryPage = () => {
+ const { formatMessage } = useIntl();
+ const { toggleNotification } = useNotification();
+ const http = useHTTP();
+ const containerRef = useRef(null);
+ const mediaLibrary = useRef(null);
+
+ const handleMediaSelection = useCallback(async (payload: any) => {
+ if (!payload || !payload.data || !payload.data.length) {
+ return;
+ }
+
+ const assets = payload.data;
+ try {
+ const assetData = assets || [];
+
+ if (!assetData.length) {
+ toggleNotification({
+ type: 'warning',
+ message: formatMessage({
+ id: `${camelCase(PLUGIN_ID)}.page.mediaLibrary.notification.import.noFiles`,
+ defaultMessage: 'No files selected for import',
+ }),
+ });
+ return;
+ }
+
+ interface WebhookPayload {
+ eventType: string;
+ data: any[];
+ }
+
+ interface WebhookResponse {
+ status: 'success' | 'warning' | 'error';
+ message: string;
+ imported?: any[];
+ stats?: {
+ total: number;
+ successful: number;
+ failed: number;
+ };
+ details?: string;
+ error?: string;
+ }
+
+ const payload: WebhookPayload = {
+ eventType: 'INSERT',
+ data: assetData,
+ };
+
+ interface AxiosResponse {
+ data: T;
+ status: number;
+ statusText: string;
+ headers: Record;
+ }
+
+ const response = (await http.post('/webhook', payload)) as AxiosResponse;
+ const data = response.data;
+
+ if (data?.status === 'success') {
+ const stats = data.stats || { successful: 0, total: 0, failed: 0 };
+
+ toggleNotification({
+ type: 'success',
+ message: formatMessage(
+ {
+ id: `${camelCase(PLUGIN_ID)}.page.mediaLibrary.notification.import.success`,
+ defaultMessage: 'Successfully imported {successful} of {total} files',
+ },
+ { successful: stats.successful, total: stats.total }
+ ),
+ });
+
+ if (stats.successful > 0) {
+ }
+ } else if (data?.status === 'warning') {
+ toggleNotification({
+ type: 'warning',
+ message: formatMessage(
+ {
+ id: `${camelCase(PLUGIN_ID)}.page.mediaLibrary.notification.import.warning`,
+ defaultMessage: 'Import completed with warnings: {message}',
+ },
+ { message: data.message || 'Some files may not have been imported' }
+ ),
+ });
+ } else {
+ toggleNotification({
+ type: 'danger',
+ message: formatMessage({
+ id: `${camelCase(PLUGIN_ID)}.page.mediaLibrary.notification.import.complete`,
+ defaultMessage: 'Import process failed',
+ }),
+ });
+ }
+ } catch (error: any) {
+ console.error('Error importing from ImageKit:', error);
+
+ const errorResponse = error.response?.data;
+ const errorMessage = errorResponse?.message || error.message || 'Unknown error';
+ const errorDetails = errorResponse?.details || '';
+
+ toggleNotification({
+ type: 'warning',
+ message: formatMessage(
+ {
+ id: `${camelCase(PLUGIN_ID)}.page.mediaLibrary.notification.import.error`,
+ defaultMessage: 'Error importing files: {message} {details}',
+ },
+ {
+ message: errorMessage,
+ details: errorDetails ? `(${errorDetails})` : '',
+ }
+ ),
+ });
+ }
+ }, []);
+
+ const initializeMediaLibrary = useCallback(() => {
+ if (!mediaLibrary.current) {
+ try {
+ const config: MediaLibraryWidgetOptions = {
+ container: '#ik-media-library-container',
+ className: 'imagekit-media-library',
+ view: 'inline',
+ renderOpenButton: false,
+ mlSettings: {
+ toolbar: {
+ showCloseButton: false,
+ showInsertButton: true,
+ },
+ },
+ };
+
+ const widget = new ImagekitMediaLibraryWidget(config, handleMediaSelection);
+ mediaLibrary.current = widget;
+ } catch (error) {
+ console.error('Error initializing ImageKit Media Library:', error);
+ }
+ }
+ }, [handleMediaSelection]);
+
+ useEffect(() => {
+ if (containerRef?.current) {
+ initializeMediaLibrary();
+ }
+
+ return () => {
+ if (mediaLibrary?.current) {
+ mediaLibrary.current.destroy();
+ }
+ };
+ }, [initializeMediaLibrary]);
+
+ return (
+
+
+
+
+
+ {formatMessage({
+ id: `${camelCase(PLUGIN_ID)}.page.mediaLibrary.header.title`,
+ defaultMessage: 'ImageKit Media Library',
+ })}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default MediaLibraryPage;
diff --git a/admin/src/pages/MediaLibrary/index.tsx b/admin/src/pages/MediaLibrary/index.tsx
new file mode 100644
index 0000000..3ef429e
--- /dev/null
+++ b/admin/src/pages/MediaLibrary/index.tsx
@@ -0,0 +1,25 @@
+import { Page } from '@strapi/strapi/admin';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { pluginPermissions } from '../../../../common';
+import MediaLibraryPage from './MediaLibrary';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ retry: false,
+ },
+ },
+});
+
+const MediaLibraryPageWrapper = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+export { MediaLibraryPageWrapper };
diff --git a/admin/src/pages/Settings/Settings.tsx b/admin/src/pages/Settings/Settings.tsx
new file mode 100644
index 0000000..62b1b3f
--- /dev/null
+++ b/admin/src/pages/Settings/Settings.tsx
@@ -0,0 +1,705 @@
+import {
+ Box,
+ Button,
+ Flex,
+ Grid,
+ Link,
+ Field as NativeField,
+ NumberInput,
+ Toggle,
+ Typography,
+} from '@strapi/design-system';
+import { Check } from '@strapi/icons';
+import { Layouts, Page, useNotification, useRBAC } from '@strapi/strapi/admin';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Formik, FormikHelpers } from 'formik';
+import { camelCase, isEmpty, isNil, merge } from 'lodash';
+import { useCallback, useState } from 'react';
+import { useIntl } from 'react-intl';
+import {
+ flattenPermissions,
+ PLUGIN_ID,
+ Settings,
+ SettingsForm,
+ SettingsSchema,
+ tryCatch,
+} from '../../../../common';
+import { Code } from '../../components/Code';
+import Field from '../../components/Field';
+import { HeaderLink } from '../../components/HeaderLink';
+import { HttpError, HttpErrorDetails } from '../../errors/HttpError';
+import { useHTTP } from '../../hooks';
+import { getTranslated } from '../../utils/getTranslation';
+
+const SettingsPage = () => {
+ const { formatMessage } = useIntl();
+ const { toggleNotification } = useNotification();
+ const queryClient = useQueryClient();
+ const http = useHTTP();
+
+ const [submitInProgress, setSubmitInProgress] = useState(false);
+
+ const {
+ isLoading: isLoadingForPermissions,
+ allowedActions: { canChange },
+ } = useRBAC(flattenPermissions);
+
+ const { data, isLoading } = useQuery({
+ queryKey: [camelCase(PLUGIN_ID), 'get-settings'],
+ queryFn: () => http.get('/settings'),
+ select: (response) => ({
+ enabled: response.enabled,
+ urlEndpoint: response.urlEndpoint,
+ publicKey: response.publicKey,
+ privateKey: response.privateKey,
+ useSignedUrls: response.useSignedUrls,
+ uploadEnabled: response.uploadEnabled,
+ uploadOptions: response.uploadOptions,
+ expiry: response.expiry,
+ useTransformUrls: response.useTransformUrls,
+ }),
+ });
+
+ const saveSettings = useMutation({
+ mutationKey: [camelCase(PLUGIN_ID), 'save-settings'],
+ mutationFn: (payload: SettingsForm) => http.put('/settings', payload),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: [camelCase(PLUGIN_ID), 'get-settings'],
+ });
+ toggleNotification({
+ type: 'success',
+ message: getTranslated('page.settings.notification.save.success'),
+ });
+ },
+ onError: (error: HttpError) => {
+ if (error instanceof HttpError) {
+ const details = error.response?.map((e: { message: string }) => e.message).join('\n');
+ toggleNotification({
+ type: 'warning',
+ message: formatMessage(
+ {
+ id: `${camelCase(PLUGIN_ID)}.page.settings.notification.save.error`,
+ },
+ { details }
+ ),
+ });
+ }
+ },
+ });
+
+ const preparePayload = useCallback(
+ (values: SettingsForm) => {
+ const payload: SettingsForm = {
+ enabled: values.enabled,
+ publicKey: values.publicKey,
+ privateKey: values.privateKey,
+ urlEndpoint: values.urlEndpoint,
+ useSignedUrls: values.useSignedUrls,
+ expiry: values.expiry,
+ uploadEnabled: values.uploadEnabled,
+ uploadOptions: values.uploadOptions,
+ useTransformUrls: values.useTransformUrls,
+ };
+
+ if (values.privateKey.trim() !== data?.privateKey) {
+ payload.privateKey = values.privateKey;
+ }
+
+ return payload;
+ },
+ [data]
+ );
+
+ const onSubmitForm = async (values: SettingsForm, actions: FormikHelpers) => {
+ setSubmitInProgress(true);
+ const payload = preparePayload(values);
+
+ await tryCatch(
+ saveSettings.mutateAsync(payload).then(() => actions.resetForm({ values })),
+ () => {
+ setSubmitInProgress(false);
+ }
+ );
+ };
+
+ const boxDefaultProps = {
+ width: '100%',
+ background: 'neutral0',
+ hasRadius: true,
+ shadow: 'filterShadow',
+ padding: 6,
+ };
+
+ const initialValues: SettingsForm = {
+ enabled: false,
+ publicKey: '',
+ privateKey: '',
+ urlEndpoint: '',
+ useSignedUrls: false,
+ expiry: 0,
+ uploadEnabled: false,
+ uploadOptions: {
+ tags: [],
+ folder: '',
+ overwriteTags: false,
+ overwriteCustomMetadata: false,
+ checks: '',
+ isPrivateFile: false,
+ },
+ useTransformUrls: false,
+ };
+
+ const validate = async (values: SettingsForm) => {
+ const payload = preparePayload(values);
+ const result = SettingsSchema.safeParse({
+ ...payload,
+ privateKey: isNil(payload.privateKey) ? data?.privateKey : payload.privateKey,
+ });
+
+ if (result.success) {
+ // Maybe Validate private key
+ return;
+ }
+
+ const errors = result.error.issues.reduce(
+ (acc, issue) => {
+ acc[camelCase(issue.path.join('.'))] = issue.message;
+ return acc;
+ },
+ {} as Record
+ );
+
+ if (isEmpty(errors)) {
+ // Maybe Validate private key
+ return;
+ }
+
+ return errors;
+ };
+
+ if (isLoading || isLoadingForPermissions) {
+ return {getTranslated('page.settings.state.loading')};
+ }
+
+ const asyncActionInProgress = submitInProgress;
+
+ return (
+
+ {getTranslated('page.settings.header.title', 'Settings')}
+
+ onSubmit={onSubmitForm}
+ initialValues={merge({ ...initialValues }, data || {}) as SettingsForm}
+ validate={validate}
+ validateOnChange={false}
+ validateOnBlur={false}
+ enableReinitialize={true}
+ >
+ {({ handleSubmit, values, errors, dirty, handleChange, setValues }) => (
+
+ )}
+
+
+ );
+};
+
+export default SettingsPage;
diff --git a/admin/src/pages/Settings/index.tsx b/admin/src/pages/Settings/index.tsx
new file mode 100644
index 0000000..17c754c
--- /dev/null
+++ b/admin/src/pages/Settings/index.tsx
@@ -0,0 +1,25 @@
+import { Page } from '@strapi/strapi/admin';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { pluginPermissions } from '../../../../common';
+import SettingsPage from './Settings';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ retry: false,
+ },
+ },
+});
+
+const SettingsPageWrapper = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+export { SettingsPageWrapper };
diff --git a/admin/src/translations/en.ts b/admin/src/translations/en.ts
new file mode 100644
index 0000000..bbf2e1f
--- /dev/null
+++ b/admin/src/translations/en.ts
@@ -0,0 +1,189 @@
+const en = {
+ name: 'ImageKit',
+ page: {
+ mediaLibrary: {
+ header: {
+ title: 'ImageKit Media Library',
+ },
+ notification: {
+ import: {
+ noFiles: 'No files selected for import',
+ success: 'Successfully imported {successful} of {total} files',
+ warning: 'Import completed with warnings: {message}',
+ complete: 'Import process failed',
+ error: 'Error importing files: {message} {details}',
+ },
+ },
+ },
+ settings: {
+ state: {
+ loading: 'Loading...',
+ },
+ notification: {
+ save: {
+ success: 'Settings saved',
+ error: 'Failed to save settings: {details}',
+ },
+ admin: {
+ restore: {
+ success: 'Settings restored',
+ error: 'Failed to restore settings: {details}',
+ },
+ sync: {
+ success: 'Media Library synchronized',
+ error: 'Failed to synchronize your Media Library: {details}',
+ },
+ desync: {
+ success: 'Media Library desynchronized',
+ error: 'Failed to desynchronize your Media Library: {details}',
+ },
+ },
+ },
+ header: {
+ title: 'ImageKit.io Settings',
+ description:
+ 'This integration allows you to easily use ImageKit in Strapi. For more information about configuring the app, see our {link}',
+ link: 'documentation',
+ },
+ sections: {
+ form: {
+ base: {
+ title: 'Base configuration',
+ enabled: {
+ label: 'Deliver media using ImageKit',
+ hint: 'Optimize and deliver media assets through ImageKit',
+ },
+ urlEndpoint: {
+ label: 'ImageKit URL Endpoint',
+ hint: 'The base URL endpoint from your {link}.',
+ example: 'Example: {example}',
+ errors: {
+ format: 'Please provide a URL or path in valid format',
+ trailingSlash: 'URL should end with a trailing slash',
+ },
+ },
+ useSignedUrls: {
+ label: 'Use signed urls',
+ hint: 'Secure your media with time-bound signed URLs',
+ },
+ expiry: {
+ label: 'URL Expiry Time',
+ hint: 'Duration in seconds before signed URLs expire (0 means never expire)',
+ errors: {
+ format: 'Please provide a valid number of seconds',
+ },
+ },
+ useTransformUrls: {
+ label: 'Use ImageKit Transformations for Responsive URLs',
+ hint: 'Enable this to use ImageKit transformations when generating responsive image URLs',
+ errors: {
+ required: 'Field is required',
+ },
+ },
+ },
+ delivery: {
+ title: 'Media Delivery Options',
+ },
+ upload: {
+ title: 'Upload configuration',
+ enabled: {
+ label: 'Enable ImageKit upload',
+ hint: 'Enable ImageKit upload for Strapi Media Library',
+ },
+ publicKey: {
+ label: 'ImageKit Public Key',
+ hint: 'The public API key for your ImageKit account.',
+ example: 'Example: {example}',
+ errors: {
+ format: 'Please provide a valid Public Key and must start with "public_"',
+ required: 'Field is required',
+ },
+ },
+ privateKey: {
+ label: 'ImageKit Private Key',
+ hint: 'The private API key for your ImageKit account.',
+ example: 'Example: {example}',
+ errors: {
+ format: 'Please provide a valid Private Key and must start with "private_"',
+ required: 'Field is required',
+ },
+ },
+ },
+ uploadOptions: {
+ title: 'Upload Options',
+ description: 'Configure options that will be used when uploading files to ImageKit.',
+ tags: {
+ label: 'Tags',
+ hint: 'Comma separated list of tags to apply to the uploaded file.',
+ errors: {
+ format: 'Please provide a valid list of tags',
+ },
+ },
+ folder: {
+ label: 'Upload Folder',
+ hint: 'Base folder path in ImageKit where files will be uploaded. This will be combined with Strapi folders if they exist.',
+ errors: {
+ format: 'Please provide a valid folder path',
+ },
+ },
+ overwriteTags: {
+ label: 'Overwrite Tags',
+ hint: 'If set to true, existing tags will be overwritten.',
+ errors: {
+ format: 'Please provide a valid boolean value',
+ },
+ },
+ overwriteCustomMetadata: {
+ label: 'Overwrite Custom Metadata',
+ hint: 'If set to true, existing custom metadata will be overwritten.',
+ errors: {
+ format: 'Please provide a valid boolean value',
+ },
+ },
+ checks: {
+ label: 'Checks',
+ hint: 'Comma-separated list of the checks you want to run on the file.',
+ errors: {
+ format: 'Please provide a valid list of checks',
+ },
+ },
+ isPrivateFile: {
+ label: 'Mark files as private',
+ hint: 'If enabled, files will be marked as private in ImageKit and require signed URLs to access',
+ errors: {
+ format: 'Please provide a valid boolean value',
+ },
+ },
+ },
+ },
+ },
+ actions: {
+ save: 'Save',
+ },
+ },
+ },
+ permissions: {
+ 'media-library': {
+ read: {
+ label: 'Media Library',
+ description: 'Access the ImageKit Media Library',
+ },
+ },
+ },
+ components: {
+ confirmation: {
+ dialog: {
+ header: 'Confirmation',
+ description: 'Are you sure you want to perform this action?',
+ button: {
+ cancel: 'Cancel',
+ confirm: 'Confirm',
+ },
+ },
+ },
+ },
+};
+
+export default en;
+
+export type EN = typeof en;
diff --git a/admin/src/translations/index.ts b/admin/src/translations/index.ts
new file mode 100644
index 0000000..39141e1
--- /dev/null
+++ b/admin/src/translations/index.ts
@@ -0,0 +1,45 @@
+import { PLUGIN_ID } from '../../../common';
+import { EN } from './en';
+
+type Path = Key extends keyof T
+ ? T[Key] extends Record
+ ? T[Key] extends ArrayLike
+ ? Key | `${Key & string}.${Path> & string}`
+ : Key | `${Key & string}.${Path & string}`
+ : Key
+ : never;
+
+export type TranslationPath = Path;
+
+function flattenObject(obj: any, prefix = '') {
+ return Object.keys(obj).reduce((acc, key) => {
+ const pre = prefix.length ? `${prefix}.` : '';
+ if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
+ Object.assign(acc, flattenObject(obj[key], pre + key));
+ } else {
+ acc[pre + key] = obj[key];
+ }
+ return acc;
+ }, {} as any);
+}
+
+type TradOptions = Record;
+
+const prefixPluginTranslations = (trad: TradOptions, pluginId: string): TradOptions => {
+ if (!pluginId) {
+ throw new TypeError("pluginId can't be empty");
+ }
+ return Object.keys(trad).reduce((acc, current) => {
+ acc[`${pluginId}.${current}`] = trad[current];
+ return acc;
+ }, {} as TradOptions);
+};
+
+const trads = {
+ en: () =>
+ import('./en').then((mod) => ({
+ default: prefixPluginTranslations(flattenObject(mod.default), `${PLUGIN_ID}`),
+ })),
+};
+
+export default trads;
diff --git a/admin/src/utils/getTranslation.ts b/admin/src/utils/getTranslation.ts
new file mode 100644
index 0000000..ba5bb3f
--- /dev/null
+++ b/admin/src/utils/getTranslation.ts
@@ -0,0 +1,36 @@
+import { camelCase } from 'lodash';
+import { useIntl } from 'react-intl';
+import { PLUGIN_ID } from '../../../common';
+import { TranslationPath } from '../translations';
+
+const getTranslation = (id: string) => `${PLUGIN_ID}.${id}`;
+
+export type MessageInput = TranslationPath | MessageInputObject;
+
+type MessageInputObject = {
+ id: TranslationPath;
+ props?: {
+ [key: string]: any;
+ };
+};
+
+const getTranslated = (input: MessageInput, defaultMessage = '', inPluginScope = true) => {
+ const { formatMessage } = useIntl();
+
+ let formattedId = '';
+ if (typeof input === 'string') {
+ formattedId = input;
+ } else {
+ formattedId = input?.id.toString() || formattedId;
+ }
+
+ return formatMessage(
+ {
+ id: `${inPluginScope ? camelCase(PLUGIN_ID) : 'app.components'}.${formattedId}`,
+ defaultMessage,
+ },
+ typeof input === 'string' ? undefined : input?.props
+ );
+};
+
+export { getTranslated, getTranslation };
diff --git a/admin/src/utils/url.ts b/admin/src/utils/url.ts
new file mode 100644
index 0000000..3f0feef
--- /dev/null
+++ b/admin/src/utils/url.ts
@@ -0,0 +1,13 @@
+export function removeTrailingSlash(str: string) {
+ if (typeof str == 'string' && str[str.length - 1] == '/') {
+ str = str.substring(0, str.length - 1);
+ }
+ return str;
+}
+
+export function removeLeadingSlash(str: string) {
+ if (typeof str == 'string' && str[0] == '/') {
+ str = str.slice(1);
+ }
+ return str;
+}
diff --git a/admin/tsconfig.build.json b/admin/tsconfig.build.json
new file mode 100644
index 0000000..d033e0c
--- /dev/null
+++ b/admin/tsconfig.build.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig",
+ "include": ["./src", "./custom.d.ts"],
+ "exclude": ["**/*.test.ts", "**/*.test.tsx"],
+ "compilerOptions": {
+ "rootDir": "../",
+ "baseUrl": ".",
+ "outDir": "./dist"
+ }
+}
diff --git a/admin/tsconfig.json b/admin/tsconfig.json
new file mode 100644
index 0000000..b1e6c91
--- /dev/null
+++ b/admin/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@strapi/typescript-utils/tsconfigs/admin",
+ "include": ["./src", "./custom.d.ts", "../common/permissions.ts"],
+ "compilerOptions": {
+ "rootDir": "../",
+ "baseUrl": "."
+ }
+}
diff --git a/common/index.ts b/common/index.ts
new file mode 100644
index 0000000..f0f4324
--- /dev/null
+++ b/common/index.ts
@@ -0,0 +1,7 @@
+export * from './pluginId';
+
+export * from './permissions';
+export * from './schema';
+export * from './types';
+export * from './utils';
+export * from './validators';
diff --git a/common/permissions.ts b/common/permissions.ts
new file mode 100644
index 0000000..7976cd0
--- /dev/null
+++ b/common/permissions.ts
@@ -0,0 +1,43 @@
+import { get, isArray } from 'lodash';
+import { PLUGIN_ID } from '.';
+
+const settings = {
+ read: 'settings.read',
+ change: 'settings.change',
+};
+
+const mediaLibrary = {
+ read: 'media-library.read',
+};
+
+type Settings = typeof settings;
+type MediaLibrary = typeof mediaLibrary;
+
+type PermissionUid = Settings[keyof Settings] | MediaLibrary[keyof MediaLibrary];
+
+const render = (uid: PermissionUid) => `plugin::${PLUGIN_ID}.${uid}`;
+
+export const permissions = {
+ render,
+ settings,
+ mediaLibrary,
+};
+
+export type Permissions = typeof permissions;
+
+export const pluginPermissions = {
+ settings: [{ action: permissions.render(permissions.settings.read), subject: null }],
+ settingsChange: [{ action: permissions.render(permissions.settings.change), subject: null }],
+ mediaLibrary: [{ action: permissions.render(permissions.mediaLibrary.read), subject: null }],
+};
+
+export const flattenPermissions = Object.keys(pluginPermissions).reduce(
+ (acc: Array, key: string) => {
+ const item = get(pluginPermissions, key);
+ if (isArray(item)) {
+ return [...acc, ...item];
+ }
+ return [...acc, item];
+ },
+ []
+);
diff --git a/common/pluginId.ts b/common/pluginId.ts
new file mode 100644
index 0000000..4f30720
--- /dev/null
+++ b/common/pluginId.ts
@@ -0,0 +1 @@
+export const PLUGIN_ID = 'imagekit';
diff --git a/common/schema/index.ts b/common/schema/index.ts
new file mode 100644
index 0000000..40ed3e6
--- /dev/null
+++ b/common/schema/index.ts
@@ -0,0 +1 @@
+export * from './settings.schema';
diff --git a/common/schema/settings.schema.ts b/common/schema/settings.schema.ts
new file mode 100644
index 0000000..9a4d61c
--- /dev/null
+++ b/common/schema/settings.schema.ts
@@ -0,0 +1,118 @@
+import { z } from 'zod';
+import { UploadOptionsSchema } from './upload-options.schema';
+
+export const SettingsSchema = z
+ .object({
+ enabled: z.boolean({
+ message: 'page.settings.sections.form.base.enabled.errors.required',
+ }),
+ publicKey: z.union([
+ z
+ .string({
+ message: 'page.settings.sections.form.upload.publicKey.errors.required',
+ })
+ .trim()
+ .min(1, {
+ message: 'page.settings.sections.form.upload.publicKey.errors.required',
+ })
+ .regex(/^public_.+$/, {
+ message: 'page.settings.sections.form.upload.publicKey.errors.format',
+ }),
+ z.string().optional(),
+ ]),
+ privateKey: z.union([
+ z
+ .string({
+ message: 'page.settings.sections.form.upload.privateKey.errors.required',
+ })
+ .trim()
+ .min(1, {
+ message: 'page.settings.sections.form.upload.privateKey.errors.required',
+ })
+ .regex(/^private_.+$/, {
+ message: 'page.settings.sections.form.upload.privateKey.errors.format',
+ }),
+ z.string().optional(),
+ ]),
+ urlEndpoint: z.union([
+ z
+ .string({
+ message: 'page.settings.sections.form.base.urlEndpoint.errors.required',
+ })
+ .trim()
+ .min(1, {
+ message: 'page.settings.sections.form.base.urlEndpoint.errors.required',
+ })
+ .url({
+ message: 'page.settings.sections.form.base.urlEndpoint.errors.format',
+ }),
+ z.string().optional(),
+ ]),
+ useSignedUrls: z.boolean({
+ message: 'page.settings.sections.form.base.useSignedUrls.errors.required',
+ }),
+ expiry: z.union([
+ z
+ .number({
+ message: 'page.settings.sections.form.base.expiry.errors.format',
+ })
+ .nonnegative({
+ message: 'page.settings.sections.form.base.expiry.errors.format',
+ }),
+ z.number().optional(),
+ ]),
+ uploadEnabled: z.boolean({
+ message: 'page.settings.sections.form.upload.enabled.errors.required',
+ }),
+ uploadOptions: UploadOptionsSchema.optional(),
+ useTransformUrls: z.boolean({
+ message: 'page.settings.sections.form.base.useTransformUrls.errors.required',
+ }),
+ })
+ .superRefine((data, ctx) => {
+ if (data.enabled && (!data.urlEndpoint || data.urlEndpoint.trim() === '')) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'page.settings.sections.form.base.urlEndpoint.errors.required',
+ path: ['urlEndpoint'],
+ });
+ }
+
+ if (data.uploadEnabled) {
+ if (!data.publicKey || data.publicKey.trim() === '') {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'page.settings.sections.form.upload.publicKey.errors.required',
+ path: ['publicKey'],
+ });
+ } else if (!data.publicKey.match(/^public_.+$/)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'page.settings.sections.form.upload.publicKey.errors.format',
+ path: ['publicKey'],
+ });
+ }
+
+ if (!data.privateKey || data.privateKey.trim() === '') {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'page.settings.sections.form.upload.privateKey.errors.required',
+ path: ['privateKey'],
+ });
+ } else if (!data.privateKey.match(/^private_.+$/)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'page.settings.sections.form.upload.privateKey.errors.format',
+ path: ['privateKey'],
+ });
+ }
+ }
+
+ if (data.useSignedUrls && (data.expiry === undefined || data.expiry < 0)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'page.settings.sections.form.base.expiry.errors.format',
+ path: ['expiry'],
+ });
+ }
+ });
diff --git a/common/schema/upload-options.schema.ts b/common/schema/upload-options.schema.ts
new file mode 100644
index 0000000..36f7d60
--- /dev/null
+++ b/common/schema/upload-options.schema.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod';
+
+export const UploadOptionsSchema = z.object({
+ tags: z.array(z.string()).optional(),
+ folder: z.string().optional(),
+ overwriteTags: z.boolean().optional(),
+ overwriteCustomMetadata: z.boolean().optional(),
+ checks: z.string().optional(),
+ isPrivateFile: z.boolean().optional().default(false),
+});
+
+export type UploadOptionsForm = z.infer;
diff --git a/common/tsconfig.json b/common/tsconfig.json
new file mode 100644
index 0000000..d272db3
--- /dev/null
+++ b/common/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@strapi/typescript-utils/tsconfigs/server",
+ "include": ["**/*.ts", "**/*.d.ts"],
+ "compilerOptions": {
+ "rootDir": "../",
+ "baseUrl": ".",
+ "resolveJsonModule": true
+ }
+}
diff --git a/common/types/Settings.ts b/common/types/Settings.ts
new file mode 100644
index 0000000..4c55fc7
--- /dev/null
+++ b/common/types/Settings.ts
@@ -0,0 +1,4 @@
+import { z } from 'zod';
+import { SettingsSchema } from '../schema';
+
+export type Settings = z.infer;
diff --git a/common/types/SettingsForm.ts b/common/types/SettingsForm.ts
new file mode 100644
index 0000000..87c9dd1
--- /dev/null
+++ b/common/types/SettingsForm.ts
@@ -0,0 +1,13 @@
+import { UploadOptionsForm } from '../schema/upload-options.schema';
+
+export type SettingsForm = {
+ enabled: boolean;
+ publicKey: string;
+ privateKey: string;
+ urlEndpoint: string;
+ useSignedUrls: boolean;
+ expiry: number;
+ uploadEnabled: boolean;
+ uploadOptions: UploadOptionsForm;
+ useTransformUrls: boolean;
+};
diff --git a/common/types/index.ts b/common/types/index.ts
new file mode 100644
index 0000000..edee72a
--- /dev/null
+++ b/common/types/index.ts
@@ -0,0 +1,2 @@
+export * from './Settings';
+export * from './SettingsForm';
diff --git a/common/utils/index.ts b/common/utils/index.ts
new file mode 100644
index 0000000..ecd3a32
--- /dev/null
+++ b/common/utils/index.ts
@@ -0,0 +1 @@
+export * from './tryCatch';
diff --git a/common/utils/tryCatch.ts b/common/utils/tryCatch.ts
new file mode 100644
index 0000000..8f45383
--- /dev/null
+++ b/common/utils/tryCatch.ts
@@ -0,0 +1,25 @@
+type Success = {
+ data: T;
+ error: null;
+};
+
+type Failure = {
+ data: null;
+ error: E;
+};
+
+export type Result = Success | Failure;
+
+export async function tryCatch(
+ promise: Promise,
+ finallyFn?: () => void
+): Promise> {
+ try {
+ const data = await promise;
+ return { data, error: null };
+ } catch (error) {
+ return { data: null, error: error as E };
+ } finally {
+ finallyFn?.();
+ }
+}
diff --git a/common/validators/index.ts b/common/validators/index.ts
new file mode 100644
index 0000000..8ca0d64
--- /dev/null
+++ b/common/validators/index.ts
@@ -0,0 +1 @@
+export * from './settings.validator';
diff --git a/common/validators/settings.validator.ts b/common/validators/settings.validator.ts
new file mode 100644
index 0000000..322d000
--- /dev/null
+++ b/common/validators/settings.validator.ts
@@ -0,0 +1,12 @@
+import { SettingsSchema } from '../schema';
+
+export const getSettingsValidator = (payload: unknown) => {
+ const result = SettingsSchema.safeParse(payload);
+
+ if (!result.success) {
+ const reason = result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message }));
+ return Promise.reject(reason);
+ }
+
+ return Promise.resolve(result.data);
+};
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..74f9c94
--- /dev/null
+++ b/package.json
@@ -0,0 +1,104 @@
+{
+ "version": "1.0.0",
+ "keywords": [],
+ "type": "commonjs",
+ "exports": {
+ "./package.json": "./package.json",
+ "./strapi-admin": {
+ "types": "./dist/admin/src/index.d.ts",
+ "source": "./admin/src/index.ts",
+ "import": "./dist/admin/index.mjs",
+ "require": "./dist/admin/index.js",
+ "default": "./dist/admin/index.js"
+ },
+ "./strapi-server": {
+ "types": "./dist/server/src/index.d.ts",
+ "source": "./server/src/index.ts",
+ "import": "./dist/server/index.mjs",
+ "require": "./dist/server/index.js",
+ "default": "./dist/server/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "prepare": "husky install",
+ "publish:latest": "npm publish --tag latest",
+ "publish:beta": "npm publish --tag beta",
+ "build": "yarn clean && strapi-plugin build",
+ "clean": "rm -rf dist",
+ "lint": "prettier --check .",
+ "format": "prettier --write .",
+ "watch": "strapi-plugin watch",
+ "watch:link": "strapi-plugin watch:link",
+ "verify": "strapi-plugin verify",
+ "test:ts:front": "run -T tsc -p admin/tsconfig.json",
+ "test:ts:back": "run -T tsc -p server/tsconfig.json"
+ },
+ "dependencies": {
+ "@strapi/design-system": "^2.0.0-rc.23",
+ "@strapi/icons": "^2.0.0-rc.23",
+ "@tanstack/react-query": "^5.76.0",
+ "formik": "^2.4.6",
+ "imagekit": "^6.0.0",
+ "imagekit-media-library-widget": "^2.1.1",
+ "lodash": "^4.17.21",
+ "react-intl": "^7.1.11",
+ "zod": "^3.24.4"
+ },
+ "devDependencies": {
+ "@strapi/sdk-plugin": "^5.3.2",
+ "@strapi/strapi": "^5.13.0",
+ "@strapi/typescript-utils": "^5.13.0",
+ "@strapi/upload": "^5.13.0",
+ "@types/react": "^19.1.3",
+ "@types/react-dom": "^19.1.4",
+ "husky": "^9.1.7",
+ "prettier": "^3.5.3",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.30.0",
+ "styled-components": "^6.1.18",
+ "typescript": "^5.8.3"
+ },
+ "peerDependencies": {
+ "@strapi/sdk-plugin": "^5.3.2",
+ "@strapi/strapi": "^5.13.0",
+ "@strapi/upload": "^5.13.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.30.0",
+ "styled-components": "^6.1.18"
+ },
+ "strapi": {
+ "kind": "plugin",
+ "name": "imagekit",
+ "displayName": "Strapi Plugin for ImageKit.io",
+ "description": ""
+ },
+ "name": "strapi-plugin-imagekit",
+ "description": "",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+ssh://git@github.com/imagekit-developer/strapi-plugin-imagekit.git"
+ },
+ "bugs": {
+ "url": "https://github.com/imagekit-developer/strapi-plugin-imagekit/issues"
+ },
+ "homepage": "https://github.com/imagekit-developer/strapi-plugin-imagekit#readme",
+ "author": "ImageKit Developer ",
+ "contributors": [
+ {
+ "name": "Abhinav Dhiman",
+ "email": "abhinav@imagekit.io"
+ }
+ ],
+ "packageManager": "yarn@4.9.1",
+ "husky": {
+ "hooks": {
+ "pre-commit": "yarn format"
+ }
+ }
+}
diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts
new file mode 100644
index 0000000..f6ae5fa
--- /dev/null
+++ b/server/src/bootstrap.ts
@@ -0,0 +1,105 @@
+import type { Core } from '@strapi/strapi';
+import { get } from 'lodash';
+import { permissions, PLUGIN_ID } from '../../common';
+import { File } from './services/upload.service';
+import { getService } from './utils/getService';
+
+async function saveConfig(strapi: Core.Strapi) {
+ if (strapi.store) {
+ const pluginStore = strapi.store({ type: 'plugin', name: PLUGIN_ID });
+ const config = await pluginStore.get({ key: 'config' });
+ if (!config) {
+ const plugin = strapi.plugin(PLUGIN_ID);
+ const enabled = plugin.config('enabled', false);
+ const publicKey = plugin.config('publicKey', '');
+ const privateKey = plugin.config('privateKey', '');
+ const urlEndpoint = plugin.config('urlEndpoint', '');
+ const useSignedUrls = plugin.config('useSignedUrls', false);
+ const expiry = plugin.config('expiry', 0);
+ const uploadEnabled = plugin.config('uploadEnabled', false);
+ const useTransformUrls = plugin.config('useTransformUrls', false);
+ const uploadOptions = {
+ tags: plugin.config('uploadOptions.tags', []),
+ folder: plugin.config('uploadOptions.folder', ''),
+ overwriteTags: plugin.config('uploadOptions.overwriteTags', false),
+ overwriteCustomMetadata: plugin.config(
+ 'uploadOptions.overwriteCustomMetadata',
+ false
+ ),
+ checks: plugin.config('uploadOptions.checks', ''),
+ isPrivateFile: plugin.config('uploadOptions.isPrivateFile', false),
+ };
+
+ await pluginStore.set({
+ key: 'config',
+ value: {
+ enabled,
+ publicKey,
+ privateKey,
+ urlEndpoint,
+ useSignedUrls,
+ expiry,
+ uploadEnabled,
+ uploadOptions,
+ useTransformUrls,
+ },
+ });
+ }
+ }
+}
+
+async function addPermissions(strapi: Core.Strapi) {
+ const actions = [
+ {
+ section: 'plugins',
+ displayName: 'Access ImageKit Media Library',
+ uid: permissions.mediaLibrary.read,
+ pluginName: PLUGIN_ID,
+ },
+ {
+ section: 'plugins',
+ displayName: 'Settings: Read',
+ subCategory: 'settings',
+ uid: permissions.settings.read,
+ pluginName: PLUGIN_ID,
+ },
+ {
+ section: 'plugins',
+ displayName: 'Settings: Change',
+ subCategory: 'settings',
+ uid: permissions.settings.change,
+ pluginName: PLUGIN_ID,
+ },
+ ];
+
+ await strapi.admin?.services.permission.actionProvider.registerMany(actions);
+}
+
+async function registerUploadProvider(strapi: Core.Strapi) {
+ const provider = strapi.plugin('upload').provider;
+ const uploadService = getService(strapi, 'upload');
+ const settingsService = getService(strapi, 'settings');
+
+ Object.keys(uploadService).forEach((methodName) => {
+ const method = get(uploadService, methodName);
+ if (method) {
+ const originalMethod = provider[methodName];
+ provider[methodName] = async (file: File) => {
+ const settings = await settingsService.getSettings();
+ if (settings.uploadEnabled) {
+ return await method(file);
+ }
+
+ return await originalMethod(file);
+ };
+ }
+ });
+}
+
+const bootstrap = async ({ strapi }: { strapi: Core.Strapi }) => {
+ await saveConfig(strapi);
+ await addPermissions(strapi);
+ await registerUploadProvider(strapi);
+};
+
+export default bootstrap;
diff --git a/server/src/config/index.ts b/server/src/config/index.ts
new file mode 100644
index 0000000..d518507
--- /dev/null
+++ b/server/src/config/index.ts
@@ -0,0 +1,4 @@
+export default {
+ default: {},
+ validator() {},
+};
diff --git a/server/src/content-types/index.ts b/server/src/content-types/index.ts
new file mode 100644
index 0000000..ff8b4c5
--- /dev/null
+++ b/server/src/content-types/index.ts
@@ -0,0 +1 @@
+export default {};
diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts
new file mode 100644
index 0000000..29aa773
--- /dev/null
+++ b/server/src/controllers/index.ts
@@ -0,0 +1,7 @@
+import settings from './settings';
+import webhook from './webhook';
+
+export default {
+ settings,
+ webhook,
+};
diff --git a/server/src/controllers/settings.ts b/server/src/controllers/settings.ts
new file mode 100644
index 0000000..e71c213
--- /dev/null
+++ b/server/src/controllers/settings.ts
@@ -0,0 +1,43 @@
+import type { Core } from '@strapi/strapi';
+import { permissions } from '../../../common';
+import { permissionsChecker } from '../decorators';
+import { getService } from '../utils/getService';
+
+const controller = ({ strapi }: { strapi: Core.Strapi }) => {
+ const settingsService = getService(strapi, 'settings');
+
+ return permissionsChecker({
+ getSettings: {
+ permissions: [
+ permissions.render(permissions.settings.read),
+ permissions.render(permissions.settings.change),
+ ],
+ apply: async (ctx) => {
+ const settings = await settingsService.getSettings();
+
+ if (ctx.state.userAbility.cannot(permissions.render(permissions.settings.change))) {
+ return {
+ ...settings,
+ privateKey: Array.from({ length: settings.privateKey.length }, () => '*').join(''),
+ };
+ }
+
+ return settings;
+ },
+ },
+ updateSettings: {
+ permissions: [permissions.render(permissions.settings.change)],
+ apply: async (ctx) => {
+ return settingsService.updateSettings(ctx.request.body);
+ },
+ },
+ restoreConfig: {
+ permissions: [permissions.render(permissions.settings.change)],
+ apply: async (ctx) => {
+ return settingsService.restoreConfig();
+ },
+ },
+ });
+};
+
+export default controller;
diff --git a/server/src/controllers/webhook.ts b/server/src/controllers/webhook.ts
new file mode 100644
index 0000000..a4f6f96
--- /dev/null
+++ b/server/src/controllers/webhook.ts
@@ -0,0 +1,74 @@
+import { Core } from '@strapi/strapi';
+
+const webhookController = ({ strapi }: { strapi: Core.Strapi }) => ({
+ /**
+ * Handle ImageKit webhook
+ * @param {Object} ctx - The request context
+ */
+ async handleWebhook(ctx: any) {
+ try {
+ const { body } = ctx.request;
+
+ // Validate the webhook payload
+ if (!body || !body.eventType || !body.data || !Array.isArray(body.data)) {
+ return ctx.badRequest({
+ status: 'error',
+ message: 'Invalid webhook payload',
+ details: 'The webhook payload must contain eventType and an array of data items',
+ });
+ }
+
+ if (body.data.length === 0) {
+ return ctx.badRequest({
+ status: 'error',
+ message: 'No files to import',
+ details: 'The webhook payload contains an empty data array',
+ });
+ }
+
+ // Process the webhook data
+ const result = await strapi.plugin('imagekit').service('webhook').processWebhook(body);
+
+ // Check if we got any successful imports
+ if (result && result.length > 0) {
+ // Add statistics for successful imports vs attempted imports
+ return ctx.send({
+ status: 'success',
+ message: `Imported ${result.length} file(s) successfully`,
+ imported: result,
+ stats: {
+ total: body.data.length,
+ successful: result.length,
+ failed: body.data.length - result.length,
+ },
+ });
+ } else {
+ // We got a result but no files were imported
+ return ctx.send({
+ status: 'warning',
+ message: 'No files were imported',
+ imported: [],
+ stats: {
+ total: body.data.length,
+ successful: 0,
+ failed: body.data.length,
+ },
+ details: 'Files may have been skipped or failed to import',
+ });
+ }
+ } catch (error: any) {
+ strapi.log.error('[ImageKit Webhook Controller] Error handling webhook:', error);
+
+ // Return a structured error response
+ return ctx.badRequest({
+ status: 'error',
+ message: 'Failed to process webhook',
+ error: error.message,
+ details: error.details || 'An unexpected error occurred during file import',
+ stack: strapi.config.get('environment') === 'development' ? error.stack : undefined,
+ });
+ }
+ },
+});
+
+export default webhookController;
diff --git a/server/src/decorators/index.ts b/server/src/decorators/index.ts
new file mode 100644
index 0000000..f6cc7e4
--- /dev/null
+++ b/server/src/decorators/index.ts
@@ -0,0 +1 @@
+export * from './permission';
diff --git a/server/src/decorators/permission.ts b/server/src/decorators/permission.ts
new file mode 100644
index 0000000..9586cd4
--- /dev/null
+++ b/server/src/decorators/permission.ts
@@ -0,0 +1,49 @@
+import { Context, ExtendableContext } from 'koa';
+import { set } from 'lodash';
+
+export type RequestCtx = Context &
+ Omit & {
+ badRequest: (message: string, details?: any) => void;
+ state: {
+ userAbility: {
+ can: (action: string) => boolean;
+ cannot: (action: string) => boolean;
+ };
+ };
+ };
+
+type ControllerMethod = {
+ permissions: string[];
+ condition?: 'some' | 'every';
+ apply: (ctx: RequestCtx) => T;
+};
+
+type Controller = {
+ [K in keyof T]: ControllerMethod;
+};
+
+type PermissionCheckerKey = keyof T;
+type PermissionsChecker = Record<
+ PermissionCheckerKey,
+ Controller[PermissionCheckerKey]['apply']
+>;
+
+export const permissionsChecker = function (controller: Controller): PermissionsChecker {
+ return Object.keys(controller).reduce((acc, key) => {
+ const method = controller[key as PermissionCheckerKey];
+ set(acc, key, async function (ctx: RequestCtx) {
+ if (method.permissions.length === 0) {
+ return method.apply(ctx);
+ }
+ if (
+ method.permissions[method.condition || 'some']((permission) =>
+ ctx.state.userAbility.can(permission)
+ )
+ ) {
+ return method.apply(ctx);
+ }
+ return ctx.forbidden('You cannot access this resource');
+ });
+ return acc;
+ }, {} as PermissionsChecker);
+};
diff --git a/server/src/destroy.ts b/server/src/destroy.ts
new file mode 100644
index 0000000..d33d272
--- /dev/null
+++ b/server/src/destroy.ts
@@ -0,0 +1,7 @@
+import type { Core } from '@strapi/strapi';
+
+const destroy = ({ strapi }: { strapi: Core.Strapi }) => {
+ // destroy phase
+};
+
+export default destroy;
diff --git a/server/src/index.ts b/server/src/index.ts
new file mode 100644
index 0000000..56ffd2b
--- /dev/null
+++ b/server/src/index.ts
@@ -0,0 +1,45 @@
+/**
+ * Application methods
+ */
+import bootstrap from './bootstrap';
+import destroy from './destroy';
+import register from './register';
+
+/**
+ * Plugin server methods
+ */
+import config from './config';
+import contentTypes from './content-types';
+import controllers from './controllers';
+import middlewares from './middlewares';
+import policies from './policies';
+import routes from './routes';
+import services from './services';
+
+type PluginExport = {
+ register: typeof register;
+ bootstrap: typeof bootstrap;
+ destroy: typeof destroy;
+ config: typeof config;
+ controllers: typeof controllers;
+ routes: typeof routes;
+ services: typeof services;
+ contentTypes: typeof contentTypes;
+ policies: typeof policies;
+ middlewares: typeof middlewares;
+};
+
+const pluginExport: PluginExport = {
+ register,
+ bootstrap,
+ destroy,
+ config,
+ controllers,
+ routes,
+ services,
+ contentTypes,
+ policies,
+ middlewares,
+};
+
+export default pluginExport;
diff --git a/server/src/middlewares/index.ts b/server/src/middlewares/index.ts
new file mode 100644
index 0000000..ff8b4c5
--- /dev/null
+++ b/server/src/middlewares/index.ts
@@ -0,0 +1 @@
+export default {};
diff --git a/server/src/policies/index.ts b/server/src/policies/index.ts
new file mode 100644
index 0000000..ff8b4c5
--- /dev/null
+++ b/server/src/policies/index.ts
@@ -0,0 +1 @@
+export default {};
diff --git a/server/src/register.ts b/server/src/register.ts
new file mode 100644
index 0000000..8503134
--- /dev/null
+++ b/server/src/register.ts
@@ -0,0 +1,120 @@
+import type { Core } from '@strapi/strapi';
+import { async, traverseEntity } from '@strapi/utils';
+import { Data, Model } from '@strapi/utils/dist/types';
+import ImageKit from 'imagekit';
+import { curry } from 'lodash/fp';
+import { Settings } from '../../common';
+import { getService } from './utils/getService';
+
+function toImageKitUrl(src: string, settings: Settings, client: ImageKit): string {
+ const endpoint = settings.urlEndpoint;
+
+ if (!endpoint) {
+ return src;
+ }
+
+ if (src.startsWith(endpoint)) {
+ return client.url({
+ src,
+ signed: settings.useSignedUrls,
+ });
+ }
+
+ if (src.startsWith('/')) {
+ return client.url({
+ path: src,
+ signed: settings.useSignedUrls,
+ });
+ }
+
+ return src;
+}
+
+function transformFormats(
+ formats: Record,
+ url: string,
+ settings: Settings,
+ client: ImageKit
+): Record {
+ const result: Record = {};
+ for (const [name, fmt] of Object.entries(formats)) {
+ if (
+ fmt &&
+ typeof fmt === 'object' &&
+ typeof fmt.url === 'string' &&
+ typeof fmt.width === 'number' &&
+ typeof fmt.height === 'number'
+ ) {
+ const transformation = settings.useTransformUrls
+ ? [
+ {
+ width: fmt.width,
+ height: fmt.height,
+ },
+ ]
+ : [];
+
+ const endpoint = settings.urlEndpoint;
+ let path = settings.useTransformUrls ? url : fmt.url;
+ let transformedUrl = fmt.url;
+ if (path.startsWith(endpoint)) {
+ transformedUrl = client.url({
+ src: path,
+ transformation,
+ signed: settings.useSignedUrls,
+ expireSeconds: settings.expiry > 0 ? settings.expiry : undefined,
+ });
+ } else if (path.startsWith('/')) {
+ transformedUrl = client.url({
+ path,
+ transformation,
+ signed: settings.useSignedUrls,
+ expireSeconds: settings.expiry > 0 ? settings.expiry : undefined,
+ });
+ }
+
+ result[name] = { ...fmt, url: transformedUrl };
+ } else {
+ result[name] = fmt;
+ }
+ }
+ return result;
+}
+
+const convertToImageKitUrls = curry(async (schema: Model, entity: Data) => {
+ const uploadService = getService(strapi, 'upload');
+ const settingsService = getService(strapi, 'settings');
+ const settings = await settingsService.getSettings();
+ const client = await uploadService.getClient();
+
+ if (!settings.enabled) {
+ return entity;
+ }
+
+ return traverseEntity(
+ (args, functions) => {
+ const { schema, key, attribute, path, value, data } = args;
+ const { set } = functions;
+ if (attribute?.type === 'string' && key === 'url' && schema.uid === 'plugin::upload.file') {
+ set(key, toImageKitUrl(value as string, settings, client) as any);
+ }
+ if (attribute?.type === 'json' && key === 'formats' && schema.uid === 'plugin::upload.file') {
+ set(
+ key,
+ transformFormats(value as Record, data.url as string, settings, client)
+ );
+ }
+ },
+ { schema, getModel: strapi.getModel.bind(strapi) },
+ entity
+ );
+});
+
+export default async ({ strapi }: { strapi: Core.Strapi }) => {
+ strapi.sanitizers.add(
+ 'content-api.output',
+ curry((schema: Model, entity: Data) => {
+ return async.pipe(convertToImageKitUrls(schema))(entity);
+ })
+ );
+};
diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts
new file mode 100644
index 0000000..839c83e
--- /dev/null
+++ b/server/src/routes/index.ts
@@ -0,0 +1,35 @@
+export default [
+ {
+ method: 'GET',
+ path: '/settings',
+ handler: 'settings.getSettings',
+ config: {
+ policies: [],
+ },
+ },
+ {
+ method: 'PUT',
+ path: '/settings',
+ handler: 'settings.updateSettings',
+ config: {
+ policies: [],
+ },
+ },
+ {
+ method: 'PUT',
+ path: '/settings/restore',
+ handler: 'settings.restoreConfig',
+ config: {
+ policies: [],
+ },
+ },
+ {
+ method: 'POST',
+ path: '/webhook',
+ handler: 'webhook.handleWebhook',
+ config: {
+ policies: [],
+ auth: false, // Disable authentication for webhook endpoint
+ },
+ },
+];
diff --git a/server/src/services/index.ts b/server/src/services/index.ts
new file mode 100644
index 0000000..77ccd82
--- /dev/null
+++ b/server/src/services/index.ts
@@ -0,0 +1,21 @@
+import settings from './settings.service';
+import upload from './upload.service';
+import webhook from './webhook.service';
+
+export type PluginServiceType = {
+ settings: typeof settings;
+ upload: typeof upload;
+ webhook: typeof webhook;
+};
+
+const pluginService: PluginServiceType = {
+ settings,
+ upload,
+ webhook,
+};
+
+export type PluginServices = {
+ [key in keyof typeof pluginService]: ReturnType<(typeof pluginService)[key]>;
+};
+
+export default pluginService;
diff --git a/server/src/services/settings.service.ts b/server/src/services/settings.service.ts
new file mode 100644
index 0000000..b6afba5
--- /dev/null
+++ b/server/src/services/settings.service.ts
@@ -0,0 +1,81 @@
+import { Core } from '@strapi/strapi';
+import { PLUGIN_ID, Settings } from '../../../common';
+import { clearImageKitClient } from './upload.service';
+
+const settingsService = ({ strapi }: { strapi: Core.Strapi }) => {
+ function getPluginStore() {
+ if (strapi.store) {
+ return strapi.store({ type: 'plugin', name: PLUGIN_ID });
+ }
+ return {
+ get: async () => {
+ return {
+ enabled: false,
+ publicKey: '',
+ privateKey: '',
+ urlEndpoint: '',
+ useSignedUrls: false,
+ uploadEnabled: false,
+ expiry: 0,
+ uploadOptions: {
+ tags: [],
+ folder: '',
+ overwriteTags: false,
+ overwriteCustomMetadata: false,
+ checks: '',
+ isPrivateFile: false,
+ },
+ useTransformUrls: false,
+ };
+ },
+ set: async () => {},
+ delete: async () => {},
+ };
+ }
+
+ async function getSettings(): Promise {
+ const pluginStore = getPluginStore();
+ const config = (await pluginStore.get({ key: 'config' })) as Settings;
+ return config;
+ }
+
+ async function updateSettings(settings: Settings): Promise {
+ const pluginStore = getPluginStore();
+ await pluginStore.set({ key: 'config', value: settings });
+ clearImageKitClient();
+ return await getSettings();
+ }
+
+ function getLocalConfig(): Settings {
+ const plugin = strapi.plugin(PLUGIN_ID);
+ return {
+ enabled: plugin.config('enabled', false),
+ publicKey: plugin.config('publicKey', ''),
+ privateKey: plugin.config('privateKey', ''),
+ urlEndpoint: plugin.config('urlEndpoint', ''),
+ useSignedUrls: plugin.config('useSignedUrls', false),
+ uploadEnabled: plugin.config('uploadEnabled', false),
+ expiry: plugin.config('expiry', 0),
+ uploadOptions: {
+ tags: plugin.config('uploadOptions.tags', []),
+ folder: plugin.config('uploadOptions.folder', ''),
+ overwriteTags: plugin.config('uploadOptions.overwriteTags', false),
+ overwriteCustomMetadata: plugin.config(
+ 'uploadOptions.overwriteCustomMetadata',
+ false
+ ),
+ checks: plugin.config('uploadOptions.checks', ''),
+ isPrivateFile: plugin.config('uploadOptions.isPrivateFile', false),
+ },
+ };
+ }
+
+ async function restoreConfig(): Promise {
+ await updateSettings(getLocalConfig());
+ return await getSettings();
+ }
+
+ return { getSettings, updateSettings, restoreConfig };
+};
+
+export default settingsService;
diff --git a/server/src/services/upload.service.ts b/server/src/services/upload.service.ts
new file mode 100644
index 0000000..a32db26
--- /dev/null
+++ b/server/src/services/upload.service.ts
@@ -0,0 +1,211 @@
+import { Core } from '@strapi/strapi';
+import StrapiUploadServer from '@strapi/upload/strapi-server';
+import ImageKit from 'imagekit';
+import { UploadOptions } from 'imagekit/dist/libs/interfaces';
+import type { ReadStream } from 'node:fs';
+import { join } from 'node:path';
+import { tryCatch } from '../../../common';
+import { getService } from '../utils/getService';
+
+export type File = Parameters<
+ ReturnType['services']['provider']>['upload']
+>[0];
+
+type StrapiUploadOptions = Omit<
+ UploadOptions,
+ | 'file'
+ | 'fileName'
+ | 'responseFields'
+ | 'overwriteFile'
+ | 'isPublished'
+ | 'isPrivateFile'
+ | 'useUniqueFileName'
+> & {
+ ignoreStrapiFolders?: boolean;
+};
+
+const ValidUploadParams = [
+ 'tags',
+ 'customCoordinates',
+ 'extensions',
+ 'webhookUrl',
+ 'overwriteAITags',
+ 'overwriteTags',
+ 'overwriteCustomMetadata',
+ 'customMetadata',
+ 'transformation',
+ 'checks',
+ 'isPrivateFile',
+];
+
+export const toUploadParams = (
+ file: File,
+ uploadOptions: StrapiUploadOptions = {}
+): StrapiUploadOptions => {
+ const params = Object.entries(uploadOptions).reduce(
+ (acc, [key, value]) => {
+ if (ValidUploadParams.includes(key) && value !== undefined && value !== null) {
+ acc[key as keyof StrapiUploadOptions] = value;
+ }
+ return acc;
+ },
+ {} as Record
+ );
+
+ const ignoreStrapiFolders = uploadOptions.ignoreStrapiFolders ?? false;
+
+ if (uploadOptions.folder && !ignoreStrapiFolders && file.folderPath) {
+ params.folder = join(uploadOptions.folder, file.folderPath);
+ } else if (uploadOptions.folder) {
+ params.folder = uploadOptions.folder;
+ } else if (file.folderPath) {
+ params.folder = file.folderPath;
+ }
+
+ return params;
+};
+
+let imagekitClient: ImageKit | null = null;
+
+const uploadService = ({ strapi }: { strapi: Core.Strapi }) => {
+ const settingsService = getService(strapi, 'settings');
+
+ async function getClient() {
+ if (!imagekitClient) {
+ const { publicKey, privateKey, urlEndpoint } = await settingsService.getSettings();
+ const missingConfigs: string[] = [];
+ if (!publicKey) missingConfigs.push('publicKey');
+ if (!privateKey) missingConfigs.push('privateKey');
+ if (!urlEndpoint) missingConfigs.push('urlEndpoint');
+ if (missingConfigs.length > 0) {
+ const error = [
+ `Please remember to set up the file based config for the provider.`,
+ `Refer to the "Configuration" of the README for this plugin for additional details.`,
+ `Configs missing: ${missingConfigs.join(', ')}`,
+ ].join(' ');
+ throw new Error(`Error regarding @imagekit/strapi-provider-upload config: ${error}`);
+ }
+ imagekitClient = new ImageKit({
+ publicKey,
+ privateKey,
+ urlEndpoint,
+ });
+ }
+ return imagekitClient;
+ }
+
+ async function uploadFile(file: File, fileToUpload: Buffer | ReadStream) {
+ const client = await getClient();
+ const settings = await settingsService.getSettings();
+ const response = await tryCatch(
+ client.upload({
+ ...toUploadParams(file, settings.uploadOptions),
+ file: fileToUpload,
+ fileName: `${file.hash}${file.ext}`,
+ useUniqueFileName: false,
+ isPrivateFile: await isPrivate(),
+ })
+ );
+ if (response.error) {
+ strapi.log.error(`[ImageKit Upload Service] Error uploading file: ${response.error}`);
+ return Promise.reject(response.error);
+ }
+ const fileDetails = await tryCatch(client.getFileDetails(response.data.fileId));
+ if (fileDetails.error) {
+ strapi.log.error(
+ `[ImageKit Upload Provider] Error getting file details: ${fileDetails.error}`
+ );
+ return Promise.reject(fileDetails.error);
+ }
+ const { fileId, url } = fileDetails.data;
+ strapi.log.info(`[ImageKit Upload Provider] File uploaded successfully with id ${fileId}`);
+ file.provider = 'imagekit';
+ file.url = url;
+ file.provider_metadata = { fileId };
+ }
+
+ async function upload(file: File) {
+ await getClient();
+ let fileToUpload: Buffer | ReadStream | undefined;
+ if (file?.buffer) {
+ fileToUpload = file.buffer;
+ } else if (file?.stream) {
+ fileToUpload = file.stream as ReadStream;
+ } else {
+ return Promise.reject(new Error('[ImageKit Upload Service] Missing file buffer or stream'));
+ }
+ strapi.log.debug(
+ `[ImageKit Upload Service] File to upload: ${JSON.stringify(file)} using ${typeof fileToUpload}`
+ );
+ await uploadFile(file, fileToUpload);
+ return Promise.resolve();
+ }
+
+ async function uploadStream(file: File) {
+ await getClient();
+ return upload(file);
+ }
+
+ async function deleteFile(file: File) {
+ const fileId = file?.provider_metadata?.fileId as string;
+ strapi.log.debug(`[ImageKit Upload Service] Deleting file with id ${fileId}`);
+ if (fileId) {
+ await getClient();
+ const getFileResponse = await tryCatch(imagekitClient.getFileDetails(fileId));
+ if (getFileResponse.error) {
+ strapi.log.error(
+ `[ImageKit Upload Service] File with ID ${fileId} does not exist. Might have been deleted from ImageKit dashboard already.`
+ );
+ return Promise.resolve();
+ }
+ const deleteFileResponse = await tryCatch(imagekitClient.deleteFile(fileId));
+ if (deleteFileResponse.error) {
+ strapi.log.error(
+ `[ImageKit Upload Service] Error deleting file: ${deleteFileResponse.error}`
+ );
+ return Promise.reject(deleteFileResponse.error);
+ }
+ strapi.log.info(`[ImageKit Upload Service] File with ID ${fileId} deleted successfully.`);
+ return Promise.resolve();
+ }
+ return Promise.resolve();
+ }
+
+ async function isPrivate() {
+ const settings = await settingsService.getSettings();
+ if (settings.uploadOptions?.isPrivateFile !== undefined) {
+ return settings.uploadOptions?.isPrivateFile;
+ }
+ return false;
+ }
+
+ async function getSignedUrl(file: File) {
+ const client = await getClient();
+ try {
+ strapi.log.debug(
+ `[ImageKit Upload Service] Generating signed URL for file with ID ${file.provider_metadata?.fileId}`
+ );
+ const settings = await settingsService.getSettings();
+ const imageURL = client.url({
+ src: file.url!,
+ signed: await isPrivate(),
+ expireSeconds: settings.expiry > 0 ? settings.expiry : undefined,
+ });
+ return { url: imageURL };
+ } catch (err) {
+ strapi.log.error(
+ `[ImageKit Upload Service] Error generating signed URL for file with ID ${file.provider_metadata?.fileId}`
+ );
+ return { url: file.url };
+ }
+ }
+
+ return { upload, uploadStream, delete: deleteFile, isPrivate, getSignedUrl, getClient };
+};
+
+// Export the clearImageKitClient function to be used by other services
+export const clearImageKitClient = () => {
+ imagekitClient = null;
+};
+
+export default uploadService;
diff --git a/server/src/services/webhook.service.ts b/server/src/services/webhook.service.ts
new file mode 100644
index 0000000..270efab
--- /dev/null
+++ b/server/src/services/webhook.service.ts
@@ -0,0 +1,310 @@
+import { Core } from '@strapi/strapi';
+import { errors } from '@strapi/utils';
+import path from 'path';
+import { v4 as uuidv4 } from 'uuid';
+
+const { NotFoundError } = errors;
+const FILE_MODEL_UID = 'plugin::upload.file';
+
+// Events that will be emitted
+const MEDIA_CREATE = 'media.create';
+
+interface ImageKitFile {
+ type: string;
+ name: string;
+ fileId: string;
+ url: string;
+ thumbnail: string;
+ fileType: string;
+ filePath: string;
+ height: number;
+ width: number;
+ size: number;
+ hasAlpha: boolean;
+ mime: string;
+ customMetadata?: Record;
+ tags?: string[] | null;
+ AITags?: string[] | null;
+ isPrivateFile: boolean;
+ createdAt?: string;
+ updatedAt?: string;
+ isPublished?: boolean;
+ customCoordinates?: string | null;
+ embeddedMetadata?: Record;
+}
+
+interface ImageKitWebhookPayload {
+ eventType: string;
+ data: ImageKitFile[];
+}
+
+const webhookService = ({ strapi }: { strapi: Core.Strapi }) => {
+ /**
+ * Process the webhook payload from ImageKit
+ * @param {ImageKitWebhookPayload} payload - The webhook payload from ImageKit
+ * @returns {Promise} The processed file entities
+ */
+ async function processWebhook(payload: ImageKitWebhookPayload) {
+ const { eventType, data } = payload;
+
+ if (eventType !== 'INSERT') {
+ strapi.log.info(`[ImageKit Webhook Service] Ignoring webhook event type: ${eventType}`);
+ return [];
+ }
+
+ // First pass: Import all files and identify which are formats and which are main files
+ const importResults = await Promise.all(
+ data.map(async (fileData) => {
+ try {
+ const result = await importFileToStrapi(fileData);
+ return { fileData, result };
+ } catch (error) {
+ strapi.log.error(
+ `[ImageKit Webhook Service] Error importing file ${fileData.name}:`,
+ error
+ );
+ return null;
+ }
+ })
+ );
+
+ // Filter out errors
+ const validResults = importResults.filter(Boolean) as Array<{
+ fileData: ImageKitFile;
+ result: any;
+ }>;
+
+ // Get main files and formats separately
+ const mainFiles = validResults.filter((item) => !item.result.isFormat);
+ const formatFiles = validResults.filter((item) => item.result.isFormat);
+
+ strapi.log.info(
+ `[ImageKit Webhook Service] Imported ${mainFiles.length} main files and ` +
+ `${formatFiles.length} format files`
+ );
+
+ // Second pass: Associate formats with their parent files
+ for (const formatItem of formatFiles) {
+ const formatData = formatItem.result;
+ const formatType = formatData.format; // thumbnail, small, medium, large
+ const originalFileName = formatItem.fileData.name.replace(new RegExp(`^${formatType}_`), '');
+
+ // Find the main file that this format belongs to
+ const mainFile = mainFiles.find((m) => {
+ // Check if original name matches or has similar pattern
+ const baseName = path.basename(m.fileData.name, path.extname(m.fileData.name));
+ const formatBaseName = path.basename(originalFileName, path.extname(originalFileName));
+ return baseName === formatBaseName;
+ });
+
+ if (mainFile && mainFile.result.id) {
+ try {
+ // Get the main file's current data using direct database query instead of entityService
+ const fileEntity = await strapi.db.query(FILE_MODEL_UID).findOne({
+ where: { id: mainFile.result.id },
+ });
+
+ if (fileEntity) {
+ // Update the main file with the format information
+ const formats = fileEntity.formats || {};
+ formats[formatType] = formatData.data;
+
+ // Use direct database query to update the formats
+ await strapi.db.query(FILE_MODEL_UID).update({
+ where: { id: fileEntity.id },
+ data: { formats },
+ });
+
+ strapi.log.info(
+ `[ImageKit Webhook Service] Associated ${formatType} format with file ${fileEntity.name}`
+ );
+ }
+ } catch (error) {
+ strapi.log.error(
+ `[ImageKit Webhook Service] Error associating format ${formatType} with main file:`,
+ error
+ );
+ }
+ }
+ }
+
+ // Return only the successful main file entities
+ return mainFiles.map((item) => item.result).filter(Boolean);
+ }
+
+ /**
+ * Import a file from ImageKit to Strapi by creating a database entry
+ * @param {ImageKitFile} fileData - The file data from ImageKit
+ * @returns {Promise} The imported file entity
+ */
+ /**
+ * Import a file from ImageKit to Strapi by creating a database entry
+ * @param {ImageKitFile} fileData - The file data from ImageKit
+ * @returns {Promise} The imported file entity
+ */
+ async function importFileToStrapi(fileData: ImageKitFile) {
+ const {
+ name,
+ url,
+ mime,
+ width,
+ height,
+ size,
+ fileId,
+ customMetadata,
+ tags,
+ AITags,
+ createdAt,
+ updatedAt,
+ thumbnail,
+ } = fileData;
+
+ // Extract file extension and generate hash
+ const ext = path.extname(name);
+ const baseName = path.basename(name, ext);
+ const hash = `${baseName.replace(/\s+/g, '_')}_${fileId.substring(0, 8)}`;
+
+ const getBreakpoints = () =>
+ strapi.config.get>('plugin::upload.breakpoints', {
+ large: 1000,
+ medium: 750,
+ small: 500,
+ });
+
+ const breakpoints = getBreakpoints();
+
+ const FORMATS = Object.keys(breakpoints);
+
+ const formatMatch = name.match(new RegExp(`^(${FORMATS.join('|')})_(.+)`));
+ const isFormat = !!formatMatch;
+
+ if (isFormat) {
+ strapi.log.info(`[ImageKit Webhook Service] This file appears to be a format: ${name}`);
+ }
+
+ // Generate a document ID for the file
+ const documentId = uuidv4().replace(/-/g, '').substring(0, 24);
+
+ // Prepare metadata for the file
+ const metadata: Record = { fileId };
+
+ // Include additional metadata from ImageKit
+ if (tags && tags.length > 0) {
+ metadata.tags = tags;
+ }
+
+ if (AITags && AITags.length > 0) {
+ metadata.aiTags = AITags;
+ }
+
+ if (customMetadata && Object.keys(customMetadata).length > 0) {
+ metadata.customMetadata = customMetadata;
+ }
+
+ // Create the file entry
+ const fileEntry = {
+ name,
+ alternativeText: null,
+ caption: null,
+ width,
+ height,
+ formats: {}, // Will be populated later for the main file
+ hash,
+ ext,
+ mime,
+ size: size / 1024, // Converting bytes to KB as Strapi stores size in KB
+ url,
+ previewUrl: thumbnail || null,
+ provider: 'imagekit',
+ provider_metadata: metadata,
+ folderPath: '/',
+ isUrlSigned: false,
+ documentId: isFormat ? undefined : documentId,
+ };
+
+ // If this is not a format, save it as a main file
+ if (!isFormat) {
+ try {
+ // Use Strapi's database query directly which gives more control over the process
+ const fileEntity = await strapi.db.query(FILE_MODEL_UID).create({
+ data: {
+ ...fileEntry,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ publishedAt: new Date().toISOString(),
+ },
+ });
+
+ // Emit media create event
+ await emitEvent(MEDIA_CREATE, fileEntity);
+
+ strapi.log.info(
+ `[ImageKit Webhook Service] Successfully created file entry: ${name} with ID ${fileEntity.id}`
+ );
+ return fileEntity;
+ } catch (error) {
+ strapi.log.error(`[ImageKit Webhook Service] Error creating file entry: ${error.message}`);
+ throw error;
+ }
+ } else {
+ // This is a format, we just return the data in case we need to associate it later
+ return { isFormat: true, format: formatMatch?.[1], data: fileEntry };
+ }
+ }
+
+ /**
+ * Emit an event when a media operation occurs
+ * @param {string} event - The event name
+ * @param {object} data - The event data
+ */
+ async function emitEvent(event: string, data: Record) {
+ strapi.eventHub.emit(event, { media: data });
+ }
+
+ /**
+ * Find a file by its ID
+ * @param {string|number} id - The file ID
+ * @returns {Promise