diff --git a/.eslintignore b/.eslintignore index 7af1d7b8..0398b6d2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -50,6 +50,7 @@ package-lock.json yarn.lock # Generated +libs/ src/app/shared/generated/ firebase-export* diff --git a/.eslintrc.json b/.eslintrc.json index 620f0a02..46f02888 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,11 @@ { "root": true, - "ignorePatterns": ["projects/**/*", "src/app/shared/generated/**/*"], + "ignorePatterns": [ + "projects/**/*", + "src/app/shared/generated/**/*", + "src/app/shared/components/ui/**/*", + "src/app/shared/utils/**/*" + ], "plugins": [ "prettier" ], diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..4c41cf86 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,114 @@ +# Persona + +You are a dedicated Angular developer who thrives on leveraging the absolute latest features of the framework to build cutting-edge applications. You are currently immersed in Angular v20+, passionately adopting signals for reactive state management, embracing standalone components for streamlined architecture, and utilizing the new control flow for more intuitive template logic. Performance is paramount to you, who constantly seeks to optimize change detection and improve user experience through these modern Angular paradigms. When prompted, assume You are familiar with all the newest APIs and best practices, valuing clean, efficient, and maintainable code. + +## Examples + +These are modern examples of how to write an Angular 20 component with signals + +```ts +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; + + +@Component({ + selector: '{{tag-name}}-root', + templateUrl: '{{tag-name}}.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class {{ClassName}} { + protected readonly isServerRunning = signal(true); + toggleServerStatus() { + this.isServerRunning.update(isServerRunning => !isServerRunning); + } +} +``` + +```css +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + + button { + margin-top: 10px; + } +} +``` + +```html +
+ @if (isServerRunning()) { + Yes, the server is running + } @else { + No, the server is not running + } + +
+``` + +When you update a component, be sure to put the logic in the ts file, the styles in the css file and the html template in the html file. + +## Resources + +Here are some links to the essentials for building Angular applications. Use these to get an understanding of how some of the core functionality works +https://angular.dev/essentials/components +https://angular.dev/essentials/signals +https://angular.dev/essentials/templates +https://angular.dev/essentials/dependency-injection + +## Best practices & Style guide + +Here are the best practices and the style guide information. + +### Coding Style guide + +Here is a link to the most recent Angular style guide https://angular.dev/style-guide + +### TypeScript Best Practices + +- Use strict type checking +- Prefer type inference when the type is obvious +- Avoid the `any` type; use `unknown` when type is uncertain + +### Angular Best Practices + +- Always use standalone components over `NgModules` +- Do NOT set `standalone: true` inside the `@Component`, `@Directive` and `@Pipe` decorators +- Use signals for state management +- Implement lazy loading for feature routes +- Use `NgOptimizedImage` for all static images. +- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead + +### Components + +- Keep components small and focused on a single responsibility +- Use `input()` signal instead of decorators, learn more here https://angular.dev/guide/components/inputs +- Use `output()` function instead of decorators, learn more here https://angular.dev/guide/components/outputs +- Use `computed()` for derived state learn more about signals here https://angular.dev/guide/signals. +- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator +- Prefer inline templates for small components +- Prefer Reactive forms instead of Template-driven ones +- Do NOT use `ngClass`, use `class` bindings instead, for context: https://angular.dev/guide/templates/binding#css-class-and-style-property-bindings +- Do NOT use `ngStyle`, use `style` bindings instead, for context: https://angular.dev/guide/templates/binding#css-class-and-style-property-bindings + +### State Management + +- Use signals for local component state +- Use `computed()` for derived state +- Keep state transformations pure and predictable +- Do NOT use `mutate` on signals, use `update` or `set` instead + +### Templates + +- Keep templates simple and avoid complex logic +- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch` +- Use the async pipe to handle observables +- Use built in pipes and import pipes when being used in a template, learn more https://angular.dev/guide/templates/pipes# + +### Services + +- Design services around a single responsibility +- Use the `providedIn: 'root'` option for singleton services +- Use the `inject()` function instead of constructor injection diff --git a/.junie/guidelines.md b/.junie/guidelines.md index cbd54a09..9cb8b7f9 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -1,202 +1,47 @@ -# Localess Project Guidelines +You are an expert in TypeScript, Angular, and scalable web application development. You write maintainable, performant, and accessible code following Angular and TypeScript best practices. -This document provides essential information for developers working on the Localess project. +## TypeScript Best Practices -## Build/Configuration Instructions +- Use strict type checking +- Prefer type inference when the type is obvious +- Avoid the `any` type; use `unknown` when type is uncertain -### Project Setup +## Angular Best Practices -1. **Node.js Version**: This project requires Node.js version 20 as specified in both the root and functions package.json files. +- Always use standalone components over NgModules +- Must NOT set `standalone: true` inside Angular decorators. It's the default. +- Use signals for state management +- Implement lazy loading for feature routes +- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead +- Use `NgOptimizedImage` for all static images. + - `NgOptimizedImage` does not work for inline base64 images. -2. **Install Dependencies**: - ```bash - # Install root project dependencies - npm install - - # Install Firebase Functions dependencies - cd functions - npm install - ``` +## Components -### Building the Project +- Keep components small and focused on a single responsibility +- Use `input()` and `output()` functions instead of decorators +- Use `computed()` for derived state +- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator +- Prefer inline templates for small components +- Prefer Reactive forms instead of Template-driven ones +- Do NOT use `ngClass`, use `class` bindings instead +- Do NOT use `ngStyle`, use `style` bindings instead -The project consists of an Angular frontend and Firebase Functions backend: +## State Management -#### Frontend (Angular) +- Use signals for local component state +- Use `computed()` for derived state +- Keep state transformations pure and predictable +- Do NOT use `mutate` on signals, use `update` or `set` instead -```bash -# Development build -npm run build +## Templates -# Production build -npm run build:prod +- Keep templates simple and avoid complex logic +- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch` +- Use the async pipe to handle observables -# Docker configuration build -npm run build:docker -``` +## Services -#### Backend (Firebase Functions) - -```bash -# Build Firebase Functions -cd functions -npm run build -``` - -### Running the Project - -```bash -# Start Angular development server with proxy configuration -npm run start - -# Start Firebase emulators with data import/export -npm run emulator - -# Start Firebase emulators with debug mode -npm run emulator:debug -``` - -## Testing Information - -### Running Tests - -The project uses Karma and Jasmine for testing the Angular application: - -```bash -# Run all tests -npm run test - -# Run specific tests -npm test -- --include=path/to/test.spec.ts -``` - -### Writing Tests - -Tests follow the standard Angular testing patterns using Jasmine: - -1. **File Naming**: Test files should be named with the `.spec.ts` suffix and placed alongside the file they are testing. - -2. **Basic Test Structure**: - ```typescript - import { YourService } from './your-service'; - - describe('YourService', () => { - describe('specificMethod', () => { - it('should do something specific', () => { - expect(YourService.specificMethod('input')).toBe('expected output'); - }); - }); - }); - ``` - -### Example Test - -Here's an example of a simple utility service and its test: - -**string-utils.service.ts**: -```typescript -export class StringUtils { - /** - * Reverses a string - * @param input The string to reverse - * @returns The reversed string - */ - static reverse(input: string): string { - return input.split('').reverse().join(''); - } - - /** - * Checks if a string is a palindrome (reads the same forward and backward) - * @param input The string to check - * @returns True if the string is a palindrome, false otherwise - */ - static isPalindrome(input: string): boolean { - const normalized = input.toLowerCase().replace(/[^a-z0-9]/g, ''); - return normalized === this.reverse(normalized); - } -} -``` - -**string-utils.service.spec.ts**: -```typescript -import { StringUtils } from './string-utils.service'; - -describe('StringUtils', () => { - describe('reverse', () => { - it('should reverse a string', () => { - expect(StringUtils.reverse('hello')).toBe('olleh'); - expect(StringUtils.reverse('world')).toBe('dlrow'); - expect(StringUtils.reverse('')).toBe(''); - }); - }); - - describe('isPalindrome', () => { - it('should return true for palindromes', () => { - expect(StringUtils.isPalindrome('racecar')).toBe(true); - expect(StringUtils.isPalindrome('A man, a plan, a canal: Panama')).toBe(true); - expect(StringUtils.isPalindrome('No lemon, no melon')).toBe(true); - }); - - it('should return false for non-palindromes', () => { - expect(StringUtils.isPalindrome('hello')).toBe(false); - expect(StringUtils.isPalindrome('world')).toBe(false); - }); - - it('should handle empty strings', () => { - expect(StringUtils.isPalindrome('')).toBe(true); - }); - }); -}); -``` - -## Additional Development Information - -### Code Style - -The project uses ESLint and Prettier for code formatting and linting: - -```bash -# Lint the code -npm run lint - -# Fix linting issues -npm run lint:fix - -# Check formatting -npm run prettier - -# Fix formatting issues -npm run prettier:fix -``` - -### Angular Component Naming Conventions - -- **Component Selector Prefix**: All component selectors should use the `ll` prefix (e.g., `ll-my-component`). -- **Component Selector Style**: Component selectors should use kebab-case. -- **Directive Selector Prefix**: All directive selectors should use the `ll` prefix. -- **Directive Selector Style**: Directive selectors should use camelCase. - -### Firebase Configuration - -The project uses multiple Firebase services: - -- **Firestore**: Database for storing application data. -- **Hosting**: For hosting the Angular application. -- **Functions**: Backend services written in TypeScript. -- **Storage**: For storing files. - -Local development uses Firebase Emulators which can be started with `npm run emulator`. - -### Project Structure - -- **src/app**: Angular application code. -- **functions/src**: Firebase Functions code. -- **src/app/core**: Core functionality used throughout the application. -- **src/app/shared**: Shared components, services, and utilities. - -### Path Aliases - -The project uses TypeScript path aliases for cleaner imports: - -- `@shared/*`: Maps to `src/app/shared/*` -- `@core/*`: Maps to `src/app/core/*` +- Design services around a single responsibility +- Use the `providedIn: 'root'` option for singleton services +- Use the `inject()` function instead of constructor injection diff --git a/.postcssrc.json b/.postcssrc.json index e092dc7c..bc790554 100644 --- a/.postcssrc.json +++ b/.postcssrc.json @@ -1,5 +1,9 @@ { "plugins": { - "@tailwindcss/postcss": {} + "@tailwindcss/postcss": { + "optimize": { + "minify": true + } + } } } diff --git a/.prettierignore b/.prettierignore index e93938e5..0d6284cf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,6 +9,8 @@ node_modules dist package-lock.json +libs/ + src/app/shared/generated/ functions diff --git a/.prettierrc.json b/.prettierrc.json index 4428eac4..dbb77c45 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,4 +1,5 @@ { + "plugins": ["prettier-plugin-tailwindcss"], "tabWidth": 2, "useTabs": false, "singleQuote": true, @@ -13,9 +14,9 @@ "endOfLine": "auto", "overrides": [ { - "files": "*.component.html", + "files": "*.html", "options": { - "parser": "angular" + "parser": "html" } }, { @@ -25,9 +26,9 @@ } }, { - "files": "*.html", + "files": "*.component.html", "options": { - "parser": "html" + "parser": "angular" } } ] diff --git a/Dockerfile b/Dockerfile index a9032d77..4631042e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ WORKDIR /app COPY . . ## UI RUN npm install +RUN npm run version:generate RUN npm run build:docker ## Functions RUN npm --prefix functions install diff --git a/angular.json b/angular.json index ea6a8b3d..d44f3c60 100644 --- a/angular.json +++ b/angular.json @@ -41,7 +41,7 @@ "src/scripts" ], "styles": [ - "src/tailwind.css", + "src/styles.css", "src/styles.scss" ], "scripts": [ @@ -51,7 +51,7 @@ "browser": "src/main.ts", "stylePreprocessorOptions": { "sass": { - "silenceDeprecations": ["mixed-decls", "color-functions", "global-builtin", "import"] + "silenceDeprecations": ["color-functions", "global-builtin", "import"] } } }, @@ -102,7 +102,8 @@ "buildTarget": "localess:build:production" }, "development": { - "buildTarget": "localess:build:development" + "buildTarget": "localess:build:development", + "hmr": true } }, "defaultConfiguration": "development" @@ -119,13 +120,10 @@ "main": "src/test.ts", "polyfills": "src/polyfills.ts", "tsConfig": "tsconfig.spec.json", - "karmaConfig": "karma.conf.js", + "karmaConfig": "karma.conf.cjs", "inlineStyleLanguage": "scss", "assets": ["src/favicon.ico", "src/assets"], - "styles": [ - "src/tailwind.css", - "src/styles.scss" - ], + "styles": ["src/styles.css", "src/styles.scss"], "scripts": [] } }, diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 6f31d5b6..fbef6f5e 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -1,12 +1,46 @@ steps: + - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' + id: 'enable-apis' + entrypoint: 'bash' + allowFailure: true + args: + - '-c' + - | + gcloud services enable \ + firebasestorage.googleapis.com \ + firebaseextensions.googleapis.com \ + cloudfunctions.googleapis.com \ + cloudbuild.googleapis.com \ + artifactregistry.googleapis.com \ + run.googleapis.com \ + eventarc.googleapis.com \ + pubsub.googleapis.com \ + storage.googleapis.com \ + translate.googleapis.com \ + cloudbilling.googleapis.com \ + --project=$PROJECT_ID - name: 'node:22' id: 'install-deps' entrypoint: npm - args: ['install'] + args: [ 'install' ] - name: 'node:22' id: 'install-functions-deps' entrypoint: npm - args: ['--prefix', 'functions', 'install', '--platform=linuxmusl'] + args: [ '--prefix', 'functions', 'install', '--platform=linuxmusl' ] + - name: ghcr.io/lessify/firebase:edge + id: 'Pull Firebase Apps SDK Config' + args: [ 'apps:sdkconfig', '--project=$PROJECT_ID', 'web', '--out=src/environments/firebase-config-tmp.json' ] + - name: 'node:22' + id: 'Copy Firebase Apps SDK Config' + args: [ 'mv', '-f', 'src/environments/firebase-config-tmp.json', 'src/environments/firebase-config.json' ] + - name: 'node:22' + id: 'version-generate' + entrypoint: npm + args: + - 'run' + - 'version:generate' + env: + - 'COMMIT_SHA=$COMMIT_SHA' - name: 'node:22' id: 'build-prod' entrypoint: npm @@ -14,13 +48,6 @@ steps: - 'run' - 'build:prod' env: - - 'LOCALESS_FIREBASE_PROJECT_ID=${_LOCALESS_FIREBASE_PROJECT_ID}' - - 'LOCALESS_FIREBASE_APP_ID=${_LOCALESS_FIREBASE_APP_ID}' - - 'LOCALESS_FIREBASE_STORAGE_BUCKET=${_LOCALESS_FIREBASE_STORAGE_BUCKET}' - - 'LOCALESS_FIREBASE_API_KEY=${_LOCALESS_FIREBASE_API_KEY}' - - 'LOCALESS_FIREBASE_AUTH_DOMAIN=${_LOCALESS_FIREBASE_AUTH_DOMAIN}' - - 'LOCALESS_FIREBASE_MESSAGING_SENDER_ID=${_LOCALESS_FIREBASE_MESSAGING_SENDER_ID}' - - 'LOCALESS_FIREBASE_MEASUREMENT_ID=${_LOCALESS_FIREBASE_MEASUREMENT_ID}' - 'LOCALESS_AUTH_CUSTOM_DOMAIN=${_LOCALESS_AUTH_CUSTOM_DOMAIN}' - 'LOCALESS_AUTH_PROVIDERS=${_LOCALESS_AUTH_PROVIDERS}' - 'LOCALESS_LOGIN_MESSAGE=${_LOCALESS_LOGIN_MESSAGE}' @@ -28,9 +55,6 @@ steps: # deploy to firebase - name: ghcr.io/lessify/firebase:edge id: 'firebase-deploy' - args: ['deploy', '--project=$PROJECT_ID', '--only=hosting,functions,storage,firestore', '--force'] - - name: 'gcr.io/cloud-builders/gsutil' - id: 'gsutil-cors' - args: ['cors', 'set', 'cors.json', 'gs://${_LOCALESS_FIREBASE_STORAGE_BUCKET}'] + args: [ 'deploy', '--project=$PROJECT_ID', '--only=hosting,functions,storage,firestore', '--force' ] options: logging: CLOUD_LOGGING_ONLY diff --git a/components.json b/components.json new file mode 100644 index 00000000..d1d70049 --- /dev/null +++ b/components.json @@ -0,0 +1,13 @@ +{ + "style": "css", + "packageManager": "npm", + "tailwind": { + "css": "src/styles.css", + "baseColor": "neutral", + "cssVariables": true + }, + "componentsPath": "libs/ui", + "buildable": true, + "generateAs": "library", + "importAlias": "@spartan-ng/helm" +} diff --git a/cors.json b/cors.json deleted file mode 100644 index 79d5a710..00000000 --- a/cors.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "origin": ["*"], - "method": ["GET", "HEAD"], - "maxAgeSeconds": 3600 - } -] diff --git a/firestore.rules b/firestore.rules index a23ce861..bee52a22 100644 --- a/firestore.rules +++ b/firestore.rules @@ -72,6 +72,19 @@ service cloud.firestore { allow read: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT')); allow write: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT')); } + + // WebHooks + match /webhooks/{webhookId} { + allow read: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT')); + allow create: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT')); + allow update: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT')); + allow delete: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT')); + + // WebHook Logs + match /logs/{logId} { + allow read: if isSignedIn() && (hasRole('admin') || hasPermission('SPACE_MANAGEMENT')); + } + } } // Users diff --git a/functions/.eslintrc.cjs b/functions/.eslintrc.cjs new file mode 100644 index 00000000..c5e3586b --- /dev/null +++ b/functions/.eslintrc.cjs @@ -0,0 +1,39 @@ +module.exports = { + root: true, + env: { + es6: true, + node: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:import/errors', + 'plugin:import/warnings', + 'plugin:import/typescript', + 'google', + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + parser: '@typescript-eslint/parser', + parserOptions: { + project: ['tsconfig.json', 'tsconfig.dev.json'], + sourceType: 'module', + }, + ignorePatterns: [ + '/lib/**/*', // Ignore built files. + ], + plugins: ['@typescript-eslint', 'import'], + rules: { + quotes: ['error', 'single'], + 'import/no-unresolved': 0, + indent: ['error', 2], + 'max-len': ['error', { code: 180 }], + 'linebreak-style': 0, + 'prettier/prettier': [ + 'error', + { + endOfLine: 'auto', + }, + ], + }, +}; + diff --git a/functions/package-lock.json b/functions/package-lock.json index eb2df551..77a2abc6 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -1,29 +1,29 @@ { "name": "functions", - "version": "2.5.1", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "functions", - "version": "2.5.1", + "version": "3.0.0", "dependencies": { - "@google-cloud/translate": "^9.2.0", - "compressing": "^2.0.0", - "cors": "^2.8.5", - "deepl-node": "^1.19.1", - "exiftool-vendored": "^31.1.0", - "express": "^5.1.0", - "firebase-admin": "^13.5.0", - "firebase-functions": "^6.4.0", + "@google-cloud/translate": "^9.3.0", + "compressing": "^2.1.0", + "cors": "^2.8.6", + "deepl-node": "^1.24.0", + "exiftool-vendored": "^35.13.1", + "express": "^5.2.1", + "firebase-admin": "^13.7.0", + "firebase-functions": "^7.1.0", "fluent-ffmpeg": "^2.1.3", - "sharp": "^0.34.4", + "sharp": "^0.34.5", "uuid": "^13.0.0", - "zod": "^3.25.75" + "zod": "^4.3.6" }, "devDependencies": { - "@types/express": "^5.0.3", - "@types/fluent-ffmpeg": "^2.1.27", + "@types/express": "^5.0.6", + "@types/fluent-ffmpeg": "^2.1.28", "@types/uuid": "^11.0.0", "@typescript-eslint/eslint-plugin": "^7.9.0", "@typescript-eslint/parser": "^7.9.0", @@ -31,7 +31,7 @@ "eslint-config-google": "^0.14.0", "eslint-plugin-import": "^2.32.0", "openapi3-ts": "^4.5.0", - "typescript": "^5.8.3" + "typescript": "^5.9.3" }, "engines": { "node": "22" @@ -48,9 +48,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "license": "MIT", "optional": true, "dependencies": { @@ -516,9 +516,9 @@ } }, "node_modules/@google-cloud/storage": { - "version": "7.17.1", - "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.1.tgz", - "integrity": "sha512-2FMQbpU7qK+OtBPaegC6n+XevgZksobUGo6mGKnXNmeZpvLiAo1gTAE3oTKsrMGDV4VtL8Zzpono0YsK/Q7Iqg==", + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -528,7 +528,7 @@ "abort-controller": "^3.0.0", "async-retry": "^1.3.3", "duplexify": "^4.1.3", - "fast-xml-parser": "^4.4.1", + "fast-xml-parser": "^5.3.4", "gaxios": "^6.0.2", "google-auth-library": "^9.6.3", "html-entities": "^2.5.2", @@ -693,9 +693,9 @@ } }, "node_modules/@google-cloud/translate": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@google-cloud/translate/-/translate-9.2.0.tgz", - "integrity": "sha512-LBKoXMXsM6jyqD9RDO74E3Q8uUn9TWy7YwIrF+WS4I9erdI+VZHxmdffi4sFfQ196FeprfwMMAFa8Oy6u7G8xw==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@google-cloud/translate/-/translate-9.3.0.tgz", + "integrity": "sha512-OgZ2bCu3P0ZzMhEdYubwyCo/eFFlJMYalozmgOxlVcD51vCYelYUJeVnGlS+3cFQTJQX4RE84bYTKu7W0wqByw==", "license": "Apache-2.0", "dependencies": { "@google-cloud/common": "^6.0.0", @@ -812,9 +812,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", - "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -830,13 +830,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.3" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", - "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -852,13 +852,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.3" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", - "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -872,9 +872,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", - "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -888,9 +888,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", - "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -904,9 +904,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", - "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -920,9 +920,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", - "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], @@ -935,10 +935,26 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", - "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -952,9 +968,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", - "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -968,9 +984,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", - "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -984,9 +1000,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", - "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -1000,9 +1016,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", - "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -1018,13 +1034,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.3" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", - "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -1040,13 +1056,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.3" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", - "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ "ppc64" ], @@ -1062,13 +1078,35 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.3" + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", - "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -1084,13 +1122,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.3" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", - "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -1106,13 +1144,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.3" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", - "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -1128,13 +1166,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", - "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -1150,20 +1188,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", - "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.5.0" + "@emnapi/runtime": "^1.7.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -1173,9 +1211,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", - "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], @@ -1192,9 +1230,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", - "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -1211,9 +1249,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", - "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -1229,6 +1267,102 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -1288,11 +1422,21 @@ } }, "node_modules/@photostructure/tz-lookup": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.2.1.tgz", - "integrity": "sha512-ugPtvpdLwGQ8IWezSGFgUCYOpO/XXetfKLNv+UN2jjTYyfIDq9dA21GydGyzXuoQ06nN3VGBd3JxmEu+ZtXScg==", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.4.0.tgz", + "integrity": "sha512-yrFaDbQQZVJIzpCTnoghWO8Rttu22Hg7/JkfP3CM8UKniXYzD80cuv4UAsFkzP5Z6XWceWNsQTqUJHKyGNXzLg==", "license": "CC0-1.0" }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1409,15 +1553,15 @@ } }, "node_modules/@types/express": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", - "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "*" + "@types/serve-static": "^2" } }, "node_modules/@types/express-serve-static-core": { @@ -1434,9 +1578,9 @@ } }, "node_modules/@types/fluent-ffmpeg": { - "version": "2.1.27", - "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.27.tgz", - "integrity": "sha512-QiDWjihpUhriISNoBi2hJBRUUmoj/BMTYcfz+F+ZM9hHWBYABFAE6hjP/TbCZC0GWwlpa3FzvHH9RzFeRusZ7A==", + "version": "2.1.28", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.28.tgz", + "integrity": "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==", "dev": true, "license": "MIT", "dependencies": { @@ -1479,12 +1623,6 @@ "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", "license": "MIT" }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "license": "MIT" - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -1576,23 +1714,12 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", - "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "license": "MIT", "dependencies": { "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "license": "MIT", - "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, @@ -2174,7 +2301,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -2198,9 +2324,9 @@ "license": "MIT" }, "node_modules/batch-cluster": { - "version": "15.0.1", - "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-15.0.1.tgz", - "integrity": "sha512-eUmh0ld1AUPKTEmdzwGF9QTSexXAyt9rA1F5zDfW1wUi3okA3Tal4NLdCeFI6aiKpBenQhR6NmK9bW9tBHTGPQ==", + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-17.3.1.tgz", + "integrity": "sha512-/aWEgZKXgvEseV3WEIRyjDoFka9FTrpt5+FYCxn+giUgveGBKxWjz3cl26V3aD+1kvOBP3nmANZZfcXDmKzcAA==", "license": "MIT", "engines": { "node": ">=20" @@ -2262,42 +2388,49 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -2481,9 +2614,9 @@ } }, "node_modules/compressing": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/compressing/-/compressing-2.0.0.tgz", - "integrity": "sha512-hRG5wpuy/lkO/oO8AEhSmLw2FVJOs2DnFPtmm0XUVWoDP6k3HAw5RVgyzbbATl0ytjJDCY03DvRiyjHkSHc1Dg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/compressing/-/compressing-2.1.0.tgz", + "integrity": "sha512-CJsNw09YdOqRJ4GJxMIlshK8OEr/0e2jvygRXNf48B73KqsF94OIjcAScq6oargUrT4MZQradg6+5Z+mNYVvDQ==", "license": "MIT", "dependencies": { "@eggjs/yauzl": "^2.11.0", @@ -2551,9 +2684,9 @@ "license": "MIT" }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -2561,13 +2694,16 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2666,9 +2802,9 @@ "license": "MIT" }, "node_modules/deepl-node": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.19.1.tgz", - "integrity": "sha512-iV5AZUl+I8TOoOox3N2DvwFdHqict/egRUTmOdnfG3gwq6rwTuYWmr731MEP6JlPebiRukDbfQMGmX9jlsheJg==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.24.0.tgz", + "integrity": "sha512-vZ9jUpzJRvFamgVOfm1LDy3YYJ7k8FhxtAX9whR92EFshLIP9JlYS0HFwXL5yYsfqzXdb/wssGRSWvR48t7nSg==", "license": "MIT", "dependencies": { "@types/node": ">=12.0", @@ -2755,9 +2891,9 @@ } }, "node_modules/detect-libc": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", - "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2815,6 +2951,12 @@ "stream-shift": "^1.0.2" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3376,14 +3518,14 @@ } }, "node_modules/exiftool-vendored": { - "version": "31.1.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-31.1.0.tgz", - "integrity": "sha512-q8StxLawHLDvhqv/uoBYCfVbDskn49Cr5ouNCZhh4lgryGu1aymHwK9AvO6RcW2SbPm5MSnQDJOfGp2MW5Nnrw==", + "version": "35.13.1", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-35.13.1.tgz", + "integrity": "sha512-RiXz8RrJSBQ5jiZA1yMicmE/FgEFK/4QkU2KsqmlvTvouOOgANsNWv0f0uZbf098Ee933BE4bec5YAOBT0DuIQ==", "license": "MIT", "dependencies": { - "@photostructure/tz-lookup": "^11.2.1", + "@photostructure/tz-lookup": "^11.4.0", "@types/luxon": "^3.7.1", - "batch-cluster": "^15.0.1", + "batch-cluster": "^17.3.1", "he": "^1.2.0", "luxon": "^3.7.2" }, @@ -3391,14 +3533,14 @@ "node": ">=20.0.0" }, "optionalDependencies": { - "exiftool-vendored.exe": "13.38.0", - "exiftool-vendored.pl": "13.38.0" + "exiftool-vendored.exe": "13.52.0", + "exiftool-vendored.pl": "13.52.0" } }, "node_modules/exiftool-vendored.exe": { - "version": "13.38.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.38.0.tgz", - "integrity": "sha512-oZx5enTAvSiIAXL+OEk7nNWrfUhEdKUpaGwDjCmz4VKwOa4HbisqyM808xPGPYj8X7XikcME/fq5hvevPeE3cw==", + "version": "13.52.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.52.0.tgz", + "integrity": "sha512-8KSHKluRebjm2FL4S8rtwMLMELn/64CTI5BV3zmIdLnpS5N+aJEh6t9Y7aB7YBn5CwUao0T9/rxv4BMQqusukg==", "license": "MIT", "optional": true, "os": [ @@ -3406,9 +3548,9 @@ ] }, "node_modules/exiftool-vendored.pl": { - "version": "13.38.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.38.0.tgz", - "integrity": "sha512-Q3xl1nnwswrsR5344z4NyqvI74fKwla+VJHY1N+32gcDgt8cs9KBsDUwcNzKHSOSa/MjEfniuCJVrQiqR05iag==", + "version": "13.52.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.52.0.tgz", + "integrity": "sha512-DXsMRRNdjordn1Ckcp1h9OQJRQy9VDDOcs60H+3IP+W9zRnpSU3HqQMhAVKyHR4FzioiGDbREN9BI/M1oDNoEw==", "license": "MIT", "optional": true, "os": [ @@ -3419,18 +3561,19 @@ } }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -3525,10 +3668,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/fast-xml-parser": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", - "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz", + "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==", "funding": [ { "type": "github", @@ -3538,7 +3694,8 @@ "license": "MIT", "optional": true, "dependencies": { - "strnum": "^1.1.1" + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -3659,18 +3816,17 @@ } }, "node_modules/firebase-admin": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.5.0.tgz", - "integrity": "sha512-QZOpv1DJRJpH8NcWiL1xXE10tw3L/bdPFlgjcWrqU3ufyOJDYfxB1MMtxiVTwxK16NlybQbEM6ciSich2uWEIQ==", + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz", + "integrity": "sha512-o3qS8zCJbApe7aKzkO2Pa380t9cHISqeSd3blqYTtOuUUUua3qZTLwNWgGUOss3td6wbzrZhiHIj3c8+fC046Q==", "license": "Apache-2.0", "dependencies": { "@fastify/busboy": "^3.0.0", "@firebase/database-compat": "^2.0.0", "@firebase/database-types": "^1.0.6", - "@types/node": "^22.8.7", "farmhash-modern": "^1.1.0", "fast-deep-equal": "^3.1.1", - "google-auth-library": "^9.14.2", + "google-auth-library": "^10.6.1", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^3.1.0", "node-forge": "^1.3.1", @@ -3681,76 +3837,108 @@ }, "optionalDependencies": { "@google-cloud/firestore": "^7.11.0", - "@google-cloud/storage": "^7.14.0" + "@google-cloud/storage": "^7.19.0" } }, - "node_modules/firebase-admin/node_modules/@types/node": { - "version": "22.18.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.8.tgz", - "integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==", - "license": "MIT", + "node_modules/firebase-admin/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", "dependencies": { - "undici-types": "~6.21.0" + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" } }, "node_modules/firebase-admin/node_modules/gcp-metadata": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", - "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", "license": "Apache-2.0", "dependencies": { - "gaxios": "^6.1.1", - "google-logging-utils": "^0.0.2", + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" + } + }, + "node_modules/firebase-admin/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/firebase-admin/node_modules/google-auth-library": { - "version": "9.15.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", - "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", + "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^6.1.1", - "gcp-metadata": "^6.1.0", - "gtoken": "^7.0.0", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", "jws": "^4.0.0" }, "engines": { - "node": ">=14" - } - }, - "node_modules/firebase-admin/node_modules/google-logging-utils": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", - "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" + "node": ">=18" } }, - "node_modules/firebase-admin/node_modules/gtoken": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", - "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "node_modules/firebase-admin/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "license": "MIT", "dependencies": { - "gaxios": "^6.0.0", - "jws": "^4.0.0" + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" }, "engines": { - "node": ">=14.0.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, - "node_modules/firebase-admin/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" + "node_modules/firebase-admin/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/firebase-admin/node_modules/uuid": { "version": "11.1.0", @@ -3766,9 +3954,9 @@ } }, "node_modules/firebase-functions": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-6.4.0.tgz", - "integrity": "sha512-Q/LGhJrmJEhT0dbV60J4hCkVSeOM6/r7xJS/ccmkXzTWMjo+UPAYX9zlQmGlEjotstZ0U9GtQSJSgbB2Z+TJDg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.1.0.tgz", + "integrity": "sha512-rOE4GD8QLyC3I66sJnoADqlOFR/SUOLNOizpQxO7Vj+rPsDSxAADZPRzxBDGoxyGz4ss7PrHwVJ/+SQAdd4qwA==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.5", @@ -3781,10 +3969,24 @@ "firebase-functions": "lib/bin/firebase-functions.js" }, "engines": { - "node": ">=14.10.0" + "node": ">=18.0.0" }, "peerDependencies": { - "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0" + "@apollo/server": "^5.2.0", + "@as-integrations/express4": "^1.1.2", + "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0", + "graphql": "^16.12.0" + }, + "peerDependenciesMeta": { + "@apollo/server": { + "optional": true + }, + "@as-integrations/express4": { + "optional": true + }, + "graphql": { + "optional": true + } } }, "node_modules/firebase-functions/node_modules/@types/express": { @@ -4230,6 +4432,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", @@ -4362,6 +4580,7 @@ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", "license": "Apache-2.0", + "optional": true, "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", @@ -4382,6 +4601,7 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", + "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -4715,9 +4935,9 @@ } }, "node_modules/google-logging-utils": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.1.tgz", - "integrity": "sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -5411,6 +5631,7 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", + "optional": true, "engines": { "node": ">=8" }, @@ -5526,6 +5747,21 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jose": { "version": "4.15.9", "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", @@ -5953,7 +6189,6 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -5975,6 +6210,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6022,6 +6266,7 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", + "optional": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -6259,6 +6504,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6305,7 +6556,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6318,6 +6568,28 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/path-to-regexp": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", @@ -6493,20 +6765,40 @@ } }, "node_modules/raw-body": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", - "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.7.0", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.10" } }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/raw-body/node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", @@ -6794,9 +7086,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6897,15 +7189,15 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", - "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.0.0", - "detect-libc": "^2.1.0", - "semver": "^7.7.2" + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -6914,35 +7206,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.4", - "@img/sharp-darwin-x64": "0.34.4", - "@img/sharp-libvips-darwin-arm64": "1.2.3", - "@img/sharp-libvips-darwin-x64": "1.2.3", - "@img/sharp-libvips-linux-arm": "1.2.3", - "@img/sharp-libvips-linux-arm64": "1.2.3", - "@img/sharp-libvips-linux-ppc64": "1.2.3", - "@img/sharp-libvips-linux-s390x": "1.2.3", - "@img/sharp-libvips-linux-x64": "1.2.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", - "@img/sharp-libvips-linuxmusl-x64": "1.2.3", - "@img/sharp-linux-arm": "0.34.4", - "@img/sharp-linux-arm64": "0.34.4", - "@img/sharp-linux-ppc64": "0.34.4", - "@img/sharp-linux-s390x": "0.34.4", - "@img/sharp-linux-x64": "0.34.4", - "@img/sharp-linuxmusl-arm64": "0.34.4", - "@img/sharp-linuxmusl-x64": "0.34.4", - "@img/sharp-wasm32": "0.34.4", - "@img/sharp-win32-arm64": "0.34.4", - "@img/sharp-win32-ia32": "0.34.4", - "@img/sharp-win32-x64": "0.34.4" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6955,7 +7248,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7033,6 +7325,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -7113,6 +7417,21 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.trim": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", @@ -7184,6 +7503,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -7208,9 +7540,9 @@ } }, "node_modules/strnum": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", - "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", "funding": [ { "type": "github", @@ -7411,7 +7743,8 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/ts-api-utils": { "version": "1.4.3", @@ -7670,7 +8003,8 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "optional": true }, "node_modules/websocket-driver": { "version": "0.7.4", @@ -7700,6 +8034,7 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", + "optional": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -7709,7 +8044,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -7836,6 +8170,24 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -7929,9 +8281,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/functions/package.json b/functions/package.json index 50b37022..d9a50d62 100644 --- a/functions/package.json +++ b/functions/package.json @@ -1,6 +1,6 @@ { "name": "functions", - "version": "2.5.1", + "version": "3.0.0", "scripts": { "lint": "eslint --ext .js,.ts .", "lint:fix": "eslint --fix --ext .js,.ts .", @@ -19,22 +19,22 @@ }, "main": "lib/index.js", "dependencies": { - "@google-cloud/translate": "^9.2.0", - "compressing": "^2.0.0", - "cors": "^2.8.5", - "deepl-node": "^1.19.1", - "exiftool-vendored": "^31.1.0", - "express": "^5.1.0", - "firebase-admin": "^13.5.0", - "firebase-functions": "^6.4.0", + "@google-cloud/translate": "^9.3.0", + "compressing": "^2.1.0", + "cors": "^2.8.6", + "deepl-node": "^1.24.0", + "exiftool-vendored": "^35.13.1", + "express": "^5.2.1", + "firebase-admin": "^13.7.0", + "firebase-functions": "^7.1.0", "fluent-ffmpeg": "^2.1.3", - "sharp": "^0.34.4", + "sharp": "^0.34.5", "uuid": "^13.0.0", - "zod": "^3.25.75" + "zod": "^4.3.6" }, "devDependencies": { - "@types/express": "^5.0.3", - "@types/fluent-ffmpeg": "^2.1.27", + "@types/express": "^5.0.6", + "@types/fluent-ffmpeg": "^2.1.28", "@types/uuid": "^11.0.0", "@typescript-eslint/eslint-plugin": "^7.9.0", "@typescript-eslint/parser": "^7.9.0", @@ -42,7 +42,7 @@ "eslint-config-google": "^0.14.0", "eslint-plugin-import": "^2.32.0", "openapi3-ts": "^4.5.0", - "typescript": "^5.8.3" + "typescript": "^5.9.3" }, "private": true } diff --git a/functions/src/contents.ts b/functions/src/contents.ts index 06c8413b..84a4b20b 100644 --- a/functions/src/contents.ts +++ b/functions/src/contents.ts @@ -2,32 +2,40 @@ import { logger } from 'firebase-functions/v2'; import { HttpsError, onCall } from 'firebase-functions/v2/https'; import { onDocumentDeleted, onDocumentUpdated, onDocumentWritten, DocumentSnapshot } from 'firebase-functions/v2/firestore'; import { FieldValue, UpdateData, WithFieldValue } from 'firebase-admin/firestore'; -import { canPerform } from './utils/security-utils'; +import { AuthData, canPerform } from './utils/user-auth-utils'; import { BATCH_MAX, bucket, firestoreService } from './config'; import { Content, + ContentData, ContentDocument, ContentDocumentStorage, ContentHistory, ContentHistoryType, ContentKind, - ContentLink, PublishContentData, Schema, + SchemaComponent, + SchemaFieldKind, + SchemaType, Space, + TranslateContentLocaleData, UserPermission, + WebHookEvent, + WebHookPayload, } from './models'; import { extractContent, findAllContentsByParentSlug, findContentById, findContentsHistory, - findDocumentsToPublishByStartFullSlug, + findDocumentsToPublishByParentSlug, findSchemas, findSpaceById, spaceContentCachePath, } from './services'; -import { AuthData } from 'firebase-functions/lib/common/providers/https'; +import { translateWithGoogle } from './services/translate.service'; +import { triggerWebHooksForEvent } from './utils/webhook-utils'; +import { isSchemaFieldKindAITranslatable } from './utils/translate.utils'; // Publish const publish = onCall(async request => { @@ -46,17 +54,29 @@ const publish = onCall(async request => { if (content.kind === ContentKind.DOCUMENT) { await publishDocument(spaceId, space, contentId, content, contentSnapshot, schemas, auth); } else if (content.kind === ContentKind.FOLDER) { - const documentsSnapshot = await findDocumentsToPublishByStartFullSlug(spaceId, `${content.fullSlug}/`).get(); + const documentsSnapshot = await findDocumentsToPublishByParentSlug(spaceId, content.fullSlug).get(); for (const documentSnapshot of documentsSnapshot.docs) { const document = documentSnapshot.data() as ContentDocument; + const isAlreadyPublished = document.publishedAt ? document.publishedAt.seconds > document.updatedAt.seconds : false; + logger.info('[Content::contentPublish] check', document.fullSlug, 'isAlreadyPublished', isAlreadyPublished); // SKIP if the page was already published, by comparing publishedAt and updatedAt - if (document.publishedAt && document.publishedAt.seconds > document.updatedAt.seconds) continue; - await publishDocument(spaceId, space, contentId, document, documentSnapshot, schemas, auth); + if (isAlreadyPublished) continue; + await publishDocument(spaceId, space, documentSnapshot.id, document, documentSnapshot, schemas, auth); } } - // Save Cache - logger.info(`[Content::contentPublish] Save file to spaces/${spaceId}/contents/${contentId}/cache.json`); - await bucket.file(`spaces/${spaceId}/contents/${contentId}/cache.json`).save(''); + + // Trigger webhooks for content published event + const webhookPayload: WebHookPayload = { + event: WebHookEvent.CONTENT_PUBLISHED, + spaceId, + timestamp: new Date().toISOString(), + data: { + contentId, + content, + }, + }; + await triggerWebHooksForEvent(spaceId, webhookPayload); + return; } else { logger.info(`[Content::contentPublish] Content ${contentId} does not exist.`); @@ -83,29 +103,6 @@ async function publishDocument( schemas: Map, auth?: AuthData ) { - let aggReferences: Record | undefined; - if (document.references && document.references.length > 0) { - aggReferences = {}; - for (const refId of document.references) { - const contentSnapshot = await findContentById(spaceId, refId).get(); - const content = contentSnapshot.data() as Content; - const link: ContentLink = { - id: contentSnapshot.id, - kind: content.kind, - name: content.name, - slug: content.slug, - fullSlug: content.fullSlug, - parentSlug: content.parentSlug, - createdAt: content.createdAt.toDate().toISOString(), - updatedAt: content.updatedAt.toDate().toISOString(), - }; - if (content.kind === ContentKind.DOCUMENT) { - link.publishedAt = content.publishedAt?.toDate().toISOString(); - } - aggReferences[refId] = link; - } - } - for (const locale of space.locales) { const documentStorage: ContentDocumentStorage = { id: documentId, @@ -126,22 +123,97 @@ async function publishDocument( documentStorage.data = extractContent(document.data, schemas, locale.id); } } - if (aggReferences) { - documentStorage.links = aggReferences; + if (document.links && document.links.length > 0) { + documentStorage.links = document.links; + } + if (document.references && document.references.length > 0) { + documentStorage.references = document.references; } // Save generated JSON logger.info(`[Content::contentPublish] Save file to spaces/${spaceId}/contents/${documentId}/${locale.id}.json`); await bucket.file(`spaces/${spaceId}/contents/${documentId}/${locale.id}.json`).save(JSON.stringify(documentStorage)); - // Update publishedAt - await documentSnapshot.ref.update({ publishedAt: FieldValue.serverTimestamp() }); - const addHistory: WithFieldValue = { - type: ContentHistoryType.PUBLISHED, - name: auth?.token['name'] || FieldValue.delete(), - email: auth?.token.email || FieldValue.delete(), - createdAt: FieldValue.serverTimestamp(), + } + // Update publishedAt + await documentSnapshot.ref.update({ publishedAt: FieldValue.serverTimestamp() }); + const addHistory: WithFieldValue = { + type: ContentHistoryType.PUBLISHED, + name: auth?.token['name'] || FieldValue.delete(), + email: auth?.token.email || FieldValue.delete(), + createdAt: FieldValue.serverTimestamp(), + }; + await findContentsHistory(spaceId, documentId).add(addHistory); +} + +// Unpublish +const unpublish = onCall(async request => { + logger.info('[Content::contentUnpublish] data: ' + JSON.stringify(request.data)); + logger.info('[Content::contentUnpublish] context.auth: ' + JSON.stringify(request.auth)); + const { auth, data } = request; + if (!canPerform(UserPermission.CONTENT_PUBLISH, auth)) throw new HttpsError('permission-denied', 'permission-denied'); + const { spaceId, contentId } = data; + const spaceSnapshot = await findSpaceById(spaceId).get(); + const contentSnapshot = await findContentById(spaceId, contentId).get(); + if (spaceSnapshot.exists && contentSnapshot.exists) { + const space: Space = spaceSnapshot.data() as Space; + const content: Content = contentSnapshot.data() as Content; + if (content.kind === ContentKind.DOCUMENT) { + await unpublishDocument(spaceId, space, contentId, contentSnapshot, auth); + } else if (content.kind === ContentKind.FOLDER) { + const documentsSnapshot = await findDocumentsToPublishByParentSlug(spaceId, content.fullSlug).get(); + for (const documentSnapshot of documentsSnapshot.docs) { + const document = documentSnapshot.data() as ContentDocument; + // SKIP if the document is not published + if (!document.publishedAt) continue; + await unpublishDocument(spaceId, space, documentSnapshot.id, documentSnapshot, auth); + } + } + + // Trigger webhooks for content unpublished event + const webhookPayload: WebHookPayload = { + event: WebHookEvent.CONTENT_UNPUBLISHED, + spaceId, + timestamp: new Date().toISOString(), + data: { + contentId, + content, + }, }; - await findContentsHistory(spaceId, documentId).add(addHistory); + await triggerWebHooksForEvent(spaceId, webhookPayload); + + return; + } else { + logger.info(`[Content::contentUnpublish] Content ${contentId} does not exist.`); + throw new HttpsError('not-found', 'Content not found'); } +}); + +/** + * Unpublish Document + * @param {string} spaceId space id + * @param {Space} space + * @param {string} documentId + * @param {DocumentSnapshot} documentSnapshot + * @param {AuthData} auth + */ +async function unpublishDocument(spaceId: string, space: Space, documentId: string, documentSnapshot: DocumentSnapshot, auth?: AuthData) { + for (const locale of space.locales) { + // Delete published JSON files + logger.info(`[Content::contentUnpublish] Delete file spaces/${spaceId}/contents/${documentId}/${locale.id}.json`); + try { + await bucket.file(`spaces/${spaceId}/contents/${documentId}/${locale.id}.json`).delete(); + } catch (error) { + logger.warn(`[Content::contentUnpublish] File not found or already deleted: ${error}`); + } + } + // Clear publishedAt timestamp + await documentSnapshot.ref.update({ publishedAt: FieldValue.delete() }); + const addHistory: WithFieldValue = { + type: ContentHistoryType.UNPUBLISHED, + name: auth?.token['name'] || FieldValue.delete(), + email: auth?.token.email || FieldValue.delete(), + createdAt: FieldValue.serverTimestamp(), + }; + await findContentsHistory(spaceId, documentId).add(addHistory); } // Firestore events @@ -171,28 +243,6 @@ const onContentUpdate = onDocumentUpdated('spaces/{spaceId}/contents/{contentId} const space: Space = spaceSnapshot.data() as Space; const document: ContentDocument = contentAfter; const schemas = new Map(schemasSnapshot.docs.map(it => [it.id, it.data() as Schema])); - let aggReferences: Record | undefined; - if (document.references && document.references.length > 0) { - aggReferences = {}; - for (const refId of document.references) { - const contentSnapshot = await findContentById(spaceId, refId).get(); - const content = contentSnapshot.data() as Content; - const link: ContentLink = { - id: contentSnapshot.id, - kind: content.kind, - name: content.name, - slug: content.slug, - fullSlug: content.fullSlug, - parentSlug: content.parentSlug, - createdAt: content.createdAt.toDate().toISOString(), - updatedAt: content.updatedAt.toDate().toISOString(), - }; - if (content.kind === ContentKind.DOCUMENT) { - link.publishedAt = content.publishedAt?.toDate().toISOString(); - } - aggReferences[refId] = link; - } - } for (const locale of space.locales) { const documentStorage: ContentDocumentStorage = { id: event.data.after.id, @@ -212,16 +262,29 @@ const onContentUpdate = onDocumentUpdated('spaces/{spaceId}/contents/{contentId} documentStorage.data = extractContent(document.data, schemas, locale.id); } } - if (aggReferences) { - documentStorage.links = aggReferences; + if (document.links && document.links.length > 0) { + documentStorage.links = document.links; + } + if (document.references && document.references.length > 0) { + documentStorage.references = document.references; } // Save generated JSON logger.info(`[Content::onUpdate] Save file to spaces/${spaceId}/contents/${contentId}/draft/${locale.id}.json`); await bucket.file(`spaces/${spaceId}/contents/${contentId}/draft/${locale.id}.json`).save(JSON.stringify(documentStorage)); - // Save Cache - logger.info(`[Content::onUpdate] Save file to spaces/${spaceId}/contents/${contentId}/draft/cache.json`); - await bucket.file(`spaces/${spaceId}/contents/${contentId}/draft/cache.json`).save(''); } + + // Trigger webhooks for content saved/updated event + const webhookPayload: WebHookPayload = { + event: WebHookEvent.CONTENT_UPDATED, + spaceId, + timestamp: new Date().toISOString(), + data: { + contentId, + content: contentAfter, + previousContent: contentBefore, + }, + }; + await triggerWebHooksForEvent(spaceId, webhookPayload); } } else { // In case it is a PAGE, skip recursion as PAGE doesn't have child @@ -282,7 +345,20 @@ const onContentDelete = onDocumentDeleted('spaces/{spaceId}/contents/{contentId} const content = event.data.data() as Content; logger.info(`[Content::onDelete] eventId='${event.id}' id='${event.data.id}' fullSlug='${content.fullSlug}'`); - // Logic related to delete, in case a folder is deleted it should be cascaded to all childs + + // Trigger webhooks for content deleted event + const webhookPayload: WebHookPayload = { + event: WebHookEvent.CONTENT_DELETED, + spaceId, + timestamp: new Date().toISOString(), + data: { + contentId, + content, + }, + }; + await triggerWebHooksForEvent(spaceId, webhookPayload); + + // Logic related to delete, in case a folder is deleted it should be cascaded to all children if (content.kind === ContentKind.DOCUMENT) { await bucket.deleteFiles({ prefix: `spaces/${spaceId}/contents/${contentId}`, @@ -386,9 +462,113 @@ const onContentWrite = onDocumentWritten('spaces/{spaceId}/contents/{contentId}' return; }); +/** + * Recursively collect translatable fields that have a source value but no target translation. + * Each task carries the source value and a setter that writes the translation directly into parsedData. + * @param {ContentData} data Parsed content data node (mutated in-place via setters) + * @param {Map} schemas Schema map + * @param {string} sourceLocaleId Source locale identifier + * @param {string} targetLocaleId Target locale identifier + * @return {Array} List of { fieldName, sourceValue, setter } entries + */ +function collectTranslatableTasks( + data: ContentData, + schemas: Map, + sourceLocaleId: string | undefined, + targetLocaleId: string +): Array<{ fieldName: string; sourceValue: string; setter: (value: string) => void }> { + const tasks: Array<{ fieldName: string; sourceValue: string; setter: (value: string) => void }> = []; + const schema = schemas.get(data.schema); + if (!schema || (schema.type !== SchemaType.ROOT && schema.type !== SchemaType.NODE)) return tasks; + + for (const field of (schema as SchemaComponent).fields || []) { + if (!isSchemaFieldKindAITranslatable(field.kind)) { + continue; + } + if (field.kind === SchemaFieldKind.SCHEMA) { + const nested: ContentData | undefined = data[field.name]; + if (nested) { + tasks.push(...collectTranslatableTasks(nested, schemas, sourceLocaleId, targetLocaleId)); + } + } else if (field.kind === SchemaFieldKind.SCHEMAS) { + const items: ContentData[] | undefined = data[field.name]; + if (Array.isArray(items)) { + for (const item of items) { + tasks.push(...collectTranslatableTasks(item, schemas, sourceLocaleId, targetLocaleId)); + } + } + } else if (field.translatable) { + const targetKey = `${field.name}_i18n_${targetLocaleId}`; + const sourceValue: string | undefined = sourceLocaleId ? data[`${field.name}_i18n_${sourceLocaleId}`] : data[field.name]; + const targetValue: string | undefined = data[targetKey]; + if (sourceValue && !targetValue) { + tasks.push({ fieldName: field.name, sourceValue, setter: value => (data[targetKey] = value) }); + } + } + } + return tasks; +} + +// Translate Content Locale +const translateLocale = onCall(async request => { + logger.info('[Content::translateLocale] data: ' + JSON.stringify(request.data)); + logger.info('[Content::translateLocale] context.auth: ' + JSON.stringify(request.auth)); + const { auth, data } = request; + if (!canPerform(UserPermission.CONTENT_UPDATE, auth)) throw new HttpsError('permission-denied', 'permission-denied'); + const { spaceId, contentId, sourceLocaleId, targetLocaleId } = data; + + const [contentSnapshot, schemasSnapshot] = await Promise.all([findContentById(spaceId, contentId).get(), findSchemas(spaceId).get()]); + + if (!contentSnapshot.exists) { + logger.info(`[Content::translateLocale] Content ${contentId} does not exist.`); + throw new HttpsError('not-found', 'Content not found'); + } + + const document = contentSnapshot.data() as ContentDocument; + if (document.kind !== ContentKind.DOCUMENT || !document.data) { + logger.info(`[Content::translateLocale] Content ${contentId} has no translatable data.`); + return; + } + + const schemas = new Map(schemasSnapshot.docs.map(it => [it.id, it.data() as Schema])); + const parsedData: ContentData = typeof document.data === 'string' ? JSON.parse(document.data) : document.data; + const tasks = collectTranslatableTasks(parsedData, schemas, sourceLocaleId, targetLocaleId); + + if (tasks.length === 0) { + logger.info(`[Content::translateLocale] No translatable fields found for content ${contentId}.`); + return; + } + + // Translate all fields concurrently + const results = await Promise.all( + tasks.map(async ({ fieldName, sourceValue, setter }) => { + try { + const translatedValue = await translateWithGoogle(sourceValue, sourceLocaleId, targetLocaleId); + return { fieldName, setter, translatedValue }; + } catch (e) { + logger.error(`[Content::translateLocale] Failed to translate field '${fieldName}': ${e}`); + return { fieldName, setter, translatedValue: null }; + } + }) + ); + + // Apply translated values back into parsedData via setters, then persist as JSON string + let counter = 0; + for (const { setter, translatedValue } of results) { + if (translatedValue) { + setter(translatedValue); + counter++; + } + } + await contentSnapshot.ref.update({ data: JSON.stringify(parsedData), updatedAt: FieldValue.serverTimestamp() }); + logger.info(`[Content::translateLocale] Successfully updated ${counter} fields for content ${contentId}.`); +}); + export const content = { publish: publish, + unpublish: unpublish, onupdate: onContentUpdate, ondelete: onContentDelete, onwrite: onContentWrite, + translatelocale: translateLocale, }; diff --git a/functions/src/index.ts b/functions/src/index.ts index 1ec68c22..93a616f5 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -29,6 +29,8 @@ export { translation } from './translations'; export { user } from './users'; +export { webhook } from './webhooks'; + export { v1 as publicv1 } from './v1'; // Plugins API diff --git a/functions/src/models/asset.model.ts b/functions/src/models/asset.model.ts index b0449b88..2ef87136 100644 --- a/functions/src/models/asset.model.ts +++ b/functions/src/models/asset.model.ts @@ -44,7 +44,7 @@ export type AssetMetadata = width?: number; height?: number; orientation?: 'landscape' | 'portrait' | 'squarish'; - duration?: number; + duration?: number | string; } | { type: 'video'; @@ -52,7 +52,7 @@ export type AssetMetadata = width?: number; height?: number; orientation?: 'landscape' | 'portrait' | 'squarish'; - duration?: number; + duration?: number | string; }; // Import and Export diff --git a/functions/src/models/asset.zod.ts b/functions/src/models/asset.zod.ts index 469c499c..28e2e266 100644 --- a/functions/src/models/asset.zod.ts +++ b/functions/src/models/asset.zod.ts @@ -3,7 +3,7 @@ import { AssetKind } from './asset.model'; export const assetBaseSchema = z.object({ id: z.string(), - kind: z.nativeEnum(AssetKind), + kind: z.enum(AssetKind), name: z.string(), parentPath: z.string(), }); diff --git a/functions/src/models/content-history.model.ts b/functions/src/models/content-history.model.ts index 85fff4a1..6d53d685 100644 --- a/functions/src/models/content-history.model.ts +++ b/functions/src/models/content-history.model.ts @@ -2,12 +2,18 @@ import { Timestamp } from 'firebase-admin/firestore'; export enum ContentHistoryType { PUBLISHED = 'PUBLISHED', + UNPUBLISHED = 'UNPUBLISHED', CREATE = 'CREATE', UPDATE = 'UPDATE', DELETE = 'DELETE', } -export type ContentHistory = ContentHistoryPublish | ContentHistoryCreate | ContentHistoryUpdate | ContentHistoryDelete; +export type ContentHistory = + | ContentHistoryPublish + | ContentHistoryUnpublish + | ContentHistoryCreate + | ContentHistoryUpdate + | ContentHistoryDelete; export interface ContentHistoryBase { type: ContentHistoryType; @@ -20,6 +26,10 @@ export interface ContentHistoryPublish extends ContentHistoryBase { type: ContentHistoryType.PUBLISHED; } +export interface ContentHistoryUnpublish extends ContentHistoryBase { + type: ContentHistoryType.UNPUBLISHED; +} + export interface ContentHistoryCreate extends ContentHistoryBase { type: ContentHistoryType.CREATE; cName: string; diff --git a/functions/src/models/content.model.ts b/functions/src/models/content.model.ts index b59babab..00ef9f87 100644 --- a/functions/src/models/content.model.ts +++ b/functions/src/models/content.model.ts @@ -26,6 +26,7 @@ export interface ContentDocument extends Co data?: T | string; publishedAt?: Timestamp; assets?: string[]; + links?: string[]; references?: string[]; } @@ -45,6 +46,22 @@ export interface PublishContentData { // Storage export interface ContentDocumentStorage { + id: string; + name: string; + kind: ContentKind; + slug: string; + locale: string; + parentSlug: string; + fullSlug: string; + data?: ContentData; + createdAt: string; + updatedAt: string; + publishedAt?: string; + links?: string[]; + references?: string[]; +} + +export interface ContentDocumentApi { id: string; name: string; kind: ContentKind; @@ -57,6 +74,7 @@ export interface ContentDocumentStorage { updatedAt: string; publishedAt?: string; links?: Record; + references?: Record; } export interface ContentData extends Record { @@ -99,19 +117,6 @@ export interface RichTextContent { content?: RichTextContent[]; } -// export type RichTextContent = { -// type?: string; -// attrs?: Record; -// content?: RichTextContent[]; -// marks?: { -// type: string; -// attrs?: Record; -// [key: string]: any; -// }[]; -// text?: string; -// [key: string]: any; -// }; - // Import and Export export interface ContentFolderExport extends Omit { id: string; @@ -122,3 +127,10 @@ export interface ContentDocumentExport extends Omit { id: string; } + +export type TranslationUpdate = z.infer; + +export interface TranslateLocaleData { + spaceId: string; + sourceLocaleId: string; + targetLocaleId: string; +} diff --git a/functions/src/models/translation.zod.ts b/functions/src/models/translation.zod.ts index b6f14b91..048377fe 100644 --- a/functions/src/models/translation.zod.ts +++ b/functions/src/models/translation.zod.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; export const translationSchema = z.object({ id: z.string(), - type: z.nativeEnum(TranslationType), + type: z.enum(TranslationType), locales: z.record(z.string(), z.string()), labels: z.array(z.string()).optional(), description: z.string().optional(), @@ -14,3 +14,9 @@ export const translationSchema = z.object({ export const zTranslationExportArraySchema = z.array(translationSchema); export const zTranslationFlatExportSchema = z.record(z.string(), z.string()); + +export const zTranslationUpdateSchema = z.object({ + dryRun: z.boolean().optional(), + type: z.enum(['add-missing', 'update-existing', 'delete-missing']), + values: z.record(z.string(), z.string()), +}); diff --git a/functions/src/models/webhook.model.ts b/functions/src/models/webhook.model.ts new file mode 100644 index 00000000..bce54b3d --- /dev/null +++ b/functions/src/models/webhook.model.ts @@ -0,0 +1,46 @@ +import { Timestamp } from 'firebase-admin/firestore'; + +export interface WebHook { + name: string; + url: string; + enabled: boolean; + events: WebHookEvent[]; + headers?: Record; + secret?: string; + createdAt: Timestamp; + updatedAt: Timestamp; +} + +export enum WebHookEvent { + CONTENT_PUBLISHED = 'content.published', + CONTENT_UNPUBLISHED = 'content.unpublished', + CONTENT_DELETED = 'content.deleted', + CONTENT_UPDATED = 'content.updated', +} + +export type WebHookStatus = 'success' | 'failure'; + +export interface WebHookPayload { + event: WebHookEvent; + spaceId: string; + timestamp: string; + data: WebHookPayloadData; + signature?: string; +} + +export interface WebHookPayloadData { + contentId: string; + content: any; + previousContent?: any; +} + +export interface WebHookLog { + webhookId: string; + event: WebHookEvent; + url: string; + status: WebHookStatus; + statusCode?: number; + responseTime?: number; + errorMessage?: string; + createdAt: Timestamp; +} diff --git a/functions/src/services/asset.service.ts b/functions/src/services/asset.service.ts index e20040c4..6f549a6e 100644 --- a/functions/src/services/asset.service.ts +++ b/functions/src/services/asset.service.ts @@ -198,6 +198,11 @@ export async function updateMetadataByRef(assetRef: DocumentReference): Promise< } } } else { + if (asset.kind === AssetKind.FILE && asset.inProgress) { + await assetRef.update({ + inProgress: FieldValue.delete(), + }); + } return; } await assetRef.update(update); diff --git a/functions/src/services/content.service.ts b/functions/src/services/content.service.ts index 47b2adb2..efc0ce42 100644 --- a/functions/src/services/content.service.ts +++ b/functions/src/services/content.service.ts @@ -1,13 +1,16 @@ import { DocumentReference, Query, Timestamp } from 'firebase-admin/firestore'; import { logger } from 'firebase-functions/v2'; -import { firestoreService } from '../config'; +import { bucket, firestoreService } from '../config'; import { Content, ContentData, + ContentDocumentApi, ContentDocumentExport, + ContentDocumentStorage, ContentExport, ContentFolderExport, ContentKind, + ContentLink, Schema, SchemaFieldKind, SchemaType, @@ -71,17 +74,17 @@ export function findContentsByStartFullSlug(spaceId: string, startFullSlug: stri } /** - * find Content by Full Slug + * find Content by Parent Slug * @param {string} spaceId Space identifier - * @param {string} startFullSlug Start Full Slug path + * @param {string} parentSlug Parent Slug path * @return {DocumentReference} document reference to the space */ -export function findDocumentsToPublishByStartFullSlug(spaceId: string, startFullSlug: string): Query { - logger.info(`[findDocumentsToPublishByStartFullSlug] spaceId=${spaceId} startFullSlug=${startFullSlug}`); +export function findDocumentsToPublishByParentSlug(spaceId: string, parentSlug: string): Query { + logger.info(`[findDocumentsToPublishByParentSlug] spaceId=${spaceId} parentSlug=${parentSlug}`); return firestoreService .collection(`spaces/${spaceId}/contents`) - .where('fullSlug', '>=', startFullSlug) - .where('fullSlug', '<', `${startFullSlug}~`) + .where('parentSlug', '>=', parentSlug) + .where('parentSlug', '<', `${parentSlug}/~`) .where('kind', '==', ContentKind.DOCUMENT); } @@ -130,21 +133,6 @@ export function contentLocaleCachePath(spaceId: string, contentId: string, local } } -/** - * construct content cache path, will return url to the cache file for cache version identifier - * @param {string} spaceId - * @param {string} contentId - * @param {string} version - * @return {string} path - */ -export function contentCachePath(spaceId: string, contentId: string, version: string | 'draft' | undefined): string { - if (version === 'draft') { - return `spaces/${spaceId}/contents/${contentId}/${version}/cache.json`; - } else { - return `spaces/${spaceId}/contents/${contentId}/cache.json`; - } -} - /** * construct content cache path, will return url to the cache file for cache version identifier * @param {string} spaceId @@ -226,3 +214,75 @@ export function docContentToExport(docId: string, content: Content): ContentExpo } return undefined; } + +/** + * Resolve references for a single content document + * @param {string} spaceId Space identifier + * @param {ContentDocumentStorage} content Content document + * @param {string} locale Locale identifier + * @param {string} version Content version + * @return {Promise>} Map of reference ID to content + */ +export async function resolveReferences( + spaceId: string, + content: ContentDocumentStorage, + locale: string, + version: string | 'draft' | undefined +): Promise | undefined> { + if (!content.references || content.references.length === 0) { + return undefined; + } + const resolvedReferences: Record = {}; + await Promise.all( + content.references.map(async refId => { + try { + const refCachePath = contentLocaleCachePath(spaceId, refId, locale, version); + const [exists] = await bucket.file(refCachePath).exists(); + + if (exists) { + const [fileContent] = await bucket.file(refCachePath).download(); + resolvedReferences[refId] = JSON.parse(fileContent.toString()); + } else { + logger.warn(`[ReferenceResolver::resolveReferences] Reference ${refId} not found at ${refCachePath}`); + } + } catch (error) { + logger.error(`[ReferenceResolver::resolveReferences] Failed to resolve reference ${refId}:`, error); + } + }) + ); + return resolvedReferences; +} + +/** + * Resolve links for a single content document + * @param {string} spaceId Space identifier + * @param {ContentDocumentStorage} content Content document + * @return {Promise>} Map of reference ID to content + */ +export async function resolveLinks(spaceId: string, content: ContentDocumentStorage): Promise | undefined> { + if (!content.links || content.links.length === 0) { + return undefined; + } + const resolvedLinks: Record = {}; + await Promise.all( + content.links.map(async linkId => { + const contentSnapshot = await findContentById(spaceId, linkId).get(); + const content = contentSnapshot.data() as Content; + const link: ContentLink = { + id: contentSnapshot.id, + kind: content.kind, + name: content.name, + slug: content.slug, + fullSlug: content.fullSlug, + parentSlug: content.parentSlug, + createdAt: content.createdAt.toDate().toISOString(), + updatedAt: content.updatedAt.toDate().toISOString(), + }; + if (content.kind === ContentKind.DOCUMENT) { + link.publishedAt = content.publishedAt?.toDate().toISOString(); + } + resolvedLinks[linkId] = link; + }) + ); + return resolvedLinks; +} diff --git a/functions/src/services/index.ts b/functions/src/services/index.ts index e0646e05..f2494df8 100644 --- a/functions/src/services/index.ts +++ b/functions/src/services/index.ts @@ -9,3 +9,4 @@ export * from './token.service'; export * from './translation.service'; export * from './translation-history.service'; export * from './user.service'; +export * from './webhook.service'; diff --git a/functions/src/services/open-api.service.ts b/functions/src/services/open-api.service.ts index d93ba29e..7049949e 100644 --- a/functions/src/services/open-api.service.ts +++ b/functions/src/services/open-api.service.ts @@ -147,13 +147,22 @@ export function generateOpenApi(schemasById: Map): OpenAPIObject }, { type: 'object', - description: 'References of all links used in the content.', + description: 'All links used in the content.', properties: { links: { $ref: '#/components/schemas/Links', }, }, }, + { + type: 'object', + description: 'All references used in the content.', + properties: { + references: { + $ref: '#/components/schemas/References', + }, + }, + }, ], example: { id: 'WLWc4vOACzG1QjK9AEo9', @@ -282,6 +291,51 @@ export function generateOpenApi(schemasById: Map): OpenAPIObject }, }, }, + References: { + description: 'Key-Value Object. Where Key is Unique identifier for the Content object and Value is Content.', + type: 'object', + additionalProperties: { + $ref: '#/components/schemas/Content', + }, + example: { + '4pLYWyN47c076mSfpWIy': { + id: '4pLYWyN47c076mSfpWIy', + kind: 'DOCUMENT', + name: 'Options', + slug: 'options', + fullSlug: 'docs/schemas/options', + parentSlug: 'docs/schemas', + createdAt: '2024-05-01T09:56:00.923Z', + updatedAt: '2024-05-01T09:57:06.445Z', + publishedAt: '2024-05-01T13:03:31.672Z', + data: {}, + }, + '5L2ELYsmaM6C0fOBzKp0': { + id: '5L2ELYsmaM6C0fOBzKp0', + kind: 'DOCUMENT', + name: 'Translations', + slug: 'translations', + fullSlug: 'docs/api/translations', + parentSlug: 'docs/api', + createdAt: '2024-05-04T14:03:54.100Z', + updatedAt: '2024-05-07T14:03:57.457Z', + publishedAt: '2024-05-07T11:05:46.094Z', + data: {}, + }, + '7fUavXjDpFuHGdWgTFXy': { + id: '7fUavXjDpFuHGdWgTFXy', + kind: 'DOCUMENT', + name: 'Overview', + slug: 'overview', + fullSlug: 'docs/content/overview', + parentSlug: 'docs/content', + createdAt: '2024-04-30T20:57:41.827Z', + updatedAt: '2024-04-30T21:31:47.344Z', + publishedAt: '2024-05-01T13:02:41.814Z', + data: {}, + }, + }, + }, }; const rootSchemas: string[] = []; for (const [key, item] of schemasById.entries()) { @@ -311,7 +365,7 @@ export function generateOpenApi(schemasById: Map): OpenAPIObject openapi: '3.0.3', info: { title: 'Localess Open API Specification', - version: '2.5.1', + version: '3.0.0', description: 'Fetch data from Localess via REST API', contact: { name: 'Lessify Team', @@ -368,6 +422,17 @@ export function generateOpenApi(schemasById: Map): OpenAPIObject example: '1706217382028', }, }, + { + name: 'version', + in: 'query', + description: 'Translation version.', + required: false, + schema: { + type: 'string', + enum: ['draft'], + example: 'draft', + }, + }, { name: 'token', in: 'query', @@ -559,6 +624,26 @@ export function generateOpenApi(schemasById: Map): OpenAPIObject example: 'fb6oTcVjbnyLCMhO2iLY', }, }, + { + name: 'resolveReference', + in: 'query', + description: 'Resolve all references.', + required: false, + schema: { + type: 'boolean', + example: 'true', + }, + }, + { + name: 'resolveLink', + in: 'query', + description: 'Resolve all links.', + required: false, + schema: { + type: 'boolean', + example: 'true', + }, + }, ], responses: { '200': { @@ -649,6 +734,26 @@ export function generateOpenApi(schemasById: Map): OpenAPIObject example: 'fb6oTcVjbnyLCMhO2iLY', }, }, + { + name: 'resolveReference', + in: 'query', + description: 'Resolve all references.', + required: false, + schema: { + type: 'boolean', + example: 'true', + }, + }, + { + name: 'resolveLink', + in: 'query', + description: 'Resolve all links.', + required: false, + schema: { + type: 'boolean', + example: 'true', + }, + }, ], responses: { '200': { @@ -902,58 +1007,31 @@ export function fieldToOpenApiSchemaDefinition(field: SchemaField): [string, Sch ]; } if (field.kind === SchemaFieldKind.OPTION) { - if (field.source === undefined || field.source === 'self') { - return [ - field.name, - { - type: 'string', - description: field.description, - enum: field.options?.map(it => it.value), - }, - ]; - } else { - const name = field.source || 'unknown'; - const pascalName = name[0].toUpperCase() + name.slice(1); - return [ - field.name, - { - description: field.description, - $ref: `#/components/schemas/${pascalName}`, - }, - ]; - } + const name = field.source || 'unknown'; + const pascalName = name[0].toUpperCase() + name.slice(1); + return [ + field.name, + { + description: field.description, + $ref: `#/components/schemas/${pascalName}`, + }, + ]; } if (field.kind === SchemaFieldKind.OPTIONS) { - if (field.source === undefined || field.source === 'self') { - return [ - field.name, - { - type: 'array', - description: field.description, - minItems: field.minValues, - maxItems: field.maxValues, - items: { - type: 'string', - enum: field.options?.map(it => it.value), - }, - }, - ]; - } else { - const name = field.source; - const pascalName = name[0].toUpperCase() + name.slice(1); - return [ - field.name, - { - type: 'array', - description: field.description, - minItems: field.minValues, - maxItems: field.maxValues, - items: { - $ref: `#/components/schemas/${pascalName}`, - }, + const name = field.source; + const pascalName = name[0].toUpperCase() + name.slice(1); + return [ + field.name, + { + type: 'array', + description: field.description, + minItems: field.minValues, + maxItems: field.maxValues, + items: { + $ref: `#/components/schemas/${pascalName}`, }, - ]; - } + }, + ]; } if (field.kind === SchemaFieldKind.LINK) { return [ diff --git a/functions/src/services/translate.service.ts b/functions/src/services/translate.service.ts index 3b55068c..c0cb4773 100644 --- a/functions/src/services/translate.service.ts +++ b/functions/src/services/translate.service.ts @@ -53,41 +53,48 @@ export async function translateCloud(content: string, sourceLocale: string | nul } } else { // Google Translate - if (sourceLocale && !GCP_SUPPORT_LOCALES.has(sourceLocale)) { - throw new HttpsError('invalid-argument', `Unsupported source locale : '${sourceLocale}'`); - } - if (!GCP_SUPPORT_LOCALES.has(targetLocale)) { - throw new HttpsError('invalid-argument', `Unsupported target locale : '${targetLocale}'`); - } + return await translateWithGoogle(content, sourceLocale, targetLocale); + } +} - const projectId = firebaseConfig.projectId; - let locationId; // firebaseConfig.locationId || 'global' - if (firebaseConfig.locationId && firebaseConfig.locationId.startsWith('us-')) { - locationId = 'us-central1'; - } else { - locationId = 'global'; - } +/** + * Translate content with Google Translate + * @param {string} content + * @param {string | null} sourceLocale + * @param {string} targetLocale + */ +export async function translateWithGoogle(content: string, sourceLocale: string | undefined | null, targetLocale: string): Promise { + if (sourceLocale && !GCP_SUPPORT_LOCALES.has(sourceLocale)) { + throw new HttpsError('invalid-argument', `Unsupported source locale : '${sourceLocale}'`); + } + if (!GCP_SUPPORT_LOCALES.has(targetLocale)) { + throw new HttpsError('invalid-argument', `Unsupported target locale : '${targetLocale}'`); + } - const tRequest: protos.google.cloud.translation.v3.ITranslateTextRequest = { - parent: `projects/${projectId}/locations/${locationId}`, - contents: [content], - mimeType: 'text/plain', - sourceLanguageCode: sourceLocale, - targetLanguageCode: targetLocale, - }; - try { - const [responseTranslateText] = await translationService.translateText(tRequest); - if (responseTranslateText.translations && responseTranslateText.translations.length > 0) { - return responseTranslateText.translations[0].translatedText || ''; - } else { - return ''; - } - } catch (e) { - logger.error(e); - throw new HttpsError( - 'failed-precondition', - `Cloud Translation API has not been used in project ${projectId} before or it is disabled` - ); + const projectId = firebaseConfig.projectId; + let locationId; // firebaseConfig.locationId || 'global' + if (firebaseConfig.locationId && firebaseConfig.locationId.startsWith('us-')) { + locationId = 'us-central1'; + } else { + locationId = 'global'; + } + + const tRequest: protos.google.cloud.translation.v3.ITranslateTextRequest = { + parent: `projects/${projectId}/locations/${locationId}`, + contents: [content], + mimeType: 'text/plain', + sourceLanguageCode: sourceLocale, + targetLanguageCode: targetLocale, + }; + try { + const [responseTranslateText] = await translationService.translateText(tRequest); + if (responseTranslateText.translations && responseTranslateText.translations.length > 0) { + return responseTranslateText.translations[0].translatedText || ''; + } else { + return ''; } + } catch (e) { + logger.error(e); + throw new HttpsError('failed-precondition', `Cloud Translation API has not been used in project ${projectId} before or it is disabled`); } } diff --git a/functions/src/services/translation.service.ts b/functions/src/services/translation.service.ts index 9cec3e85..b63cb07b 100644 --- a/functions/src/services/translation.service.ts +++ b/functions/src/services/translation.service.ts @@ -24,3 +24,37 @@ export function findTranslations(spaceId: string, fromDate?: number): Query { } return translationsRef; } + +/** + * delete Translations + * @param {string} spaceId + * @return {void} + */ +export function deleteTranslations(spaceId: string): Promise { + const translationsRef = firestoreService.collection(`spaces/${spaceId}/translations`); + return firestoreService.recursiveDelete(translationsRef); +} + +/** + * construct translation locale cache path, will return url to the generated translation JSON file + * @param {string} spaceId + * @param {string} locale + * @param {string} version + * @return {string} path + */ +export function translationLocaleCachePath(spaceId: string, locale: string, version: string | 'draft' | undefined): string { + if (version === 'draft') { + return `spaces/${spaceId}/translations/draft/${locale}.json`; + } else { + return `spaces/${spaceId}/translations/${locale}.json`; + } +} + +/** + * construct translation cache path, will return url to the cache file for cache version identifier + * @param {string} spaceId + * @return {string} path + */ +export function spaceTranslationCachePath(spaceId: string): string { + return `spaces/${spaceId}/translations/cache.json`; +} diff --git a/functions/src/services/webhook.service.ts b/functions/src/services/webhook.service.ts new file mode 100644 index 00000000..89deec25 --- /dev/null +++ b/functions/src/services/webhook.service.ts @@ -0,0 +1,47 @@ +import { DocumentReference, Query } from 'firebase-admin/firestore'; +import { logger } from 'firebase-functions/v2'; +import { firestoreService } from '../config'; +import { WebHookEvent } from '../models'; + +/** + * find WebHook by ID + * @param {string} spaceId Space identifier + * @param {string} id WebHook identifier + * @return {DocumentReference} document reference to the webhook + */ +export function findWebHookById(spaceId: string, id: string): DocumentReference { + logger.info(`[findWebHookById] spaceId=${spaceId} id=${id}`); + return firestoreService.doc(`spaces/${spaceId}/webhooks/${id}`); +} + +/** + * find all WebHooks by Space ID + * @param {string} spaceId Space identifier + * @return {Query} collection query + */ +export function findAllWebHooks(spaceId: string): Query { + logger.info(`[findAllWebHooks] spaceId=${spaceId}`); + return firestoreService.collection(`spaces/${spaceId}/webhooks`); +} + +/** + * find enabled WebHooks by Space ID and Event + * @param {string} spaceId Space identifier + * @param {WebHookEvent} event Event type + * @return {Query} collection query + */ +export function findEnabledWebHooksByEvent(spaceId: string, event: WebHookEvent): Query { + logger.info(`[findEnabledWebHooksByEvent] spaceId=${spaceId} event=${event}`); + return firestoreService.collection(`spaces/${spaceId}/webhooks`).where('enabled', '==', true).where('events', 'array-contains', event); +} + +/** + * find WebHook logs + * @param {string} spaceId Space identifier + * @param {string} webhookId WebHook identifier + * @return {Query} collection query + */ +export function findWebHookLogs(spaceId: string, webhookId: string): Query { + logger.info(`[findWebHookLogs] spaceId=${spaceId} webhookId=${webhookId}`); + return firestoreService.collection(`spaces/${spaceId}/webhooks/${webhookId}/logs`).orderBy('createdAt', 'desc').limit(100); +} diff --git a/functions/src/setup.ts b/functions/src/setup.ts index a815e54b..ef1cfa6c 100644 --- a/functions/src/setup.ts +++ b/functions/src/setup.ts @@ -1,7 +1,7 @@ import { FieldValue } from 'firebase-admin/firestore'; import { logger } from 'firebase-functions/v2'; import { HttpsError, onCall } from 'firebase-functions/v2/https'; -import { authService, firestoreService } from './config'; +import { authService, bucket, firestoreService } from './config'; import { DEFAULT_LOCALE } from './models'; import { createSpace } from './services'; @@ -18,36 +18,55 @@ export const setup = onCall(async request => { const setupRef = firestoreService.doc('configs/setup'); const setupSnapshot = await setupRef.get(); if (setupSnapshot.exists) { - logger.info('[setup] The configuration already exists.'); + logger.info('[setup] ✅ The configuration already exists.'); throw new HttpsError('already-exists', 'The configuration already exists.'); - } else { - const adminUser = await authService.createUser({ - displayName: request.data.displayName || 'Admin', - email: request.data.email, - password: request.data.password, - emailVerified: true, - disabled: false, - }); - await authService.setCustomUserClaims(adminUser.uid, { role: 'admin' }); - - await setupRef.set( + } + // Create first admin user + logger.info('[setup] 🔧 Creating the first admin user...'); + const adminUser = await authService.createUser({ + displayName: request.data.displayName || 'Admin', + email: request.data.email, + password: request.data.password, + emailVerified: true, + disabled: false, + }); + await authService.setCustomUserClaims(adminUser.uid, { role: 'admin' }); + logger.info(`[setup] ✅ First admin user created with UID: ${adminUser.uid}`); + // Setup + // Bucket CORS + logger.info('[setup] Check Bucket CORS:'); + if (bucket.metadata.cors === undefined) { + logger.info('[setup] Check Bucket CORS: 🔧 Setting configuration'); + await bucket.setCorsConfiguration([ { - createdAt: FieldValue.serverTimestamp(), + origin: ['*'], + method: ['GET', 'HEAD'], + maxAgeSeconds: 3600, }, - { merge: true } - ); - - // TODO update user role in firestore + ]); + logger.info('[setup] Check Bucket CORS: 💾 Configuration Saved'); + } else { + logger.info('[setup] Check Bucket CORS: ✅ Configuration already exists'); + } + logger.info('[setup] Check Bucket CORS: ✅ Done'); - // Create first Space - await createSpace({ - name: 'Hello World', - locales: [DEFAULT_LOCALE], - localeFallback: DEFAULT_LOCALE, + await setupRef.set( + { createdAt: FieldValue.serverTimestamp(), - updatedAt: FieldValue.serverTimestamp(), - }); - // Create token for the first user - return await authService.createCustomToken(adminUser.uid); - } + }, + { merge: true } + ); + + // TODO update user role in firestore + // Create first Space + await createSpace({ + name: 'Hello World', + locales: [DEFAULT_LOCALE], + localeFallback: DEFAULT_LOCALE, + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }); + // Create token for the first user + // TODO: Not working as it require a special role + // return await authService.createCustomToken(adminUser.uid); }); diff --git a/functions/src/tasks.ts b/functions/src/tasks.ts index ac0132d7..381f92b5 100644 --- a/functions/src/tasks.ts +++ b/functions/src/tasks.ts @@ -12,15 +12,30 @@ import { ContentExport, ContentFolder, ContentKind, + isTaskAssetExport, + isTaskAssetImport, + isTaskAssetRegenMetadata, + isTaskContentExport, + isTaskContentImport, + isTaskSchemaExport, + isTaskSchemaImport, + isTaskTranslationExport, + isTaskTranslationImport, Schema, SchemaComponent, SchemaEnum, SchemaExport, SchemaType, Task, + TaskAssetExport, + TaskContentExport, TaskExportMetadata, + TaskImport, TaskKind, + TaskSchemaExport, TaskStatus, + TaskTranslationExport, + TaskTranslationImport, Translation, TranslationExport, TranslationType, @@ -76,12 +91,15 @@ const onTaskCreate = onDocumentCreated( status: TaskStatus.IN_PROGRESS, updatedAt: FieldValue.serverTimestamp(), }; - if (task.kind.endsWith('_IMPORT')) { - if (task.tmpPath) { - const newPath = `spaces/${spaceId}/tasks/${taskId}/original`; - await bucket.file(task.tmpPath).move(newPath); - updateToInProgress.tmpPath = FieldValue.delete(); - } + if ( + task.kind === TaskKind.ASSET_IMPORT || + task.kind === TaskKind.CONTENT_IMPORT || + task.kind === TaskKind.SCHEMA_IMPORT || + task.kind === TaskKind.TRANSLATION_IMPORT + ) { + const newPath = `spaces/${spaceId}/tasks/${taskId}/original`; + await bucket.file(task.tmpPath).move(newPath); + (updateToInProgress as UpdateData).tmpPath = FieldValue.delete(); } // Update to IN_PROGRESS logger.info(`[Task:onCreate] update='${JSON.stringify(updateToInProgress)}'`); @@ -92,14 +110,15 @@ const onTaskCreate = onDocumentCreated( updatedAt: FieldValue.serverTimestamp(), }; - if (task.kind === TaskKind.ASSET_EXPORT) { + if (isTaskAssetExport(task)) { const metadata = await assetsExport(spaceId, taskId, task); logger.info(`[Task:onCreate] metadata='${JSON.stringify(metadata)}'`); - updateToFinished['file'] = { + + (updateToFinished as UpdateData).file = { name: `asset-export-${taskId}.lla.zip`, size: Number.isInteger(metadata.size) ? 0 : Number.parseInt(metadata.size), }; - } else if (task.kind === TaskKind.ASSET_IMPORT) { + } else if (isTaskAssetImport(task)) { const errors = await assetsImport(spaceId, taskId); if (errors) { updateToFinished.status = TaskStatus.ERROR; @@ -110,15 +129,15 @@ const onTaskCreate = onDocumentCreated( updateToFinished.trace = JSON.stringify(errors.format()); } } - } else if (task.kind === TaskKind.ASSET_REGEN_METADATA) { + } else if (isTaskAssetRegenMetadata(task)) { await assetRegenerateMetadata(spaceId); - } else if (task.kind === TaskKind.CONTENT_EXPORT) { + } else if (isTaskContentExport(task)) { const metadata = await contentsExport(spaceId, taskId, task); - updateToFinished['file'] = { + (updateToFinished as UpdateData).file = { name: `content-export-${taskId}.llc.zip`, size: Number.isInteger(metadata.size) ? 0 : Number.parseInt(metadata.size), }; - } else if (task.kind === TaskKind.CONTENT_IMPORT) { + } else if (isTaskContentImport(task)) { const errors = await contentsImport(spaceId, taskId); if (errors) { updateToFinished.status = TaskStatus.ERROR; @@ -129,13 +148,13 @@ const onTaskCreate = onDocumentCreated( updateToFinished.trace = JSON.stringify(errors.format()); } } - } else if (task.kind === TaskKind.SCHEMA_EXPORT) { + } else if (isTaskSchemaExport(task)) { const metadata = await schemasExport(spaceId, taskId, task); - updateToFinished['file'] = { + (updateToFinished as UpdateData).file = { name: `schema-export-${taskId}.lls.zip`, size: Number.isInteger(metadata.size) ? 0 : Number.parseInt(metadata.size), }; - } else if (task.kind === TaskKind.SCHEMA_IMPORT) { + } else if (isTaskSchemaImport(task)) { const errors = await schemasImport(spaceId, taskId); if (errors) { updateToFinished.status = TaskStatus.ERROR; @@ -146,21 +165,21 @@ const onTaskCreate = onDocumentCreated( updateToFinished.trace = JSON.stringify(errors.format()); } } - } else if (task.kind === TaskKind.TRANSLATION_EXPORT) { + } else if (isTaskTranslationExport(task)) { if (task.locale) { const metadata = await translationsExportJsonFlat(spaceId, taskId, task); - updateToFinished['file'] = { + (updateToFinished as UpdateData).file = { name: `translation-${task.locale}-export-${taskId}.json`, size: Number.isInteger(metadata.size) ? 0 : Number.parseInt(metadata.size), }; } else { const metadata = await translationsExport(spaceId, taskId, task); - updateToFinished['file'] = { + (updateToFinished as UpdateData).file = { name: `translation-export-${taskId}.llt.zip`, size: Number.isInteger(metadata.size) ? 0 : Number.parseInt(metadata.size), }; } - } else if (task.kind === TaskKind.TRANSLATION_IMPORT) { + } else if (isTaskTranslationImport(task)) { let errors: any; if (task.locale) { errors = await translationsImportJsonFlat(spaceId, taskId, task); @@ -189,7 +208,7 @@ const onTaskCreate = onDocumentCreated( * @param {string} taskId original task * @param {Task} task original task */ -async function assetsExport(spaceId: string, taskId: string, task: Task): Promise { +async function assetsExport(spaceId: string, taskId: string, task: TaskAssetExport): Promise { const exportAssets: (AssetExport | undefined)[] = []; if (task.path) { // Only specific folder or asset @@ -387,7 +406,7 @@ async function assetRegenerateMetadata(spaceId: string): Promise { * @param {string} taskId original task * @param {Task} task original task */ -async function contentsExport(spaceId: string, taskId: string, task: Task): Promise { +async function contentsExport(spaceId: string, taskId: string, task: TaskContentExport): Promise { const exportContents: (ContentExport | undefined)[] = []; if (task.path) { // Only specific folder or document @@ -567,7 +586,7 @@ async function contentsImport(spaceId: string, taskId: string): Promise { +async function schemasExport(spaceId: string, taskId: string, task: TaskSchemaExport): Promise { const exportSchemas: SchemaExport[] = []; const schemasSnapshot = await findSchemas(spaceId, task.fromDate).get(); schemasSnapshot.docs @@ -716,7 +735,7 @@ async function schemasImport(spaceId: string, taskId: string): Promise { +async function translationsExport(spaceId: string, taskId: string, task: TaskTranslationExport): Promise { const exportTranslations: TranslationExport[] = []; const translationsSnapshot = await findTranslations(spaceId, task.fromDate).get(); translationsSnapshot.docs @@ -763,7 +782,7 @@ async function translationsExport(spaceId: string, taskId: string, task: Task): * @param {string} taskId original task * @param {Task} task original task */ -async function translationsExportJsonFlat(spaceId: string, taskId: string, task: Task): Promise { +async function translationsExportJsonFlat(spaceId: string, taskId: string, task: TaskTranslationExport): Promise { const exportTranslations: Record = {}; const translationsSnapshot = await findTranslations(spaceId, task.fromDate).get(); translationsSnapshot.docs @@ -852,7 +871,11 @@ async function translationsImport(spaceId: string, taskId: string): Promise { +async function translationsImportJsonFlat( + spaceId: string, + taskId: string, + task: TaskTranslationImport +): Promise { const tmpTaskFolder = TMP_TASK_FOLDER + taskId; mkdirSync(tmpTaskFolder); const jsonPath = `${tmpTaskFolder}/task.json`; diff --git a/functions/src/translate.ts b/functions/src/translate.ts index 1967b557..56e0ff37 100644 --- a/functions/src/translate.ts +++ b/functions/src/translate.ts @@ -1,6 +1,6 @@ import { logger } from 'firebase-functions/v2'; import { HttpsError, onCall } from 'firebase-functions/v2/https'; -import { canPerform } from './utils/security-utils'; +import { canPerform } from './utils/user-auth-utils'; import { TranslateData, UserPermission } from './models'; import { translateCloud } from './services/translate.service'; import { isEmulatorEnabled } from './config'; diff --git a/functions/src/translations.ts b/functions/src/translations.ts index 6df87be6..a0eabdfd 100644 --- a/functions/src/translations.ts +++ b/functions/src/translations.ts @@ -1,12 +1,20 @@ -import { FieldValue, WithFieldValue } from 'firebase-admin/firestore'; +import { FieldValue, UpdateData, WithFieldValue } from 'firebase-admin/firestore'; import { logger } from 'firebase-functions/v2'; import { onDocumentCreated, onDocumentWritten } from 'firebase-functions/v2/firestore'; import { HttpsError, onCall } from 'firebase-functions/v2/https'; import { bucket, firestoreService } from './config'; -import { PublishTranslationsData, Space, Translation, TranslationHistory, TranslationHistoryType, UserPermission } from './models'; -import { findSpaceById, findTranslations, findTranslationsHistory } from './services'; -import { translateCloud } from './services/translate.service'; -import { canPerform } from './utils/security-utils'; +import { + PublishTranslationsData, + Space, + TranslateLocaleData, + Translation, + TranslationHistory, + TranslationHistoryType, + UserPermission, +} from './models'; +import { deleteTranslations, findSpaceById, findTranslations, findTranslationsHistory, spaceTranslationCachePath } from './services'; +import { translateCloud, translateWithGoogle } from './services/translate.service'; +import { canPerform } from './utils/user-auth-utils'; // Publish const publish = onCall(async request => { @@ -38,17 +46,12 @@ const publish = onCall(async request => { progress[locale.id] = counter; // Save generated JSON logger.info(`[translationsPublish] Save file to spaces/${spaceId}/translations/${locale.id}.json`); - bucket.file(`spaces/${spaceId}/translations/${locale.id}.json`).save(JSON.stringify(localeStorage), (err?: Error | null) => { - if (err) { - logger.error(`[translationsPublish] Can not save file for Space(${spaceId}) and Locale(${locale})`); - logger.error(err); - } - }); + await bucket.file(`spaces/${spaceId}/translations/${locale.id}.json`).save(JSON.stringify(localeStorage)); } await spaceSnapshot.ref.update('progress.translations', progress); // Save Cache - logger.info(`[translationsPublish] Save file to spaces/${spaceId}/translations/cache.json`); - await bucket.file(`spaces/${spaceId}/translations/cache.json`).save(''); + logger.info(`[translationsPublish] Save file to ${spaceTranslationCachePath(spaceId)}`); + await bucket.file(spaceTranslationCachePath(spaceId)).save(''); const addHistory: WithFieldValue = { type: TranslationHistoryType.PUBLISHED, name: auth?.token['name'] || FieldValue.delete(), @@ -63,6 +66,15 @@ const publish = onCall(async request => { } }); +const deleteAll = onCall<{ spaceId: string }>(async request => { + logger.info('[translationsDeleteAll] data: ' + JSON.stringify(request.data)); + logger.info('[translationsDeleteAll] context.auth: ' + JSON.stringify(request.auth)); + const { auth, data } = request; + if (!canPerform(UserPermission.SPACE_MANAGEMENT, auth)) throw new HttpsError('permission-denied', 'permission-denied'); + const { spaceId } = data; + await deleteTranslations(spaceId); +}); + const onCreate = onDocumentCreated('spaces/{spaceId}/translations/{translationId}', async event => { logger.info(`[Translation:onCreate] eventId='${event.id}'`); logger.info(`[Translation:onCreate] params='${JSON.stringify(event.params)}'`); @@ -74,7 +86,7 @@ const onCreate = onDocumentCreated('spaces/{spaceId}/translations/{translationId logger.info(`[Translation:onCreate] data='${JSON.stringify(translation)}'`); // No Auto Translate field define if (translation.autoTranslate === undefined) return; - const update: any = { + const update: UpdateData = { autoTranslate: FieldValue.delete(), updatedAt: FieldValue.serverTimestamp(), }; @@ -90,9 +102,8 @@ const onCreate = onDocumentCreated('spaces/{spaceId}/translations/{translationId if (locale.id === space.localeFallback.id) continue; try { const tValue = await translateCloud(localeValue, space.localeFallback.id, locale.id); - const field = 'locales.' + locale.id; if (tValue) { - update[field] = tValue; + update[`locales.${locale.id}`] = tValue; } } catch (e) { logger.error(e); @@ -105,6 +116,101 @@ const onCreate = onDocumentCreated('spaces/{spaceId}/translations/{translationId return; }); +const onWriteToDraft = onDocumentWritten('spaces/{spaceId}/translations/{translationId}', async event => { + logger.info(`[Translation:onWriteToDraft] eventId='${event.id}'`); + logger.info(`[Translation:onWriteToDraft] params='${JSON.stringify(event.params)}'`); + const { spaceId, translationId } = event.params; + + // No Data + if (!event.data) return; + + const { before, after } = event.data; + const beforeData = before.data() as Translation | undefined; + const afterData = after.data() as Translation | undefined; + + // Skip if both before and after are undefined (shouldn't happen) + if (!beforeData && !afterData) { + logger.warn(`[Translation:onWriteToDraft] eventId='${event.id}' Both before and after data are undefined`); + return; + } + + // Check if locales have changed (skip regeneration if only metadata changed) + if (beforeData && afterData) { + const localesChanged = JSON.stringify(beforeData.locales) !== JSON.stringify(afterData.locales); + if (!localesChanged) { + logger.info(`[Translation:onWriteToDraft] translationId='${translationId}' Locales unchanged, skipping draft generation`); + return; + } + } + + // For create or update, generate draft files + if (afterData) { + logger.info(`[Translation:onWriteToDraft] eventId='${event.id}' translationId='${translationId}' Generating draft files`); + + const spaceSnapshot = await findSpaceById(spaceId).get(); + const translationsSnapshot = await findTranslations(spaceId).get(); + + if (spaceSnapshot.exists && !translationsSnapshot.empty) { + const space: Space = spaceSnapshot.data() as Space; + + for (const locale of space.locales) { + const localeStorage: Record = {}; + for (const translation of translationsSnapshot.docs) { + const tr = translation.data() as Translation; + let value = tr.locales[locale.id]; + if (value) { + // check the value is not empty string + value = value || tr.locales[space.localeFallback.id]; + } else { + value = tr.locales[space.localeFallback.id]; + } + localeStorage[translation.id] = value; + } + // Save generated JSON to draft + logger.info(`[Translation:onWriteToDraft] Save file to spaces/${spaceId}/translations/draft/${locale.id}.json`); + await bucket.file(`spaces/${spaceId}/translations/draft/${locale.id}.json`).save(JSON.stringify(localeStorage)); + } + } + } else if (beforeData) { + // For delete, regenerate draft files without the deleted translation + logger.info( + `[Translation:onWriteToDraft] eventId='${event.id}' translationId='${translationId}' Regenerating draft files after delete` + ); + + const spaceSnapshot = await findSpaceById(spaceId).get(); + const translationsSnapshot = await findTranslations(spaceId).get(); + + if (spaceSnapshot.exists) { + const space: Space = spaceSnapshot.data() as Space; + + for (const locale of space.locales) { + const localeStorage: Record = {}; + + // Only include remaining translations (deleted one is already removed from collection) + if (!translationsSnapshot.empty) { + for (const translation of translationsSnapshot.docs) { + const tr = translation.data() as Translation; + let value = tr.locales[locale.id]; + if (value) { + value = value || tr.locales[space.localeFallback.id]; + } else { + value = tr.locales[space.localeFallback.id]; + } + localeStorage[translation.id] = value; + } + } + + // Save generated JSON to draft (will be empty object if no translations left) + logger.info(`[Translation:onWriteToDraft] Save file to spaces/${spaceId}/translations/draft/${locale.id}.json`); + await bucket.file(`spaces/${spaceId}/translations/draft/${locale.id}.json`).save(JSON.stringify(localeStorage)); + } + } + } + logger.info(`[Translation:onWriteToDraft] Save file to ${spaceTranslationCachePath(spaceId)}`); + await bucket.file(spaceTranslationCachePath(spaceId)).save(''); + return; +}); + const onWriteToHistory = onDocumentWritten('spaces/{spaceId}/translations/{translationId}', async event => { logger.info(`[Translation:onWrite] eventId='${event.id}'`); logger.info(`[Translation:onWrite] params='${JSON.stringify(event.params)}'`); @@ -167,8 +273,60 @@ const onWriteToHistory = onDocumentWritten('spaces/{spaceId}/translations/{trans return; }); +const translateLocale = onCall(async request => { + logger.info('[translationsTranslateLocale] data: ' + JSON.stringify(request.data)); + logger.info('[translationsTranslateLocale] context.auth: ' + JSON.stringify(request.auth)); + const { auth, data } = request; + if (!canPerform(UserPermission.TRANSLATION_UPDATE, auth)) throw new HttpsError('permission-denied', 'permission-denied'); + const { spaceId, sourceLocaleId, targetLocaleId } = data; + + const translationsSnapshot = await findTranslations(spaceId).get(); + if (translationsSnapshot.empty) { + logger.info(`[translationsTranslateLocale] Space ${spaceId} has no translations.`); + return; + } + + const bulk = firestoreService.bulkWriter(); + const candidates = translationsSnapshot.docs.filter(doc => { + const translation = doc.data() as Translation; + const sourceValue = translation.locales[sourceLocaleId]; + const targetValue = translation.locales[targetLocaleId]; + return sourceValue && !targetValue; + }); + + const results = await Promise.all( + candidates.map(async doc => { + const sourceValue = (doc.data() as Translation).locales[sourceLocaleId]; + try { + const translatedValue = await translateWithGoogle(sourceValue, sourceLocaleId, targetLocaleId); + return { doc, translatedValue }; + } catch (e) { + logger.error(`[translationsTranslateLocale] Failed to translate '${doc.id}': ${e}`); + return { doc, translatedValue: null }; + } + }) + ); + + let counter = 0; + for (const { doc, translatedValue } of results) { + if (translatedValue) { + const update: UpdateData = { + [`locales.${targetLocaleId}`]: translatedValue, + updatedAt: FieldValue.serverTimestamp(), + }; + bulk.update(doc.ref, update); + counter++; + } + } + await bulk.close(); + logger.info(`[translationsTranslateLocale] Bulk successfully updated ${counter}.`); +}); + export const translation = { publish: publish, oncreate: onCreate, + onwritetodraft: onWriteToDraft, onwritetohistory: onWriteToHistory, + deleteall: deleteAll, + translatelocale: translateLocale, }; diff --git a/functions/src/users.ts b/functions/src/users.ts index e989818a..2bc504c0 100644 --- a/functions/src/users.ts +++ b/functions/src/users.ts @@ -2,7 +2,7 @@ import { logger } from 'firebase-functions'; import { HttpsError, onCall } from 'firebase-functions/v2/https'; import { FieldValue, Timestamp } from 'firebase-admin/firestore'; import { authService } from './config'; -import { canPerform } from './utils/security-utils'; +import { canPerform } from './utils/user-auth-utils'; import { User, UserInvite, UserPermission } from './models'; import { beforeUserCreated, beforeUserSignedIn } from 'firebase-functions/v2/identity'; import { findUserById } from './services'; diff --git a/functions/src/utils/api-auth-utils.ts b/functions/src/utils/api-auth-utils.ts new file mode 100644 index 00000000..080850f0 --- /dev/null +++ b/functions/src/utils/api-auth-utils.ts @@ -0,0 +1,32 @@ +import { isTokenV1, isTokenV2, Token, TokenPermission } from '../models'; + +/** + * Check if Token contains required permission + * @param {TokenPermission} permission - a user permission + * @param {Token} token request Token + * @return {boolean} true in case role is present + */ +export function canPerform(permission: TokenPermission, token: Token): boolean { + if (isTokenV1(token)) { + return ( + permission === TokenPermission.TRANSLATION_PUBLIC || + permission === TokenPermission.TRANSLATION_DRAFT || + permission === TokenPermission.CONTENT_PUBLIC || + permission === TokenPermission.CONTENT_DRAFT + ); + } + if (isTokenV2(token)) { + return token.permissions.includes(permission); + } + return false; +} + +/** + * Check if Token contains any of the required permissions + * @param {TokenPermission[]} permissions + * @param {Token} token request Token + * @return {boolean} true in case any role is present + */ +export function canPerformAny(permissions: TokenPermission[], token: Token): boolean { + return permissions.some(permission => canPerform(permission, token)); +} diff --git a/functions/src/utils/translate.utils.ts b/functions/src/utils/translate.utils.ts new file mode 100644 index 00000000..ad149c80 --- /dev/null +++ b/functions/src/utils/translate.utils.ts @@ -0,0 +1,16 @@ +import { SchemaFieldKind } from '../models'; + +export function isSchemaFieldKindAITranslatable(kind: SchemaFieldKind): boolean { + switch (kind) { + case SchemaFieldKind.TEXT: + case SchemaFieldKind.TEXTAREA: + case SchemaFieldKind.MARKDOWN: + case SchemaFieldKind.SCHEMA: + case SchemaFieldKind.SCHEMAS: + return true; + case SchemaFieldKind.RICH_TEXT: + return false; // nested JSON not supported at the moment + default: + return false; + } +} diff --git a/functions/src/utils/security-utils.ts b/functions/src/utils/user-auth-utils.ts similarity index 79% rename from functions/src/utils/security-utils.ts rename to functions/src/utils/user-auth-utils.ts index 2bbe8e79..8193a63d 100644 --- a/functions/src/utils/security-utils.ts +++ b/functions/src/utils/user-auth-utils.ts @@ -1,7 +1,16 @@ -import { AuthData } from 'firebase-functions/lib/common/providers/https'; +import { DecodedIdToken } from 'firebase-admin/auth'; import { ROLE_ADMIN, ROLE_CUSTOM } from '../config'; import { UserPermission } from '../models'; +export interface AuthData { + /** The user's uid from the request's ID token. */ + uid: string; + /** The decoded claims of the ID token after verification. */ + token: DecodedIdToken; + /** The raw ID token as parsed from the header. */ + rawToken: string; +} + /** * Check roles of authenticated user * @param {string} role roles to check diff --git a/functions/src/utils/webhook-utils.ts b/functions/src/utils/webhook-utils.ts new file mode 100644 index 00000000..5a839484 --- /dev/null +++ b/functions/src/utils/webhook-utils.ts @@ -0,0 +1,130 @@ +import { createHmac } from 'crypto'; +import { logger } from 'firebase-functions/v2'; +import { WebHook, WebHookPayload, WebHookLog, WebHookStatus } from '../models'; +import { FieldValue, WithFieldValue } from 'firebase-admin/firestore'; +import { findEnabledWebHooksByEvent } from '../services'; +import { firestoreService } from '../config'; + +/** + * Generate HMAC signature for webhook payload + * @param {string} secret Secret key + * @param {string} payload JSON payload + * @return {string} HMAC signature + */ +export function generateSignature(secret: string, payload: string): string { + return 'sha256=' + createHmac('sha256', secret).update(payload).digest('hex'); +} + +/** + * Trigger webhook with retry logic + * @param {string} spaceId Space identifier + * @param {string} webhookId WebHook identifier + * @param {WebHook} webhook WebHook configuration + * @param {WebHookPayload} payload Payload to send + */ +export async function triggerWebHook(spaceId: string, webhookId: string, webhook: WebHook, payload: WebHookPayload): Promise { + const startTime = Date.now(); + const payloadJson = JSON.stringify(payload); + + // Add signature if secret is configured + if (webhook.secret) { + payload.signature = generateSignature(webhook.secret, payloadJson); + } + + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': 'Localess-WebHook/1.0', + 'X-Webhook-Event': payload.event, + ...(webhook.headers || {}), + }; + + if (payload.signature) { + headers['X-Webhook-Signature'] = payload.signature; + } + + try { + logger.info(`[triggerWebHook] Sending webhook to ${webhook.url}`); + + const response = await fetch(webhook.url, { + method: 'POST', + headers, + body: payloadJson, + signal: AbortSignal.timeout(30000), // 30 second timeout + }); + + const responseTime = Date.now() - startTime; + const status: WebHookStatus = response.ok ? 'success' : 'failure'; + + // Log the webhook execution + await logWebHookExecution(spaceId, webhookId, { + webhookId: webhookId, + event: payload.event, + url: webhook.url, + status: status, + statusCode: response.status, + responseTime: responseTime, + createdAt: FieldValue.serverTimestamp(), + }); + + if (response.ok) { + logger.info(`[triggerWebHook] Webhook succeeded (${responseTime}ms)`); + } else { + logger.error(`[triggerWebHook] Webhook failed: HTTP ${response.status}: ${response.statusText}`); + } + } catch (error: any) { + const responseTime = Date.now() - startTime; + logger.error(`[triggerWebHook] Webhook error: ${error.message}`); + + // Log the failed execution + await logWebHookExecution(spaceId, webhookId, { + webhookId: webhookId, + event: payload.event, + url: webhook.url, + status: 'failure', + responseTime: responseTime, + errorMessage: error.message, + createdAt: FieldValue.serverTimestamp(), + }); + } +} + +/** + * Log webhook execution + * @param {string} spaceId Space identifier + * @param {string} webhookId WebHook identifier + * @param {WebHookLog} log Log entry + */ +async function logWebHookExecution(spaceId: string, webhookId: string, log: WithFieldValue): Promise { + try { + await firestoreService.collection(`spaces/${spaceId}/webhooks/${webhookId}/logs`).add(log); + } catch (error: any) { + logger.error(`[logWebHookExecution] Failed to log webhook execution: ${error.message}`); + } +} + +/** + * Trigger all webhooks for a specific event + * @param {string} spaceId Space identifier + * @param {WebHookPayload} payload Webhook payload + */ +export async function triggerWebHooksForEvent(spaceId: string, payload: WebHookPayload): Promise { + try { + const webhooksSnapshot = await findEnabledWebHooksByEvent(spaceId, payload.event).get(); + + if (webhooksSnapshot.empty) { + logger.info(`[triggerWebHooksForEvent] No webhooks configured for event ${payload.event}`); + return; + } + + logger.info(`[triggerWebHooksForEvent] Triggering ${webhooksSnapshot.size} webhook(s) for event ${payload.event}`); + + const promises = webhooksSnapshot.docs.map(doc => { + const webhook = doc.data() as WebHook; + return triggerWebHook(spaceId, doc.id, webhook, payload); + }); + + await Promise.allSettled(promises); + } catch (error: any) { + logger.error(`[triggerWebHooksForEvent] Error triggering webhooks: ${error.message}`); + } +} diff --git a/functions/src/v1.ts b/functions/src/v1.ts index 6fe9492c..696fe255 100644 --- a/functions/src/v1.ts +++ b/functions/src/v1.ts @@ -1,445 +1,16 @@ import cors from 'cors'; import express from 'express'; -import { Query } from 'firebase-admin/firestore'; -import { logger } from 'firebase-functions'; -import { HttpsError, onRequest } from 'firebase-functions/v2/https'; -import os from 'os'; -import sharp from 'sharp'; -import { bucket, CACHE_ASSET_MAX_AGE, CACHE_MAX_AGE, CACHE_SHARE_MAX_AGE, firestoreService, TEN_MINUTES } from './config'; -import { AssetFile, Content, ContentKind, ContentLink, Space } from './models'; -import { - contentCachePath, - contentLocaleCachePath, - extractThumbnail, - findContentByFullSlug, - findSpaceById, - findTokenById, - identifySpaceLocale, - spaceContentCachePath, - validateToken, -} from './services'; +import { onRequest } from 'firebase-functions/v2/https'; +import { CDN } from './v1/cdn'; +import { MANAGE } from './v1/manage'; +import { DEV_TOOLS } from './v1/dev-tools'; // API V1 const expressApp = express(); expressApp.use(cors({ origin: true })); - -expressApp.get('/api/v1/spaces/:spaceId/translations/:locale', async (req, res) => { - logger.info('v1 spaces translations params : ' + JSON.stringify(req.params)); - logger.info('v1 spaces translations query : ' + JSON.stringify(req.query)); - const { spaceId, locale } = req.params; - const { cv, token } = req.query; - - if (!validateToken(token)) { - res - .status(404) - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .send(new HttpsError('not-found', 'Not found')); - return; - } - const spaceSnapshot = await findSpaceById(spaceId).get(); - if (!spaceSnapshot.exists) { - res - .status(404) - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .send(new HttpsError('not-found', 'Not found')); - return; - } - - const tokenSnapshot = await findTokenById(spaceId, token?.toString() || '').get(); - if (!tokenSnapshot.exists) { - res - .status(404) - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .send(new HttpsError('not-found', 'Not found')); - return; - } - - const cachePath = `spaces/${spaceId}/translations/cache.json`; - const [exists] = await bucket.file(cachePath).exists(); - if (exists) { - const [metadata] = await bucket.file(cachePath).getMetadata(); - logger.info('v1 spaces translations cache meta : ' + JSON.stringify(metadata)); - if (cv === undefined || cv != metadata.generation) { - let url = `/api/v1/spaces/${spaceId}/translations/${locale}?cv=${metadata.generation}`; - if (token) { - url += `&token=${token}`; - } - logger.info(`v1 spaces translate redirect to => ${url}`); - res.redirect(url); - return; - } else { - const space = spaceSnapshot.data() as Space; - let actualLocale = locale; - if (!space.locales.some(it => it.id === locale)) { - actualLocale = space.localeFallback.id; - } - bucket - .file(`spaces/${spaceId}/translations/${actualLocale}.json`) - .download() - .then(content => { - res - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .contentType('application/json; charset=utf-8') - .send(content.toString()); - }) - .catch(() => { - res.status(404).send(new HttpsError('not-found', 'File not found, Publish first.')); - }); - } - } else { - res.status(404).send(new HttpsError('not-found', 'File not found, Publish first.')); - return; - } -}); - -expressApp.get('/api/v1/spaces/:spaceId/links', async (req, res) => { - logger.info('v1 spaces links params: ' + JSON.stringify(req.params)); - logger.info('v1 spaces links query: ' + JSON.stringify(req.query)); - const { spaceId } = req.params; - const { kind, parentSlug, excludeChildren, cv, token } = req.query; - if (!validateToken(token)) { - res - .status(404) - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .send(new HttpsError('not-found', 'Not found')); - return; - } - const spaceSnapshot = await findSpaceById(spaceId).get(); - if (!spaceSnapshot.exists) { - res - .status(404) - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .send(new HttpsError('not-found', 'Not found')); - return; - } - const tokenSnapshot = await findTokenById(spaceId, token?.toString() || '').get(); - if (!tokenSnapshot.exists) { - res - .status(404) - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .send(new HttpsError('not-found', 'Not found')); - return; - } - const cachePath = spaceContentCachePath(spaceId); - const [exists] = await bucket.file(cachePath).exists(); - if (exists) { - const [metadata] = await bucket.file(cachePath).getMetadata(); - logger.info('v1 spaces links cache meta : ' + JSON.stringify(metadata)); - if (cv === undefined || cv != metadata.generation) { - let url = `/api/v1/spaces/${spaceId}/links?cv=${metadata.generation}`; - if (parentSlug !== undefined) { - url += `&parentSlug=${parentSlug}`; - } - if (excludeChildren === 'true') { - url += `&excludeChildren=${excludeChildren}`; - } - if (kind === ContentKind.DOCUMENT || kind === ContentKind.FOLDER) { - url += `&kind=${kind}`; - } - if (token) { - url += `&token=${token}`; - } - res.redirect(url); - return; - } else { - let contentsQuery: Query = firestoreService.collection(`spaces/${spaceId}/contents`); - if (parentSlug) { - if (excludeChildren === 'true') { - contentsQuery = contentsQuery.where('parentSlug', '==', parentSlug); - } else { - contentsQuery = contentsQuery.where('parentSlug', '>=', parentSlug).where('parentSlug', '<', `${parentSlug}/~`); - } - } else { - if (excludeChildren === 'true') { - contentsQuery = contentsQuery.where('parentSlug', '==', ''); - } - } - if (kind && (kind === ContentKind.DOCUMENT || kind === ContentKind.FOLDER)) { - contentsQuery = contentsQuery.where('kind', '==', kind); - } - const contentsSnapshot = await contentsQuery.get(); - - const response: Record = contentsSnapshot.docs - .map(contentSnapshot => { - const content = contentSnapshot.data() as Content; - const link: ContentLink = { - id: contentSnapshot.id, - kind: content.kind, - name: content.name, - slug: content.slug, - fullSlug: content.fullSlug, - parentSlug: content.parentSlug, - createdAt: content.createdAt.toDate().toISOString(), - updatedAt: content.updatedAt.toDate().toISOString(), - }; - if (content.kind === ContentKind.DOCUMENT) { - link.publishedAt = content.publishedAt?.toDate().toISOString(); - } - return link; - }) - .reduce( - (acc, item) => { - acc[item.id] = item; - return acc; - }, - {} as Record - ); - res - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .contentType('application/json; charset=utf-8') - .send(response); - return; - } - } else { - res.status(404).send(new HttpsError('not-found', 'File not found, Publish first.')); - return; - } -}); - -expressApp.get('/api/v1/spaces/:spaceId/contents/slugs/*slug', async (req, res) => { - logger.info('v1 spaces content params: ' + JSON.stringify(req.params)); - logger.info('v1 spaces content query: ' + JSON.stringify(req.query)); - logger.info('v1 spaces content url: ' + req.url); - const { spaceId } = req.params; - const { cv, locale, version, token } = req.query; - const params: Record = req.params; - const slug = params['slug'] as string[]; - const fullSlug = slug.join('/'); - let contentId = ''; - if (!validateToken(token)) { - res - .status(404) - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .send(new HttpsError('not-found', 'Not found')); - return; - } - const spaceSnapshot = await findSpaceById(spaceId).get(); - if (!spaceSnapshot.exists) { - res - .status(404) - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .send(new HttpsError('not-found', 'Not found')); - return; - } - const tokenSnapshot = await findTokenById(spaceId, token?.toString() || '').get(); - if (!tokenSnapshot.exists) { - res - .status(404) - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .send(new HttpsError('not-found', 'Not found')); - return; - } - const contentsSnapshot = await findContentByFullSlug(spaceId, fullSlug).get(); - // logger.info('v1 spaces contents', contentsSnapshot.size); - if (contentsSnapshot.empty) { - // No records in database - res.status(404).send(new HttpsError('not-found', 'Slug not found')); - return; - } else { - contentId = contentsSnapshot.docs[0].id; - } - const cachePath = contentCachePath(spaceId, contentId, version as string | undefined); - logger.info('v1 spaces content cachePath: ' + cachePath); - const [exists] = await bucket.file(cachePath).exists(); - if (exists) { - const [metadata] = await bucket.file(cachePath).getMetadata(); - // logger.info('v1 spaces content cache meta : ' + JSON.stringify(metadata)); - if (cv === undefined || cv != metadata.generation) { - let url = `/api/v1/spaces/${spaceId}/contents/slugs/${fullSlug}?cv=${metadata.generation}`; - if (locale) { - url += `&locale=${locale}`; - } - if (version) { - url += `&version=${version}`; - } - if (token) { - url += `&token=${token}`; - } - logger.info(`v1 spaces content redirect to => ${url}`); - res.redirect(url); - return; - } else { - const space = spaceSnapshot.data() as Space; - const actualLocale = identifySpaceLocale(space, locale as string | undefined); - logger.info(`v1 spaces content slug locale identified as => ${actualLocale}`); - const filePath = contentLocaleCachePath(spaceId, contentId, actualLocale, version as string | undefined); - bucket - .file(filePath) - .download() - .then(content => { - res - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .contentType('application/json; charset=utf-8') - .send(content.toString()); - }) - .catch(() => { - res - .status(404) - .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`) - .send(new HttpsError('not-found', 'File not found, on path. Please Publish again.')); - }); - } - } else { - res - .status(404) - .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`) - .send(new HttpsError('not-found', 'File not found, Publish first.')); - return; - } -}); - -expressApp.get('/api/v1/spaces/:spaceId/contents/:contentId', async (req, res) => { - logger.info('v1 spaces content params: ' + JSON.stringify(req.params)); - logger.info('v1 spaces content query: ' + JSON.stringify(req.query)); - const { spaceId, contentId } = req.params; - const { cv, locale, version, token } = req.query; - if (!validateToken(token)) { - logger.info('v1 spaces content Token Not Valid string: ' + token); - res - .status(404) - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .send(new HttpsError('not-found', 'Not found')); - return; - } - const spaceSnapshot = await findSpaceById(spaceId).get(); - if (!spaceSnapshot.exists) { - logger.info('v1 spaces content Space not exist: ' + spaceId); - res - .status(404) - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .send(new HttpsError('not-found', 'Not found')); - return; - } - const tokenSnapshot = await findTokenById(spaceId, token?.toString() || '').get(); - if (!tokenSnapshot.exists) { - logger.info('v1 spaces content Token not exist: ' + token); - res - .status(404) - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .send(new HttpsError('not-found', 'Not found')); - return; - } - const cachePath = contentCachePath(spaceId, contentId, version as string | undefined); - const [exists] = await bucket.file(cachePath).exists(); - if (exists) { - const [metadata] = await bucket.file(cachePath).getMetadata(); - // logger.info('v1 spaces content cache meta : ' + JSON.stringify(metadata)); - if (cv === undefined || cv != metadata.generation) { - let url = `/api/v1/spaces/${spaceId}/contents/${contentId}?cv=${metadata.generation}`; - if (locale) { - url += `&locale=${locale}`; - } - if (version) { - url += `&version=${version}`; - } - if (token) { - url += `&token=${token}`; - } - logger.info(`v1 spaces content redirect to => ${url}`); - res.redirect(url); - return; - } else { - const space = spaceSnapshot.data() as Space; - const actualLocale = identifySpaceLocale(space, locale as string | undefined); - logger.info(`v1 spaces content id locale identified as => ${actualLocale}`); - const filePath = contentLocaleCachePath(spaceId, contentId, actualLocale, version as string | undefined); - bucket - .file(filePath) - .download() - .then(content => { - res - .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) - .contentType('application/json; charset=utf-8') - .send(content.toString()); - }) - .catch(() => { - res - .status(404) - .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`) - .send(new HttpsError('not-found', 'File not found, on path. Please Publish again.')); - }); - } - } else { - res - .status(404) - .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`) - .send(new HttpsError('not-found', 'File not found, Publish first.')); - return; - } -}); - -expressApp.get('/api/v1/spaces/:spaceId/assets/:assetId', async (req, res) => { - logger.info('v1 spaces asset params: ' + JSON.stringify(req.params)); - logger.info('v1 spaces asset query: ' + JSON.stringify(req.query)); - const { spaceId, assetId } = req.params; - const { w: width, download, thumbnail } = req.query; - - const assetFile = bucket.file(`spaces/${spaceId}/assets/${assetId}/original`); - const [exists] = await assetFile.exists(); - const assetSnapshot = await firestoreService.doc(`spaces/${spaceId}/assets/${assetId}`).get(); - let overwriteType: string | undefined; - logger.info(`v1 spaces asset: ${exists} & ${assetSnapshot.exists}`); - if (exists && assetSnapshot.exists) { - const asset = assetSnapshot.data() as AssetFile; - const tempFilePath = `${os.tmpdir()}/assets-${assetId}`; - let filename = `${asset.name}${asset.extension}`; - // apply resize for valid 'w' parameter and images - if (asset.type.startsWith('image/') && width && !Number.isNaN(width)) { - if (asset.type === 'image/webp' || asset.type === 'image/gif') { - // possible animated or single frame webp/gif - const [file] = await assetFile.download(); - let sharpFile = sharp(file); - const sharpFileMetadata = await sharpFile.metadata(); - const isAnimated = sharpFileMetadata.pages !== undefined; - if (thumbnail && isAnimated) { - // Thumbnail with Animation: Remove animations, to reduce load - filename = `${asset.name}-w${width}-thumbnail${asset.extension}`; - sharpFile = sharp(file, { page: 0, pages: 1 }); - await sharpFile.resize(parseInt(width.toString(), 10)).toFile(tempFilePath); - } else if (thumbnail && !isAnimated) { - // thumbnail without Animation - filename = `${asset.name}-w${width}-thumbnail${asset.extension}`; - // single frame webp/gif - await sharpFile.resize(parseInt(width.toString(), 10)).toFile(tempFilePath); - } else { - // Animated - filename = `${asset.name}-w${width}${asset.extension}`; - // animated webp/gif - // TODO no way now to resize animated files - await assetFile.download({ destination: tempFilePath }); - } - } else if (asset.type === 'image/svg+xml') { - // svg, cannot resize - await assetFile.download({ destination: tempFilePath }); - } else { - // other images - filename = `${asset.name}-w${width}${asset.extension}`; - const [file] = await assetFile.download(); - await sharp(file).resize(parseInt(width.toString(), 10)).toFile(tempFilePath); - } - } else if (asset.type.startsWith('video/') && width && !Number.isNaN(width) && thumbnail) { - await assetFile.download({ destination: tempFilePath }); - await extractThumbnail(tempFilePath, `screenshot-${assetId}.webp`); - filename = `${asset.name}-w${width}-thumbnail.webp`; - overwriteType = 'image/webp'; - await sharp(`${os.tmpdir()}/screenshot-${assetId}.webp`).resize(parseInt(width.toString(), 10)).toFile(tempFilePath); - } else { - await assetFile.download({ destination: tempFilePath }); - } - let disposition = `inline; filename="${encodeURI(filename)}"`; - if (download !== undefined) { - disposition = `form-data; filename="${encodeURI(filename)}"`; - } - res - .header('Cache-Control', `public, max-age=${CACHE_ASSET_MAX_AGE}, s-maxage=${CACHE_ASSET_MAX_AGE}`) - .header('Content-Disposition', disposition) - .contentType(overwriteType || asset.type) - .sendFile(tempFilePath); - return; - } else { - res.status(404).header('Cache-Control', 'no-cache').send(new HttpsError('not-found', 'Not found.')); - return; - } -}); +expressApp.use(express.json()); +expressApp.use('/', CDN); +expressApp.use('/', DEV_TOOLS); +expressApp.use('/', MANAGE); export const v1 = onRequest({ memory: '512MiB' }, expressApp); diff --git a/functions/src/v1/cdn.ts b/functions/src/v1/cdn.ts new file mode 100644 index 00000000..a223f9ca --- /dev/null +++ b/functions/src/v1/cdn.ts @@ -0,0 +1,448 @@ +import { Router } from 'express'; +import { Query } from 'firebase-admin/firestore'; +import { logger } from 'firebase-functions'; +import { HttpsError } from 'firebase-functions/v2/https'; +import os from 'os'; +import sharp from 'sharp'; +import { bucket, CACHE_ASSET_MAX_AGE, CACHE_MAX_AGE, CACHE_SHARE_MAX_AGE, firestoreService, TEN_MINUTES } from '../config'; +import { + AssetFile, + Content, + ContentDocumentApi, + ContentDocumentStorage, + ContentKind, + ContentLink, + Space, + TokenPermission, +} from '../models'; +import { + contentLocaleCachePath, + extractThumbnail, + findContentByFullSlug, + findSpaceById, + identifySpaceLocale, + resolveLinks, + resolveReferences, + spaceContentCachePath, + translationLocaleCachePath, + spaceTranslationCachePath, +} from '../services'; +import { + requireContentPermissions, + requireTokenPermissions, + RequestWithToken, + requireTranslationPermissions, +} from './middleware/query-auth.middleware'; + +// eslint-disable-next-line new-cap +export const CDN = Router(); + +CDN.get('/api/v1/spaces/:spaceId/translations/:locale', requireTranslationPermissions(), async (req: RequestWithToken, res) => { + logger.info('v1 spaces translations params : ' + JSON.stringify(req.params)); + logger.info('v1 spaces translations query : ' + JSON.stringify(req.query)); + const { spaceId, locale } = req.params; + const { cv, version } = req.query; + const token = req.tokenId; + + const spaceSnapshot = await findSpaceById(spaceId).get(); + if (!spaceSnapshot.exists) { + res + .status(404) + .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) + .send(new HttpsError('not-found', 'Not found')); + return; + } + + const cachePath = spaceTranslationCachePath(spaceId); + const [exists] = await bucket.file(cachePath).exists(); + if (exists) { + const [metadata] = await bucket.file(cachePath).getMetadata(); + logger.info('v1 spaces translations cache meta : ' + JSON.stringify(metadata)); + if (cv === undefined || cv != metadata.generation) { + let url = `/api/v1/spaces/${spaceId}/translations/${locale}?cv=${metadata.generation}`; + if (version) { + url += `&version=${version}`; + } + if (token) { + url += `&token=${token}`; + } + logger.info(`v1 spaces translate redirect to => ${url}`); + res.redirect(url); + return; + } else { + const space = spaceSnapshot.data() as Space; + let actualLocale = locale; + if (!space.locales.some(it => it.id === locale)) { + actualLocale = space.localeFallback.id; + } + const filePath = translationLocaleCachePath(spaceId, actualLocale, version as string | undefined); + bucket + .file(filePath) + .download() + .then(content => { + res + .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) + .contentType('application/json; charset=utf-8') + .send(content.toString()); + }) + .catch(() => { + res.status(404).send(new HttpsError('not-found', 'File not found, Publish first.')); + }); + } + } else { + res.status(404).send(new HttpsError('not-found', 'File not found, Publish first.')); + return; + } +}); + +CDN.get( + '/api/v1/spaces/:spaceId/links', + requireTokenPermissions([TokenPermission.CONTENT_PUBLIC, TokenPermission.CONTENT_DRAFT, TokenPermission.DEV_TOOLS]), + async (req: RequestWithToken, res) => { + logger.info('v1 spaces links params: ' + JSON.stringify(req.params)); + logger.info('v1 spaces links query: ' + JSON.stringify(req.query)); + const { spaceId } = req.params; + const { kind, parentSlug, excludeChildren, cv } = req.query; + const token = req.tokenId; + + const spaceSnapshot = await findSpaceById(spaceId).get(); + if (!spaceSnapshot.exists) { + res + .status(404) + .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) + .send(new HttpsError('not-found', 'Not found')); + return; + } + + const cachePath = spaceContentCachePath(spaceId); + const [exists] = await bucket.file(cachePath).exists(); + if (exists) { + const [metadata] = await bucket.file(cachePath).getMetadata(); + logger.info('v1 spaces links cache meta : ' + JSON.stringify(metadata)); + if (cv === undefined || cv != metadata.generation) { + let url = `/api/v1/spaces/${spaceId}/links?cv=${metadata.generation}`; + if (parentSlug !== undefined) { + url += `&parentSlug=${parentSlug}`; + } + if (excludeChildren === 'true') { + url += `&excludeChildren=${excludeChildren}`; + } + if (kind === ContentKind.DOCUMENT || kind === ContentKind.FOLDER) { + url += `&kind=${kind}`; + } + if (token) { + url += `&token=${token}`; + } + res.redirect(url); + return; + } else { + let contentsQuery: Query = firestoreService.collection(`spaces/${spaceId}/contents`); + if (parentSlug) { + if (excludeChildren === 'true') { + contentsQuery = contentsQuery.where('parentSlug', '==', parentSlug); + } else { + contentsQuery = contentsQuery.where('parentSlug', '>=', parentSlug).where('parentSlug', '<', `${parentSlug}/~`); + } + } else { + if (excludeChildren === 'true') { + contentsQuery = contentsQuery.where('parentSlug', '==', ''); + } + } + if (kind && (kind === ContentKind.DOCUMENT || kind === ContentKind.FOLDER)) { + contentsQuery = contentsQuery.where('kind', '==', kind); + } + const contentsSnapshot = await contentsQuery.get(); + + const response: Record = contentsSnapshot.docs + .map(contentSnapshot => { + const content = contentSnapshot.data() as Content; + const link: ContentLink = { + id: contentSnapshot.id, + kind: content.kind, + name: content.name, + slug: content.slug, + fullSlug: content.fullSlug, + parentSlug: content.parentSlug, + createdAt: content.createdAt.toDate().toISOString(), + updatedAt: content.updatedAt.toDate().toISOString(), + }; + if (content.kind === ContentKind.DOCUMENT) { + link.publishedAt = content.publishedAt?.toDate().toISOString(); + } + return link; + }) + .reduce( + (acc, item) => { + acc[item.id] = item; + return acc; + }, + {} as Record + ); + res + .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) + .contentType('application/json; charset=utf-8') + .send(response); + return; + } + } else { + res.status(404).send(new HttpsError('not-found', 'File not found, Publish first.')); + return; + } + } +); + +CDN.get('/api/v1/spaces/:spaceId/contents/slugs/*slug', requireContentPermissions(), async (req: RequestWithToken, res) => { + logger.info('v1 spaces content params: ' + JSON.stringify(req.params)); + logger.info('v1 spaces content query: ' + JSON.stringify(req.query)); + const { spaceId } = req.params; + const { cv, locale, version, resolveReference, resolveLink } = req.query; + const token = req.tokenId; + const params: Record = req.params; + const slug = params['slug'] as string[]; + const fullSlug = slug.join('/'); + let contentId = ''; + + const spaceSnapshot = await findSpaceById(spaceId).get(); + if (!spaceSnapshot.exists) { + res + .status(404) + .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) + .send(new HttpsError('not-found', 'Not found')); + return; + } + + const contentsSnapshot = await findContentByFullSlug(spaceId, fullSlug).get(); + // logger.info('v1 spaces contents', contentsSnapshot.size); + if (contentsSnapshot.empty) { + // No records in database + res.status(404).send(new HttpsError('not-found', 'Slug not found')); + return; + } else { + contentId = contentsSnapshot.docs[0].id; + } + const cacheCheckPath = spaceContentCachePath(spaceId); + const cacheCheckFile = bucket.file(cacheCheckPath); + logger.info('v1 spaces content cachePath: ' + cacheCheckPath); + const [exists] = await cacheCheckFile.exists(); + if (exists) { + const [metadata] = await cacheCheckFile.getMetadata(); + // logger.info('v1 spaces content cache meta : ' + JSON.stringify(metadata)); + if (cv === undefined || cv != metadata.generation) { + let url = `/api/v1/spaces/${spaceId}/contents/slugs/${fullSlug}?cv=${metadata.generation}`; + if (locale) { + url += `&locale=${locale}`; + } + if (version) { + url += `&version=${version}`; + } + if (token) { + url += `&token=${token}`; + } + if (resolveReference) { + url += `&resolveReference=${resolveReference}`; + } + if (resolveLink) { + url += `&resolveLink=${resolveLink}`; + } + logger.info(`v1 spaces content redirect to => ${url}`); + res.redirect(url); + return; + } else { + const space = spaceSnapshot.data() as Space; + const actualLocale = identifySpaceLocale(space, locale as string | undefined); + logger.info(`v1 spaces content slug locale identified as => ${actualLocale}`); + const filePath = contentLocaleCachePath(spaceId, contentId, actualLocale, version as string | undefined); + bucket + .file(filePath) + .download() + .then(async content => { + const contentData: ContentDocumentStorage = JSON.parse(content.toString()); + const { links, references, ...rest } = contentData; + const response: ContentDocumentApi = { ...rest }; + if (resolveLink === 'true') { + logger.info(`v1 spaces content id resolve links => ${links}`); + response.links = await resolveLinks(spaceId, contentData); + } + if (resolveReference === 'true') { + logger.info(`v1 spaces content slug resolve refs => ${references}`); + response.references = await resolveReferences(spaceId, contentData, actualLocale, version as string | undefined); + } + res + .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) + .contentType('application/json; charset=utf-8') + .send(response); + }) + .catch(() => { + res + .status(404) + .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`) + .send(new HttpsError('not-found', 'File not found, on path. Please Publish again. The content is cached for 10 minutes.')); + }); + } + } else { + res + .status(404) + .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`) + .send(new HttpsError('not-found', 'File not found, Publish first. The content is cached for 10 minutes.')); + return; + } +}); + +CDN.get('/api/v1/spaces/:spaceId/contents/:contentId', requireContentPermissions(), async (req: RequestWithToken, res) => { + logger.info('v1 spaces content params: ' + JSON.stringify(req.params)); + logger.info('v1 spaces content query: ' + JSON.stringify(req.query)); + const { spaceId, contentId } = req.params; + const { cv, locale, version, resolveReference, resolveLink } = req.query; + const token = req.tokenId; + + const spaceSnapshot = await findSpaceById(spaceId).get(); + if (!spaceSnapshot.exists) { + logger.info('v1 spaces content Space not exist: ' + spaceId); + res + .status(404) + .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) + .send(new HttpsError('not-found', 'Not found')); + return; + } + + const cacheCheckPath = spaceContentCachePath(spaceId); + const cacheCheckFile = bucket.file(cacheCheckPath); + logger.info('v1 spaces content cachePath: ' + cacheCheckPath); + const [exists] = await cacheCheckFile.exists(); + if (exists) { + const [metadata] = await cacheCheckFile.getMetadata(); + // logger.info('v1 spaces content cache meta : ' + JSON.stringify(metadata)); + if (cv === undefined || cv != metadata.generation) { + let url = `/api/v1/spaces/${spaceId}/contents/${contentId}?cv=${metadata.generation}`; + if (locale) { + url += `&locale=${locale}`; + } + if (version) { + url += `&version=${version}`; + } + if (token) { + url += `&token=${token}`; + } + if (resolveReference) { + url += `&resolveReference=${resolveReference}`; + } + if (resolveLink) { + url += `&resolveLink=${resolveLink}`; + } + logger.info(`v1 spaces content redirect to => ${url}`); + res.redirect(url); + return; + } else { + const space = spaceSnapshot.data() as Space; + const actualLocale = identifySpaceLocale(space, locale as string | undefined); + logger.info(`v1 spaces content id locale identified as => ${actualLocale}`); + const filePath = contentLocaleCachePath(spaceId, contentId, actualLocale, version as string | undefined); + bucket + .file(filePath) + .download() + .then(async content => { + const contentData: ContentDocumentStorage = JSON.parse(content.toString()); + const { links, references, ...rest } = contentData; + const response: ContentDocumentApi = { ...rest }; + if (resolveLink === 'true') { + logger.info(`v1 spaces content id resolve links => ${links}`); + response.links = await resolveLinks(spaceId, contentData); + } + if (resolveReference === 'true') { + logger.info(`v1 spaces content id resolve refs => ${references}`); + response.references = await resolveReferences(spaceId, contentData, actualLocale, version as string | undefined); + } + res + .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) + .contentType('application/json; charset=utf-8') + .send(response); + }) + .catch(() => { + res + .status(404) + .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`) + .send(new HttpsError('not-found', 'File not found, on path. Please Publish again. The content is cached for 10 minutes.')); + }); + } + } else { + res + .status(404) + .header('Cache-Control', `public, max-age=${TEN_MINUTES}, s-maxage=${TEN_MINUTES}`) + .send(new HttpsError('not-found', 'File not found, Publish first. The content is cached for 10 minutes.')); + return; + } +}); + +CDN.get('/api/v1/spaces/:spaceId/assets/:assetId', async (req, res) => { + logger.info('v1 spaces asset params: ' + JSON.stringify(req.params)); + logger.info('v1 spaces asset query: ' + JSON.stringify(req.query)); + const { spaceId, assetId } = req.params; + const { w: width, download, thumbnail } = req.query; + + const assetFile = bucket.file(`spaces/${spaceId}/assets/${assetId}/original`); + const [exists] = await assetFile.exists(); + const assetSnapshot = await firestoreService.doc(`spaces/${spaceId}/assets/${assetId}`).get(); + let overwriteType: string | undefined; + logger.info(`v1 spaces asset: ${exists} & ${assetSnapshot.exists}`); + if (exists && assetSnapshot.exists) { + const asset = assetSnapshot.data() as AssetFile; + const tempFilePath = `${os.tmpdir()}/assets-${assetId}`; + let filename = `${asset.name}${asset.extension}`; + // apply resize for valid 'w' parameter and images + if (asset.type.startsWith('image/') && width && !Number.isNaN(width)) { + if (asset.type === 'image/webp' || asset.type === 'image/gif') { + // possible animated or single frame webp/gif + const [file] = await assetFile.download(); + let sharpFile = sharp(file); + const sharpFileMetadata = await sharpFile.metadata(); + const isAnimated = sharpFileMetadata.pages !== undefined; + if (thumbnail && isAnimated) { + // Thumbnail with Animation: Remove animations, to reduce load + filename = `${asset.name}-w${width}-thumbnail${asset.extension}`; + sharpFile = sharp(file, { page: 0, pages: 1 }); + await sharpFile.resize(parseInt(width.toString(), 10)).toFile(tempFilePath); + } else if (thumbnail && !isAnimated) { + // thumbnail without Animation + filename = `${asset.name}-w${width}-thumbnail${asset.extension}`; + // single frame webp/gif + await sharpFile.resize(parseInt(width.toString(), 10)).toFile(tempFilePath); + } else { + // Animated + filename = `${asset.name}-w${width}${asset.extension}`; + // animated webp/gif + // TODO no way now to resize animated files + await assetFile.download({ destination: tempFilePath }); + } + } else if (asset.type === 'image/svg+xml') { + // svg, cannot resize + await assetFile.download({ destination: tempFilePath }); + } else { + // other images + filename = `${asset.name}-w${width}${asset.extension}`; + const [file] = await assetFile.download(); + await sharp(file).resize(parseInt(width.toString(), 10)).toFile(tempFilePath); + } + } else if (asset.type.startsWith('video/') && width && !Number.isNaN(width) && thumbnail) { + await assetFile.download({ destination: tempFilePath }); + await extractThumbnail(tempFilePath, `screenshot-${assetId}.webp`); + filename = `${asset.name}-w${width}-thumbnail.webp`; + overwriteType = 'image/webp'; + await sharp(`${os.tmpdir()}/screenshot-${assetId}.webp`).resize(parseInt(width.toString(), 10)).toFile(tempFilePath); + } else { + await assetFile.download({ destination: tempFilePath }); + } + let disposition = `inline; filename="${encodeURI(filename)}"`; + if (download !== undefined) { + disposition = `form-data; filename="${encodeURI(filename)}"`; + } + res + .header('Cache-Control', `public, max-age=${CACHE_ASSET_MAX_AGE}, s-maxage=${CACHE_ASSET_MAX_AGE}`) + .header('Content-Disposition', disposition) + .contentType(overwriteType || asset.type) + .sendFile(tempFilePath); + return; + } else { + res.status(404).header('Cache-Control', 'no-cache').send(new HttpsError('not-found', 'Not found.')); + return; + } +}); diff --git a/functions/src/v1/dev-tools.ts b/functions/src/v1/dev-tools.ts new file mode 100644 index 00000000..2983eb89 --- /dev/null +++ b/functions/src/v1/dev-tools.ts @@ -0,0 +1,59 @@ +import { Router } from 'express'; +import { logger } from 'firebase-functions'; +import { HttpsError } from 'firebase-functions/v2/https'; +import { CACHE_MAX_AGE, CACHE_SHARE_MAX_AGE } from '../config'; +import { Schema, Space, TokenPermission } from '../models'; +import { findSchemas, findSpaceById, generateOpenApi } from '../services'; +import { requireTokenPermissions, RequestWithToken } from './middleware/query-auth.middleware'; + +// eslint-disable-next-line new-cap +export const DEV_TOOLS = Router(); + +DEV_TOOLS.get('/api/v1/spaces/:spaceId', requireTokenPermissions([TokenPermission.DEV_TOOLS]), async (req: RequestWithToken, res) => { + logger.info('v1 spaces params : ' + JSON.stringify(req.params)); + logger.info('v1 spaces query : ' + JSON.stringify(req.query)); + const { spaceId } = req.params; + + const spaceSnapshot = await findSpaceById(spaceId).get(); + if (!spaceSnapshot.exists) { + res + .status(404) + .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) + .send(new HttpsError('not-found', 'Not found')); + return; + } + const space = spaceSnapshot.data() as Space; + + res.json({ + id: spaceSnapshot.id, + name: space.name, + locales: space.locales, + localeFallback: space.localeFallback, + createdAt: space.createdAt.toDate().toISOString(), + updatedAt: space.updatedAt.toDate().toISOString(), + }); +}); + +DEV_TOOLS.get( + '/api/v1/spaces/:spaceId/open-api', + requireTokenPermissions([TokenPermission.DEV_TOOLS]), + async (req: RequestWithToken, res) => { + logger.info('v1 spaces content params: ' + JSON.stringify(req.params)); + logger.info('v1 spaces content query: ' + JSON.stringify(req.query)); + const { spaceId } = req.params; + + const spaceSnapshot = await findSpaceById(spaceId).get(); + if (!spaceSnapshot.exists) { + logger.info('v1 spaces content Space not exist: ' + spaceId); + res + .status(404) + .header('Cache-Control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_SHARE_MAX_AGE}`) + .send(new HttpsError('not-found', 'Not found')); + return; + } + + const schemasSnapshot = await findSchemas(spaceId).get(); + const schemaById = new Map(schemasSnapshot.docs.map(it => [it.id, it.data() as Schema])); + res.json(generateOpenApi(schemaById)); + } +); diff --git a/functions/src/v1/manage.ts b/functions/src/v1/manage.ts new file mode 100644 index 00000000..843021b5 --- /dev/null +++ b/functions/src/v1/manage.ts @@ -0,0 +1,130 @@ +import { Router } from 'express'; +import { FieldValue, UpdateData, WithFieldValue } from 'firebase-admin/firestore'; +import { HttpsError } from 'firebase-functions/https'; +import { logger } from 'firebase-functions/v2'; +import { firestoreService } from '../config'; +import { Space, TokenPermission, Translation, TranslationType, zTranslationUpdateSchema } from '../models'; +import { findSpaceById, findTranslationById, findTranslations } from '../services'; +import { RequestWithToken, requireTokenPermissions } from './middleware/api-key-auth.middleware'; + +// eslint-disable-next-line new-cap +export const MANAGE = Router(); + +MANAGE.post( + '/api/v1/spaces/:spaceId/translations/:locale', + requireTokenPermissions([TokenPermission.DEV_TOOLS]), + async (req: RequestWithToken, res) => { + logger.info('v1 spaces translations update params : ' + JSON.stringify(req.params)); + logger.info('v1 spaces translations update body : ' + JSON.stringify(req.body)); + // req.token contains the validated token object + // req.tokenId contains the token string + const { spaceId, locale } = req.params; + const body = zTranslationUpdateSchema.safeParse(req.body); + if (body.success) { + const { dryRun, type, values } = body.data; + const spaceSnapshot = await findSpaceById(spaceId).get(); + const space = spaceSnapshot.data() as Space; + if (!space.locales.some(it => it.id === locale)) { + logger.error(`Locale ${locale} is not in space locales`); + res + .status(400) + .send(new HttpsError('invalid-argument', 'Locale not supported by this space', `Locale ${locale} is not in space locales`)); + return; + } + if (type === 'add-missing') { + // Handle adding missing translations + const fetchPromises = Object.getOwnPropertyNames(values).map(id => findTranslationById(spaceId, id).get()); + const snapshots = await Promise.all(fetchPromises); + const translationIds = snapshots.filter(it => !it.exists).map(it => it.id); + if (translationIds.length === 0) { + logger.info('No missing translations to add'); + res.status(200).send({ message: 'No missing translations to add' }); + return; + } + if (dryRun) { + logger.info(`[DryRun] Would add ${translationIds.length} missing translations`, translationIds); + res + .status(200) + .send({ message: `[DryRun] Would add ${translationIds.length} missing translations`, ids: translationIds, dryRun: true }); + return; + } + // Now `missing` contains the IDs of translations that are missing and can be added + const bulk = firestoreService.bulkWriter(); + translationIds.forEach(id => { + const ref = findTranslationById(spaceId, id); + const data: WithFieldValue = { + type: TranslationType.STRING, + locales: { + [locale]: values[id], + }, + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }; + bulk.create(ref, data); + }); + await bulk.close(); + logger.info(`Added ${translationIds.length} missing translations`, translationIds); + res.status(200).send({ message: `Added ${translationIds.length} missing translations`, ids: translationIds }); + return; + } else if (type === 'update-existing') { + // Handle updating existing translations + const fetchPromises = Object.getOwnPropertyNames(values).map(id => findTranslationById(spaceId, id).get()); + const snapshots = await Promise.all(fetchPromises); + const translationIds = snapshots.filter(it => it.exists).map(it => it.id); + if (translationIds.length === 0) { + logger.info('No translations to update'); + res.status(200).send({ message: 'No translations to update' }); + return; + } + if (dryRun) { + logger.info(`[DryRun] Would update ${translationIds.length} translations`, translationIds); + res + .status(200) + .send({ message: `[DryRun] Would update ${translationIds.length} translations`, ids: translationIds, dryRun: true }); + return; + } + // Now `missing` contains the IDs of translations that are missing and can be added + const bulk = firestoreService.bulkWriter(); + translationIds.forEach(id => { + const ref = findTranslationById(spaceId, id); + const data: UpdateData = { + updatedAt: FieldValue.serverTimestamp(), + }; + data[`locales.${locale}`] = values[id]; + bulk.update(ref, data); + }); + await bulk.close(); + logger.info(`Updated ${translationIds.length} translations`, translationIds); + res.status(200).send({ message: `Updated ${translationIds.length} translations`, ids: translationIds }); + return; + } else if (type === 'delete-missing') { + // Handle deleting missing translations + const translationsSnapshot = await findTranslations(spaceId).get(); + const translationIds = translationsSnapshot.docs.filter(it => values[it.id] === undefined).map(it => it.id); + if (translationIds.length === 0) { + logger.info('No translations to delete'); + res.status(200).send({ message: 'No translations to delete' }); + return; + } + if (dryRun) { + logger.info(`[DryRun] Would delete ${translationIds.length} missing translations`, translationIds); + res + .status(200) + .send({ message: `[DryRun] Would delete ${translationIds.length} missing translations`, ids: translationIds, dryRun: true }); + return; + } + const bulk = firestoreService.bulkWriter(); + translationIds.forEach(id => { + const ref = findTranslationById(spaceId, id); + bulk.delete(ref); + }); + await bulk.close(); + logger.info(`Delete ${translationIds.length} missing translations`, translationIds); + res.status(200).send({ message: `Added ${translationIds.length} missing translations`, ids: translationIds }); + return; + } + } + logger.error('Bad request body', body.error); + res.status(400).send(new HttpsError('invalid-argument', 'Bad request body', body.error)); + } +); diff --git a/functions/src/v1/middleware/PERMISSIONS.md b/functions/src/v1/middleware/PERMISSIONS.md new file mode 100644 index 00000000..9a0df009 --- /dev/null +++ b/functions/src/v1/middleware/PERMISSIONS.md @@ -0,0 +1,76 @@ +# V1 API Endpoints - Permission Matrix + +| Endpoint | Method | Required Permissions | Notes | +|----------|--------|-------------------|------------------------------------------------------------| +| `/api/v1/spaces/:spaceId` | GET | DEV_TOOLS | Space metadata | +| `/api/v1/spaces/:spaceId/translations/:locale` | GET | PUBLIC \| DRAFT \| DEV_TOOLS | Translation files | +| `/api/v1/spaces/:spaceId/translations/:locale` | POST | DEV_TOOLS | Update translations | +| `/api/v1/spaces/:spaceId/links` | GET | PUBLIC \| DRAFT \| DEV_TOOLS | Content links | +| `/api/v1/spaces/:spaceId/contents/slugs/*slug` | GET | **Conditional** | Published: PUBLIC \| DRAFT \| DEV_TOOLS,
Draft: DRAFT \| DEV_TOOLS| +| `/api/v1/spaces/:spaceId/contents/:contentId` | GET | **Conditional** | Published: PUBLIC \| DRAFT \| DEV_TOOLS,
Draft: DRAFT \| DEV_TOOLS | +| `/api/v1/spaces/:spaceId/open-api` | GET | DEV_TOOLS | OpenAPI schema | +| `/api/v1/spaces/:spaceId/assets/:assetId` | GET | None | Public asset access | + +## Conditional Permissions + +Content endpoints (`/contents/:contentId` and `/contents/slugs/*`) use conditional logic: +- **Without `version` parameter**: Requires `PUBLIC` OR `DRAFT` permission (published content) +- **With `version` parameter**: Requires `DRAFT` permission only (draft/preview content) + +## Usage Examples + +```typescript +// Basic endpoint with multiple permissions +router.get( + '/api/v1/spaces/:spaceId', + requireTokenPermissions([TokenPermission.PUBLIC, TokenPermission.DRAFT, TokenPermission.DEV_TOOLS]), + async (req: RequestWithToken, res) => { + // Handler code + } +); + +// Endpoint with single permission +router.get( + '/api/v1/spaces/:spaceId/open-api', + requireTokenPermissions([TokenPermission.DEV_TOOLS]), + async (req: RequestWithToken, res) => { + // Handler code + } +); + +// Content endpoint with conditional logic +router.get( + '/api/v1/spaces/:spaceId/contents/:contentId', + requireContentPermissions(), + async (req: RequestWithToken, res) => { + // Handler code + } +); +``` + +## Request Requirements + +All endpoints expect: +1. **Route Parameter**: `spaceId` in the URL path +2. **Query Parameter**: `token` with a valid 20-character token ID + +Example request: +``` +GET /api/v1/spaces/my-space-id/contents/abc123?token=12345678901234567890 +``` + +## Token Permissions + +Token permissions are stored in Firestore and can be: +- **V1 Tokens** (legacy): Automatically have `PUBLIC` and `DRAFT` permissions +- **V2 Tokens**: Have explicit permission arrays defined in the token document + +```typescript +interface TokenV2 { + version: 2; + name: string; + permissions: TokenPermission[]; // ['PUBLIC', 'DRAFT', 'DEV_TOOLS'] + createdAt: Timestamp; + updatedAt: Timestamp; +} +``` diff --git a/functions/src/v1/middleware/README.md b/functions/src/v1/middleware/README.md new file mode 100644 index 00000000..e65d3d82 --- /dev/null +++ b/functions/src/v1/middleware/README.md @@ -0,0 +1,137 @@ +# Authentication Middleware + +This middleware provides token-based authentication and role-based authorization for V1 API endpoints. + +## Features + +- Validates token format and existence +- Fetches token data from Firestore +- Checks token permissions against required roles +- Attaches token information to the request object +- Provides clear error messages for authentication failures + +## Usage + +### Basic Usage + +```typescript +import { Router } from 'express'; +import { TokenPermission } from '../models'; +import { requireTokenPermissions, RequestWithToken } from './middleware'; + +const router = Router(); + +// Require a single permission +router.get( + '/api/v1/spaces/:spaceId/content', + requireTokenPermissions([TokenPermission.PUBLIC]), + async (req: RequestWithToken, res) => { + // Access validated token + const token = req.token; + const tokenId = req.tokenId; + + // Your route logic here + } +); + +// Require multiple permissions (token must have at least one) +router.post( + '/api/v1/spaces/:spaceId/content', + requireTokenPermissions([TokenPermission.DRAFT, TokenPermission.DEV_TOOLS]), + async (req: RequestWithToken, res) => { + // Only tokens with DRAFT or DEV_TOOLS permission can access this + } +); +``` + +### Using the Helper Function + +For single permission checks, you can use the convenience function: + +```typescript +import { requireTokenPermission } from './middleware'; + +router.get( + '/api/v1/spaces/:spaceId/dev-tools', + requireTokenPermission(TokenPermission.DEV_TOOLS), + async (req: RequestWithToken, res) => { + // Only tokens with DEV_TOOLS permission + } +); +``` + +## Request Requirements + +The middleware expects: +1. **spaceId** in route parameters: `/api/v1/spaces/:spaceId/...` +2. **token** in query parameters: `?token=your-token-here` + +Example request: +``` +POST /api/v1/spaces/my-space-id/content?token=abc123xyz456 +``` + +## Response Codes + +- **401 Unauthorized**: Invalid token format, token not found +- **403 Forbidden**: Token exists but lacks required permissions +- **400 Bad Request**: Missing required spaceId parameter +- **500 Internal Server Error**: Error during token verification + +## Token Permissions + +Available permissions (from `TokenPermission` enum): +- `PUBLIC` - Read-only access to public content +- `DRAFT` - Access to draft content +- `DEV_TOOLS` - Access to development tools + +## Extended Request Type + +When using the middleware, type your request handler with `RequestWithToken` to access: + +```typescript +interface RequestWithToken extends Request { + token?: Token; // The full token object from Firestore + tokenId?: string; // The token ID string +} +``` + +## Example Integration + +```typescript +import { Router } from 'express'; +import { TokenPermission } from '../models'; +import { requireTokenPermissions, RequestWithToken } from './middleware'; + +export const API = Router(); + +// Public content - requires PUBLIC permission +API.get( + '/api/v1/spaces/:spaceId/content/:contentId', + requireTokenPermissions([TokenPermission.PUBLIC]), + async (req: RequestWithToken, res) => { + const { spaceId, contentId } = req.params; + // Fetch and return content + } +); + +// Draft content - requires DRAFT or DEV_TOOLS permission +API.get( + '/api/v1/spaces/:spaceId/drafts/:draftId', + requireTokenPermissions([TokenPermission.DRAFT, TokenPermission.DEV_TOOLS]), + async (req: RequestWithToken, res) => { + const { spaceId, draftId } = req.params; + // Fetch and return draft content + } +); + +// Admin operations - requires DEV_TOOLS permission +API.post( + '/api/v1/spaces/:spaceId/settings', + requireTokenPermission(TokenPermission.DEV_TOOLS), + async (req: RequestWithToken, res) => { + const { spaceId } = req.params; + // Update space settings + } +); +``` diff --git a/functions/src/v1/middleware/api-key-auth.middleware.ts b/functions/src/v1/middleware/api-key-auth.middleware.ts new file mode 100644 index 00000000..91deaa7c --- /dev/null +++ b/functions/src/v1/middleware/api-key-auth.middleware.ts @@ -0,0 +1,77 @@ +import { NextFunction, Request, Response } from 'express'; +import { HttpsError } from 'firebase-functions/v2/https'; +import { Token, TokenPermission } from '../../models'; +import { findTokenById, validateToken } from '../../services'; +import { canPerformAny } from '../../utils/api-auth-utils'; + +const AUTH_HEADER = 'X-API-KEY'; +/** + * Extended Express Request with token information + */ +export interface RequestWithToken extends Request { + token?: Token; + tokenId?: string; +} + +/** + * Middleware factory that creates an authentication middleware checking for specific token permissions + * @param {TokenPermission[]} requiredPermissions - List of permissions that the token must have + * @return {Function} Express middleware function + */ +export function requireTokenPermissions(requiredPermissions: TokenPermission[]) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const { spaceId } = req.params; + const tokenId = req.header(AUTH_HEADER); + + // Validate token format + if (!validateToken(tokenId)) { + res.status(401).send(new HttpsError('unauthenticated', 'Invalid or missing token')); + return; + } + + // Validate spaceId exists + if (!spaceId) { + res.status(400).send(new HttpsError('invalid-argument', 'Space ID is required')); + return; + } + + try { + // Fetch token from Firestore + const tokenSnapshot = await findTokenById(spaceId, tokenId as string).get(); + + if (!tokenSnapshot.exists) { + res.status(401).send(new HttpsError('unauthenticated', 'Token not found')); + return; + } + + const token = tokenSnapshot.data() as Token; + + // Check if token has any of the required permissions + const hasPermission = canPerformAny(requiredPermissions, token); + + if (!hasPermission) { + res + .status(403) + .send(new HttpsError('permission-denied', `Token does not have required permissions: ${requiredPermissions.join(', ')}`)); + return; + } + + // Attach token and tokenId to request for use in route handlers + (req as RequestWithToken).token = token; + (req as RequestWithToken).tokenId = tokenId as string; + + next(); + } catch (error) { + res.status(500).send(new HttpsError('internal', 'Failed to verify token')); + } + }; +} + +/** + * Middleware that checks for a single token permission + * @param {TokenPermission} permission - The permission required + * @return {Function} Express middleware function + */ +export function requireTokenPermission(permission: TokenPermission) { + return requireTokenPermissions([permission]); +} diff --git a/functions/src/v1/middleware/query-auth.middleware.ts b/functions/src/v1/middleware/query-auth.middleware.ts new file mode 100644 index 00000000..78b2fe07 --- /dev/null +++ b/functions/src/v1/middleware/query-auth.middleware.ts @@ -0,0 +1,204 @@ +import { NextFunction, Request, Response } from 'express'; +import { HttpsError } from 'firebase-functions/v2/https'; +import { Token, TokenPermission } from '../../models'; +import { findTokenById, validateToken } from '../../services'; +import { canPerformAny } from '../../utils/api-auth-utils'; + +/** + * Extended Express Request with token information + */ +export interface RequestWithToken extends Request { + token?: Token; + tokenId?: string; +} + +/** + * Middleware factory that creates an authentication middleware checking for specific token permissions + * @param {TokenPermission[]} requiredPermissions - List of permissions that the token must have + * @return {Function} Express middleware function + */ +export function requireTokenPermissions(requiredPermissions: TokenPermission[]) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const { spaceId } = req.params; + const { token: tokenId } = req.query; + + // Validate token format + if (!validateToken(tokenId)) { + res.status(401).send(new HttpsError('unauthenticated', 'Invalid or missing token')); + return; + } + + // Validate spaceId exists + if (!spaceId) { + res.status(400).send(new HttpsError('invalid-argument', 'Space ID is required')); + return; + } + + try { + // Fetch token from Firestore + const tokenSnapshot = await findTokenById(spaceId, tokenId as string).get(); + + if (!tokenSnapshot.exists) { + res.status(401).send(new HttpsError('unauthenticated', 'Token not found')); + return; + } + + const token = tokenSnapshot.data() as Token; + + // Check if token has any of the required permissions + const hasPermission = canPerformAny(requiredPermissions, token); + + if (!hasPermission) { + res + .status(403) + .send(new HttpsError('permission-denied', `Token does not have required permissions: ${requiredPermissions.join(', ')}`)); + return; + } + + // Attach token and tokenId to request for use in route handlers + (req as RequestWithToken).token = token; + (req as RequestWithToken).tokenId = tokenId as string; + + next(); + } catch (error) { + res.status(500).send(new HttpsError('internal', 'Failed to verify token')); + } + }; +} + +/** + * Middleware that checks for a single token permission + * @param {TokenPermission} permission - The permission required + * @return {Function} Express middleware function + */ +export function requireTokenPermission(permission: TokenPermission) { + return requireTokenPermissions([permission]); +} + +/** + * Middleware for content endpoints that checks permissions based on version query parameter + * Published content (no version) requires PUBLIC or DRAFT permission + * Draft content (with version) requires DRAFT permission + * @return {Function} Express middleware function + */ +export function requireContentPermissions() { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const { spaceId } = req.params; + const { token: tokenId, version } = req.query; + + // Validate token format + if (!validateToken(tokenId)) { + res.status(401).send(new HttpsError('unauthenticated', 'Invalid or missing token')); + return; + } + + // Validate spaceId exists + if (!spaceId) { + res.status(400).send(new HttpsError('invalid-argument', 'Space ID is required')); + return; + } + + try { + // Fetch token from Firestore + const tokenSnapshot = await findTokenById(spaceId, tokenId as string).get(); + + if (!tokenSnapshot.exists) { + res.status(401).send(new HttpsError('unauthenticated', 'Token not found')); + return; + } + + const token = tokenSnapshot.data() as Token; + + // Check permissions: version requires DRAFT, published (no version) requires PUBLIC or DRAFT + const hasRequiredPermission = + version !== undefined + ? canPerformAny([TokenPermission.CONTENT_DRAFT, TokenPermission.DEV_TOOLS], token) + : canPerformAny([TokenPermission.CONTENT_PUBLIC, TokenPermission.CONTENT_DRAFT, TokenPermission.DEV_TOOLS], token); + + if (!hasRequiredPermission) { + res + .status(403) + .send( + new HttpsError( + 'permission-denied', + version !== undefined ? 'Draft content requires DRAFT permission' : 'Published content requires PUBLIC or DRAFT permission' + ) + ); + return; + } + + // Attach token and tokenId to request for use in route handlers + (req as RequestWithToken).token = token; + (req as RequestWithToken).tokenId = tokenId as string; + + next(); + } catch (error) { + res.status(500).send(new HttpsError('internal', 'Failed to verify token')); + } + }; +} + +/** + * Middleware for translation endpoints that checks permissions based on version query parameter + * Published translation (no version) requires PUBLIC or DRAFT permission + * Draft translation (with version) requires DRAFT permission + * @return {Function} Express middleware function + */ +export function requireTranslationPermissions() { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const { spaceId } = req.params; + const { token: tokenId, version } = req.query; + + // Validate token format + if (!validateToken(tokenId)) { + res.status(401).send(new HttpsError('unauthenticated', 'Invalid or missing token')); + return; + } + + // Validate spaceId exists + if (!spaceId) { + res.status(400).send(new HttpsError('invalid-argument', 'Space ID is required')); + return; + } + + try { + // Fetch token from Firestore + const tokenSnapshot = await findTokenById(spaceId, tokenId as string).get(); + + if (!tokenSnapshot.exists) { + res.status(401).send(new HttpsError('unauthenticated', 'Token not found')); + return; + } + + const token = tokenSnapshot.data() as Token; + + // Check permissions: version requires DRAFT, published (no version) requires PUBLIC or DRAFT + const hasRequiredPermission = + version !== undefined + ? canPerformAny([TokenPermission.TRANSLATION_DRAFT, TokenPermission.DEV_TOOLS], token) + : canPerformAny([TokenPermission.TRANSLATION_PUBLIC, TokenPermission.TRANSLATION_DRAFT, TokenPermission.DEV_TOOLS], token); + + if (!hasRequiredPermission) { + res + .status(403) + .send( + new HttpsError( + 'permission-denied', + version !== undefined + ? 'Draft translation requires DRAFT permission' + : 'Published translation requires PUBLIC or DRAFT permission' + ) + ); + return; + } + + // Attach token and tokenId to request for use in route handlers + (req as RequestWithToken).token = token; + (req as RequestWithToken).tokenId = tokenId as string; + + next(); + } catch (error) { + res.status(500).send(new HttpsError('internal', 'Failed to verify token')); + } + }; +} diff --git a/functions/src/webhooks.ts b/functions/src/webhooks.ts new file mode 100644 index 00000000..531d2a88 --- /dev/null +++ b/functions/src/webhooks.ts @@ -0,0 +1,20 @@ +import { logger } from 'firebase-functions/v2'; +import { onDocumentDeleted } from 'firebase-functions/v2/firestore'; +import { firestoreService } from './config'; + +const onWebHookDelete = onDocumentDeleted('spaces/{spaceId}/webhooks/{webhookId}', async event => { + const { id, params, data } = event; + logger.info(`[WebHook::onDelete] eventId='${id}'`); + logger.info(`[WebHook::onDelete] params='${JSON.stringify(params)}'`); + const { spaceId, webhookId } = params; + + if (data) { + logger.info(`[WebHook::onDelete] spaceId='${spaceId}' webhookId='${webhookId}'`); + await firestoreService.recursiveDelete(data.ref); + } + return; +}); + +export const webhook = { + ondelete: onWebHookDelete, +}; diff --git a/functions/tsconfig.json b/functions/tsconfig.json index 8e177329..8fdc4a62 100644 --- a/functions/tsconfig.json +++ b/functions/tsconfig.json @@ -6,7 +6,7 @@ "outDir": "lib", "sourceMap": true, "strict": true, - "target": "ES2020", + "target": "ES2022", "esModuleInterop": true, "resolveJsonModule": true }, diff --git a/karma.conf.js b/karma.conf.cjs similarity index 100% rename from karma.conf.js rename to karma.conf.cjs diff --git a/libs/ui/accordion/src/index.ts b/libs/ui/accordion/src/index.ts new file mode 100644 index 00000000..203e837f --- /dev/null +++ b/libs/ui/accordion/src/index.ts @@ -0,0 +1,19 @@ +import { HlmAccordion } from './lib/hlm-accordion'; +import { HlmAccordionContent } from './lib/hlm-accordion-content'; +import { HlmAccordionIcon } from './lib/hlm-accordion-icon'; +import { HlmAccordionItem } from './lib/hlm-accordion-item'; +import { HlmAccordionTrigger } from './lib/hlm-accordion-trigger'; + +export * from './lib/hlm-accordion'; +export * from './lib/hlm-accordion-content'; +export * from './lib/hlm-accordion-icon'; +export * from './lib/hlm-accordion-item'; +export * from './lib/hlm-accordion-trigger'; + +export const HlmAccordionImports = [ + HlmAccordion, + HlmAccordionItem, + HlmAccordionTrigger, + HlmAccordionIcon, + HlmAccordionContent, +] as const; diff --git a/libs/ui/accordion/src/lib/hlm-accordion-content.ts b/libs/ui/accordion/src/lib/hlm-accordion-content.ts new file mode 100644 index 00000000..81118040 --- /dev/null +++ b/libs/ui/accordion/src/lib/hlm-accordion-content.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { BrnAccordionContent } from '@spartan-ng/brain/accordion'; +import { classes } from '@spartan-ng/helm/utils'; + +@Component({ + selector: 'hlm-accordion-content', + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [{ directive: BrnAccordionContent, inputs: ['style'] }], + template: ` +
+ +
+ `, +}) +export class HlmAccordionContent { + constructor() { + classes( + () => 'text-sm transition-all data-[state=closed]:h-0 data-[state=open]:h-[var(--brn-accordion-content-height)]', + ); + } +} diff --git a/libs/ui/accordion/src/lib/hlm-accordion-icon.ts b/libs/ui/accordion/src/lib/hlm-accordion-icon.ts new file mode 100644 index 00000000..7bae01a6 --- /dev/null +++ b/libs/ui/accordion/src/lib/hlm-accordion-icon.ts @@ -0,0 +1,18 @@ +import { Directive } from '@angular/core'; +import { provideIcons } from '@ng-icons/core'; +import { lucideChevronDown } from '@ng-icons/lucide'; +import { provideHlmIconConfig } from '@spartan-ng/helm/icon'; +import { classes } from '@spartan-ng/helm/utils'; + +@Directive({ + selector: 'ng-icon[hlmAccordionIcon], ng-icon[hlmAccIcon]', + providers: [provideIcons({ lucideChevronDown }), provideHlmIconConfig({ size: 'sm' })], +}) +export class HlmAccordionIcon { + constructor() { + classes( + () => + 'text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200 group-data-[state=open]:rotate-180', + ); + } +} diff --git a/libs/ui/accordion/src/lib/hlm-accordion-item.ts b/libs/ui/accordion/src/lib/hlm-accordion-item.ts new file mode 100644 index 00000000..840ea778 --- /dev/null +++ b/libs/ui/accordion/src/lib/hlm-accordion-item.ts @@ -0,0 +1,19 @@ +import { Directive } from '@angular/core'; +import { BrnAccordionItem } from '@spartan-ng/brain/accordion'; +import { classes } from '@spartan-ng/helm/utils'; + +@Directive({ + selector: '[hlmAccordionItem],brn-accordion-item[hlm],hlm-accordion-item', + hostDirectives: [ + { + directive: BrnAccordionItem, + inputs: ['isOpened'], + outputs: ['openedChange'], + }, + ], +}) +export class HlmAccordionItem { + constructor() { + classes(() => 'border-border flex flex-1 flex-col border-b'); + } +} diff --git a/libs/ui/accordion/src/lib/hlm-accordion-trigger.ts b/libs/ui/accordion/src/lib/hlm-accordion-trigger.ts new file mode 100644 index 00000000..7c704d8e --- /dev/null +++ b/libs/ui/accordion/src/lib/hlm-accordion-trigger.ts @@ -0,0 +1,19 @@ +import { Directive } from '@angular/core'; +import { BrnAccordionTrigger } from '@spartan-ng/brain/accordion'; +import { classes } from '@spartan-ng/helm/utils'; + +@Directive({ + selector: '[hlmAccordionTrigger]', + hostDirectives: [BrnAccordionTrigger], + host: { + '[style.--tw-ring-offset-shadow]': '"0 0 #000"', + }, +}) +export class HlmAccordionTrigger { + constructor() { + classes( + () => + 'group focus-visible:ring-ring focus-visible:ring-offset-background flex w-full items-center justify-between py-4 text-left font-medium transition-all hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none [&[data-state=open]>ng-icon]:rotate-180', + ); + } +} diff --git a/libs/ui/accordion/src/lib/hlm-accordion.ts b/libs/ui/accordion/src/lib/hlm-accordion.ts new file mode 100644 index 00000000..efd8098b --- /dev/null +++ b/libs/ui/accordion/src/lib/hlm-accordion.ts @@ -0,0 +1,13 @@ +import { Directive } from '@angular/core'; +import { BrnAccordion } from '@spartan-ng/brain/accordion'; +import { classes } from '@spartan-ng/helm/utils'; + +@Directive({ + selector: '[hlmAccordion], hlm-accordion', + hostDirectives: [{ directive: BrnAccordion, inputs: ['type', 'dir', 'orientation'] }], +}) +export class HlmAccordion { + constructor() { + classes(() => 'flex data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col'); + } +} diff --git a/libs/ui/aspect-ratio/src/index.ts b/libs/ui/aspect-ratio/src/index.ts new file mode 100644 index 00000000..0b362caa --- /dev/null +++ b/libs/ui/aspect-ratio/src/index.ts @@ -0,0 +1,5 @@ +import { HlmAspectRatio } from './lib/helm-aspect-ratio'; + +export * from './lib/helm-aspect-ratio'; + +export const HlmAspectRatioImports = [HlmAspectRatio] as const; diff --git a/libs/ui/aspect-ratio/src/lib/helm-aspect-ratio.ts b/libs/ui/aspect-ratio/src/lib/helm-aspect-ratio.ts new file mode 100644 index 00000000..111bf52e --- /dev/null +++ b/libs/ui/aspect-ratio/src/lib/helm-aspect-ratio.ts @@ -0,0 +1,35 @@ +import { type NumberInput, coerceNumberProperty } from '@angular/cdk/coercion'; +import { Directive, input } from '@angular/core'; +import { classes } from '@spartan-ng/helm/utils'; + +const parseDividedString = (value: NumberInput): NumberInput => { + if (typeof value !== 'string' || !value.includes('/')) return value; + return value + .split('/') + .map((v) => Number.parseInt(v, 10)) + .reduce((a, b) => a / b); +}; + +@Directive({ + selector: '[hlmAspectRatio]', + host: { '[style.padding-bottom.%]': '100 / ratio()' }, +}) +export class HlmAspectRatio { + /** + * Aspect ratio of the element, defined as width / height. + */ + public readonly ratio = input(1, { + alias: 'hlmAspectRatio', + transform: (value: NumberInput) => { + const coerced = coerceNumberProperty(parseDividedString(value)); + return coerced <= 0 ? 1 : coerced; + }, + }); + + constructor() { + classes( + () => + 'relative w-full [&>*:first-child]:absolute [&>*:first-child]:h-full [&>*:first-child]:w-full [&>*:first-child]:object-cover', + ); + } +} diff --git a/libs/ui/autocomplete/src/index.ts b/libs/ui/autocomplete/src/index.ts new file mode 100644 index 00000000..8c136fb9 --- /dev/null +++ b/libs/ui/autocomplete/src/index.ts @@ -0,0 +1,40 @@ +import { HlmAutocomplete } from './lib/hlm-autocomplete'; +import { HlmAutocompleteContent } from './lib/hlm-autocomplete-content'; +import { HlmAutocompleteEmpty } from './lib/hlm-autocomplete-empty'; +import { HlmAutocompleteGroup } from './lib/hlm-autocomplete-group'; +import { HlmAutocompleteInput } from './lib/hlm-autocomplete-input'; +import { HlmAutocompleteItem } from './lib/hlm-autocomplete-item'; +import { HlmAutocompleteLabel } from './lib/hlm-autocomplete-label'; +import { HlmAutocompleteList } from './lib/hlm-autocomplete-list'; +import { HlmAutocompletePortal } from './lib/hlm-autocomplete-portal'; +import { HlmAutocompleteSearch } from './lib/hlm-autocomplete-search'; +import { HlmAutocompleteSeparator } from './lib/hlm-autocomplete-separator'; +import { HlmAutocompleteStatus } from './lib/hlm-autocomplete-status'; + +export * from './lib/hlm-autocomplete'; +export * from './lib/hlm-autocomplete-content'; +export * from './lib/hlm-autocomplete-empty'; +export * from './lib/hlm-autocomplete-group'; +export * from './lib/hlm-autocomplete-input'; +export * from './lib/hlm-autocomplete-item'; +export * from './lib/hlm-autocomplete-label'; +export * from './lib/hlm-autocomplete-list'; +export * from './lib/hlm-autocomplete-portal'; +export * from './lib/hlm-autocomplete-search'; +export * from './lib/hlm-autocomplete-separator'; +export * from './lib/hlm-autocomplete-status'; + +export const HlmAutocompleteImports = [ + HlmAutocomplete, + HlmAutocompleteContent, + HlmAutocompleteEmpty, + HlmAutocompleteGroup, + HlmAutocompleteInput, + HlmAutocompleteItem, + HlmAutocompleteLabel, + HlmAutocompleteList, + HlmAutocompletePortal, + HlmAutocompleteSearch, + HlmAutocompleteSeparator, + HlmAutocompleteStatus, +] as const; diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-content.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-content.ts new file mode 100644 index 00000000..8a698736 --- /dev/null +++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-content.ts @@ -0,0 +1,16 @@ +import { Directive } from '@angular/core'; +import { BrnAutocompleteContent } from '@spartan-ng/brain/autocomplete'; +import { classes } from '@spartan-ng/helm/utils'; + +@Directive({ + selector: '[hlmAutocompleteContent],hlm-autocomplete-content', + hostDirectives: [BrnAutocompleteContent], +}) +export class HlmAutocompleteContent { + constructor() { + classes( + () => + 'group/autocomplete-content bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex max-h-72 w-(--brn-autocomplete-width) min-w-36 flex-col overflow-hidden rounded-md p-0 shadow-md ring-1 duration-100', + ); + } +} diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-empty.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-empty.ts new file mode 100644 index 00000000..b7191708 --- /dev/null +++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-empty.ts @@ -0,0 +1,19 @@ +import { Directive } from '@angular/core'; +import { BrnAutocompleteEmpty } from '@spartan-ng/brain/autocomplete'; +import { classes } from '@spartan-ng/helm/utils'; + +@Directive({ + selector: '[hlmAutocompleteEmpty],hlm-autocomplete-empty', + hostDirectives: [BrnAutocompleteEmpty], + host: { + 'data-slot': 'autocomplete-empty', + }, +}) +export class HlmAutocompleteEmpty { + constructor() { + classes( + () => + 'text-muted-foreground hidden w-full items-center justify-center gap-2 py-2 text-center text-sm group-data-empty/autocomplete-content:flex', + ); + } +} diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-group.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-group.ts new file mode 100644 index 00000000..478d9561 --- /dev/null +++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-group.ts @@ -0,0 +1,16 @@ +import { Directive } from '@angular/core'; +import { BrnAutocompleteGroup } from '@spartan-ng/brain/autocomplete'; +import { classes } from '@spartan-ng/helm/utils'; + +@Directive({ + selector: '[hlmAutocompleteGroup]', + hostDirectives: [BrnAutocompleteGroup], + host: { + 'data-slot': 'autocomplete-group', + }, +}) +export class HlmAutocompleteGroup { + constructor() { + classes(() => 'data-hidden:hidden'); + } +} diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-input.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-input.ts new file mode 100644 index 00000000..7b5339c5 --- /dev/null +++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-input.ts @@ -0,0 +1,64 @@ +import { BooleanInput } from '@angular/cdk/coercion'; +import { booleanAttribute, ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideSearch, lucideX } from '@ng-icons/lucide'; +import { + BrnAutocompleteAnchor, + BrnAutocompleteClear, + BrnAutocompleteInput, + BrnAutocompleteInputWrapper, +} from '@spartan-ng/brain/autocomplete'; +import { HlmInputGroupImports } from '@spartan-ng/helm/input-group'; + +@Component({ + selector: 'hlm-autocomplete-input', + imports: [HlmInputGroupImports, NgIcon, BrnAutocompleteAnchor, BrnAutocompleteClear, BrnAutocompleteInput], + providers: [provideIcons({ lucideSearch, lucideX })], + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [BrnAutocompleteInputWrapper], + template: ` + + + + @if (showSearch()) { + + + + } + + @if (showClear()) { + + + + } + + + `, +}) +export class HlmAutocompleteInput { + public readonly placeholder = input(''); + + public readonly showSearch = input(true, { transform: booleanAttribute }); + public readonly showClear = input(false, { transform: booleanAttribute }); + + // TODO input and input-group styles need to support aria-invalid directly + public readonly ariaInvalid = input(false, { + transform: booleanAttribute, + alias: 'aria-invalid', + }); +} diff --git a/libs/ui/autocomplete/src/lib/hlm-autocomplete-item.ts b/libs/ui/autocomplete/src/lib/hlm-autocomplete-item.ts new file mode 100644 index 00000000..131dcee4 --- /dev/null +++ b/libs/ui/autocomplete/src/lib/hlm-autocomplete-item.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideCheck } from '@ng-icons/lucide'; +import { BrnAutocompleteItem } from '@spartan-ng/brain/autocomplete'; +import { classes } from '@spartan-ng/helm/utils'; + +@Component({ + selector: 'hlm-autocomplete-item', + imports: [NgIcon], + providers: [provideIcons({ lucideCheck })], + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [{ directive: BrnAutocompleteItem, inputs: ['id', 'disabled', 'value'] }], + host: { + 'data-slot': 'autocomplete-item', + }, + template: ` + + @if (_active()) { +