From e0e7f4cab14045271571532652faffc4276a05bd Mon Sep 17 00:00:00 2001 From: Wayne <5291640+ringoinca@users.noreply.github.com> Date: Wed, 22 Oct 2025 00:04:38 +0200 Subject: [PATCH] License service/module --- .../docker-deployment-enterprise.yml | 41 ++++ .github/workflows/docker-deployment.yml | 2 +- docker/Dockerfile | 56 ------ packages/backend/src/api/server.ts | 3 +- packages/backend/src/index.ts | 10 +- packages/enterprise/package.json | 2 + packages/enterprise/src/index.ts | 4 +- .../src/middleware/featureEnabled.ts | 31 +++ .../src/modules/audit-log/audit-log.module.ts | 3 +- .../src/modules/audit-log/audit-log.routes.ts | 16 +- .../license/LicenseReportingService.ts | 81 ++++++++ .../src/modules/license/LicenseService.ts | 114 ++++++++++- .../src/modules/license/license.controller.ts | 36 ++++ .../src/modules/license/license.module.ts | 14 ++ .../src/modules/license/license.routes.ts | 14 ++ .../src/modules/license/public-key.pem | 6 +- .../retention-policy.module.ts | 16 +- .../retention-policy.routes.ts | 18 ++ .../src/modules/status/status.module.ts | 21 --- packages/frontend/package.json | 1 + .../src/lib/components/ui/progress/index.ts | 7 + .../components/ui/progress/progress.svelte | 27 +++ .../frontend/src/lib/translations/de.json | 28 +++ .../frontend/src/lib/translations/en.json | 28 +++ packages/frontend/src/routes/+layout.ts | 2 +- .../src/routes/dashboard/+error.svelte | 1 - .../src/routes/dashboard/+layout.svelte | 5 + .../admin/jobs/[queueName]/+page.svelte | 5 +- .../dashboard/admin/license/+page.server.ts | 33 ++++ .../dashboard/admin/license/+page.svelte | 177 ++++++++++++++++++ .../compliance/audit-log/+page.server.ts | 36 ++-- .../compliance/audit-log/+page.svelte | 1 - packages/types/src/index.ts | 1 + packages/types/src/license.types.ts | 49 +++++ pnpm-lock.yaml | 25 +++ 35 files changed, 782 insertions(+), 132 deletions(-) create mode 100644 .github/workflows/docker-deployment-enterprise.yml delete mode 100644 docker/Dockerfile create mode 100644 packages/enterprise/src/middleware/featureEnabled.ts create mode 100644 packages/enterprise/src/modules/license/LicenseReportingService.ts create mode 100644 packages/enterprise/src/modules/license/license.controller.ts create mode 100644 packages/enterprise/src/modules/license/license.module.ts create mode 100644 packages/enterprise/src/modules/license/license.routes.ts create mode 100644 packages/enterprise/src/modules/retention-policy/retention-policy.routes.ts delete mode 100644 packages/enterprise/src/modules/status/status.module.ts create mode 100644 packages/frontend/src/lib/components/ui/progress/index.ts create mode 100644 packages/frontend/src/lib/components/ui/progress/progress.svelte create mode 100644 packages/frontend/src/routes/dashboard/admin/license/+page.server.ts create mode 100644 packages/frontend/src/routes/dashboard/admin/license/+page.svelte create mode 100644 packages/types/src/license.types.ts diff --git a/.github/workflows/docker-deployment-enterprise.yml b/.github/workflows/docker-deployment-enterprise.yml new file mode 100644 index 0000000..aa120b8 --- /dev/null +++ b/.github/workflows/docker-deployment-enterprise.yml @@ -0,0 +1,41 @@ +name: docker-deployment + +on: + push: + branches: + - main + paths-ignore: + - '**.md' + - 'docs/**' + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract short SHA + id: sha + run: echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./apps/open-archiver-enterprise/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: logiclabshq/open-archiver:${{ steps.sha.outputs.sha }}-enterprise diff --git a/.github/workflows/docker-deployment.yml b/.github/workflows/docker-deployment.yml index 099c7d1..7a157f2 100644 --- a/.github/workflows/docker-deployment.yml +++ b/.github/workflows/docker-deployment.yml @@ -35,7 +35,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: ./docker/Dockerfile + file: ./apps/open-archiver/Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: logiclabshq/open-archiver:${{ steps.sha.outputs.sha }} diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 11b8341..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,56 +0,0 @@ -# Dockerfile for Open Archiver - -ARG BASE_IMAGE=node:22-alpine - -# 0. Base Stage: Define all common dependencies and setup -FROM ${BASE_IMAGE} AS base -WORKDIR /app - -# Install pnpm -RUN --mount=type=cache,target=/root/.npm \ - npm install -g pnpm - -# Copy manifests and lockfile -COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ -COPY packages/backend/package.json ./packages/backend/ -COPY packages/frontend/package.json ./packages/frontend/ -COPY packages/types/package.json ./packages/types/ - -# 1. Build Stage: Install all dependencies and build the project -FROM base AS build -COPY packages/frontend/svelte.config.js ./packages/frontend/ - -# Install all dependencies. Use --shamefully-hoist to create a flat node_modules structure -ENV PNPM_HOME="/pnpm" -RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm install --shamefully-hoist --frozen-lockfile --prod=false - -# Copy the rest of the source code -COPY . . - -# Build all packages. -RUN pnpm build - -# 2. Production Stage: Install only production dependencies and copy built artifacts -FROM base AS production - - -# Copy built application from build stage -COPY --from=build /app/packages/backend/dist ./packages/backend/dist -COPY --from=build /app/packages/frontend/build ./packages/frontend/build -COPY --from=build /app/packages/types/dist ./packages/types/dist -COPY --from=build /app/packages/backend/drizzle.config.ts ./packages/backend/drizzle.config.ts -COPY --from=build /app/packages/backend/src/database/migrations ./packages/backend/src/database/migrations - -# Copy the entrypoint script and make it executable -COPY docker/docker-entrypoint.sh /usr/local/bin/ - -# Expose the port the app runs on -EXPOSE 4000 -EXPOSE 3000 - -# Set the entrypoint -ENTRYPOINT ["docker-entrypoint.sh"] - -# Start the application -CMD ["pnpm", "docker-start"] diff --git a/packages/backend/src/api/server.ts b/packages/backend/src/api/server.ts index d2847dc..350e10a 100644 --- a/packages/backend/src/api/server.ts +++ b/packages/backend/src/api/server.ts @@ -34,10 +34,11 @@ import path from 'path'; import { logger } from '../config/logger'; import { rateLimiter } from './middleware/rateLimiter'; import { config } from '../config'; +import { OpenArchiverFeature } from '@open-archiver/types'; // Define the "plugin" interface export interface ArchiverModule { initialize: (app: Express, authService: AuthService) => Promise; - name: string; + name: OpenArchiverFeature; } export let authService: AuthService; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 4dbaa10..0c8d457 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,6 +1,10 @@ export { createServer, ArchiverModule } from './api/server'; export { logger } from './config/logger'; export { config } from './config'; -export * from './services/AuthService' -export * from './services/AuditService' -export * from './api/middleware/requireAuth' \ No newline at end of file +export * from './services/AuthService'; +export * from './services/AuditService'; +export * from './api/middleware/requireAuth'; +export * from './api/middleware/requirePermission' +export { db } from './database'; +export * as drizzleOrm from 'drizzle-orm'; +export * from './database/schema'; diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 900ba78..8ec40ab 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -12,8 +12,10 @@ }, "dependencies": { "@open-archiver/backend": "workspace:*", + "@types/node-cron": "^3.0.11", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", + "node-cron": "^4.2.1", "zod": "^4.1.5" }, "devDependencies": { diff --git a/packages/enterprise/src/index.ts b/packages/enterprise/src/index.ts index 8d73849..9070921 100644 --- a/packages/enterprise/src/index.ts +++ b/packages/enterprise/src/index.ts @@ -1,10 +1,10 @@ import { ArchiverModule } from '@open-archiver/backend'; -import { statusModule } from './modules/status/status.module'; import { retentionPolicyModule } from './modules/retention-policy/retention-policy.module'; import { auditLogModule } from './modules/audit-log/audit-log.module'; +import { licenseModule } from './modules/license/license.module'; export const enterpriseModules: ArchiverModule[] = [ - statusModule, + licenseModule, retentionPolicyModule, auditLogModule ]; diff --git a/packages/enterprise/src/middleware/featureEnabled.ts b/packages/enterprise/src/middleware/featureEnabled.ts new file mode 100644 index 0000000..40dd498 --- /dev/null +++ b/packages/enterprise/src/middleware/featureEnabled.ts @@ -0,0 +1,31 @@ +import { Request, Response, NextFunction } from 'express'; +import { OpenArchiverFeature } from '@open-archiver/types'; +import { licenseService } from '../modules/license/LicenseService'; +import { logger } from '@open-archiver/backend'; + +/** + * Middleware factory to create a feature flag guard. + * It checks if a specific enterprise feature is enabled for the current license. + * + * @param feature The enterprise feature to check. + * @returns An Express middleware function. + */ +export const featureEnabled = (feature: OpenArchiverFeature) => { + return (req: Request, res: Response, next: NextFunction) => { + try { + if (licenseService.isFeatureEnabled(feature)) { + return next(); + } + + res.status(403).json({ + error: 'Forbidden', + message: `This feature (${feature}) is not enabled for your current license. Please upgrade your plan to access this feature.`, + }); + } catch (error) { + // In case of an unexpected error during license verification, + // log the error but allow the request to proceed. + logger.error(`🚨 CRITICAL: License check failed for feature "${feature}". Allowing access by default. Error:`, error); + return next(); + } + }; +}; diff --git a/packages/enterprise/src/modules/audit-log/audit-log.module.ts b/packages/enterprise/src/modules/audit-log/audit-log.module.ts index 72b6c3c..5197534 100644 --- a/packages/enterprise/src/modules/audit-log/audit-log.module.ts +++ b/packages/enterprise/src/modules/audit-log/audit-log.module.ts @@ -3,9 +3,10 @@ import { ArchiverModule } from '@open-archiver/backend'; import { auditLogRoutes } from './audit-log.routes'; import { AuthService } from '@open-archiver/backend'; import { config } from '@open-archiver/backend'; +import { OpenArchiverFeature } from '@open-archiver/types'; class AuditLogModule implements ArchiverModule { - name = 'audit-log'; + name: OpenArchiverFeature = OpenArchiverFeature.AUDIT_LOG; async initialize(app: Express, authService: AuthService): Promise { app.use(`/${config.api.version}/enterprise/audit-logs`, auditLogRoutes(authService)); diff --git a/packages/enterprise/src/modules/audit-log/audit-log.routes.ts b/packages/enterprise/src/modules/audit-log/audit-log.routes.ts index 1a172c0..cc05cf8 100644 --- a/packages/enterprise/src/modules/audit-log/audit-log.routes.ts +++ b/packages/enterprise/src/modules/audit-log/audit-log.routes.ts @@ -1,14 +1,24 @@ import { Router } from 'express'; import { AuditLogController } from './audit-log.controller'; -import { requireAuth } from '@open-archiver/backend'; +import { requireAuth, requirePermission } from '@open-archiver/backend'; import { AuthService } from '@open-archiver/backend'; +import { featureEnabled } from '../../middleware/featureEnabled'; +import { OpenArchiverFeature } from '@open-archiver/types'; + + export const auditLogRoutes = (authService: AuthService): Router => { const router = Router(); const controller = new AuditLogController(); + router.use(requireAuth(authService), featureEnabled(OpenArchiverFeature.AUDIT_LOG)); - router.get('/', requireAuth(authService), controller.getAuditLogs); - router.post('/verify', requireAuth(authService), controller.verifyAuditLog); + router.get('/', + requirePermission('manage', 'all'), + controller.getAuditLogs); + + router.post('/verify', + requirePermission('manage', 'all'), + controller.verifyAuditLog); return router; }; diff --git a/packages/enterprise/src/modules/license/LicenseReportingService.ts b/packages/enterprise/src/modules/license/LicenseReportingService.ts new file mode 100644 index 0000000..a5d689d --- /dev/null +++ b/packages/enterprise/src/modules/license/LicenseReportingService.ts @@ -0,0 +1,81 @@ +import * as cron from 'node-cron'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { LicenseFilePayload, LicenseStatusPayload } from '@open-archiver/types'; +import { + logger, + db, + drizzleOrm, + archivedEmails +} from '@open-archiver/backend'; + +// license server is yet to be implemented. +const LICENSE_SERVER_URL = 'https://licensing.openarchiver.com/api/v1/ping'; +export const CACHE_FILE_PATH = path.join(__dirname, 'license-status.json'); + +class LicenseReportingService { + private licensePayload: LicenseFilePayload | null = null; + + public start(payload: LicenseFilePayload) { + this.licensePayload = payload; + // Schedule to run once every 24 hours, with a random minute/hour to distribute load. + const cronExpression = `${Math.floor(Math.random() * 60)} ${Math.floor(Math.random() * 5)} * * *`; + + cron.schedule(cronExpression, () => { + this.phoneHome(); + }); + + logger.info(`📞 License reporting service scheduled with expression: ${cronExpression}`); + } + + public async phoneHome() { + if (!this.licensePayload) { + logger.warn('📞 Phone home skipped: License payload not loaded.'); + return; + } + + try { + // 1. Count Active Seats, the result will be used to send to license server. + const activeSeats = await this.countActiveSeats(); + + logger.info(`Performing daily license check for ${this.licensePayload.customerName}. Active seats: ${activeSeats}`); + + // 2. Phone Home (mocked for now) + // will be replaced by a fetch call to the license server. + const mockedResponse: LicenseStatusPayload = { + status: 'VALID' + }; + + // 3. Cache Response + await this.cacheLicenseStatus(mockedResponse); + + } catch (error) { + logger.error('Phone home failed:', error); + // If the request fails, we do nothing and the app continues with the last known status. + } + } + + public async countActiveSeats(): Promise { + try { + const result = await db + .select({ count: drizzleOrm.countDistinct(archivedEmails.userEmail) }) + .from(archivedEmails); + + return result[0]?.count || 0; + } catch (error) { + logger.error('Failed to count active seats from database:', error); + return 0; // Return 0 if the query fails to avoid breaking the process. + } + } + + private async cacheLicenseStatus(status: LicenseStatusPayload) { + try { + await fs.writeFile(CACHE_FILE_PATH, JSON.stringify(status, null, 2)); + logger.info(`License status successfully cached to ${CACHE_FILE_PATH}`); + } catch (error) { + logger.error(`Failed to cache license status to ${CACHE_FILE_PATH}:`, error); + } + } +} + +export const licenseReportingService = new LicenseReportingService(); diff --git a/packages/enterprise/src/modules/license/LicenseService.ts b/packages/enterprise/src/modules/license/LicenseService.ts index cb9cee1..7b9a742 100644 --- a/packages/enterprise/src/modules/license/LicenseService.ts +++ b/packages/enterprise/src/modules/license/LicenseService.ts @@ -1,13 +1,113 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as jwt from 'jsonwebtoken'; +import { LicenseFilePayload, LicenseStatusPayload, OpenArchiverFeature } from '@open-archiver/types'; +import { logger } from '@open-archiver/backend'; +import { licenseReportingService } from './LicenseReportingService'; +import { CACHE_FILE_PATH } from './LicenseReportingService'; -// This is a placeholder for the LicenseService. In a real implementation, this service would handle license validation. Currently all validations returns true. -export class LicenseService { - private publicKey = fs.readFileSync(path.resolve(__dirname, './public-key.pem'), 'utf-8'); +// --- SECURITY BEST PRACTICE --- +// The public key is embedded directly into the code to prevent tampering. +const PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUusug3dvT36RcauKYmO5JtHxpSpi +X0SrmkMxGyEd18vMXBGD9piAR3MTRskQ6XOoEo0fio6s9LtgPzKkJBdyVg== +-----END PUBLIC KEY-----`; - public isFeatureEnabled(feature: string): boolean { - // For now, all features are enabled. - console.log(`Checking feature: ${feature}`); - return true; +type LicenseStatus = 'VALID' | 'INVALID' | 'EXPIRED' | 'NOT_FOUND'; + +class LicenseService { + public licensePayload: LicenseFilePayload | null = null; + public licenseStatus: LicenseStatus = 'NOT_FOUND'; + public cachedStatus: LicenseStatusPayload | null = null; + + constructor() { + this.loadAndVerifyLicense(); + this.loadCachedStatus(); + } + + private loadAndVerifyLicense() { + try { + const licenseKey = process.env.OA_LICENSE_KEY || fs.readFileSync(path.join(__dirname, 'license.jwt'), 'utf-8'); + + if (!licenseKey) { + this.licenseStatus = 'NOT_FOUND'; + logger.warn('📄 License key not found.'); + return; + } + + const decoded = jwt.verify(licenseKey, PUBLIC_KEY, { + algorithms: ['ES256'], + }) as LicenseFilePayload; + + this.licensePayload = decoded; + this.licenseStatus = 'VALID'; + logger.info(`Enterprise license successfully verified for: ${this.licensePayload.customerName}`); + // Start the reporting service now that we have a valid license payload + licenseReportingService.start(this.licensePayload); + + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + this.licenseStatus = 'EXPIRED'; + logger.error('License verification failed: The license has expired.'); + } else if (error instanceof jwt.JsonWebTokenError) { + this.licenseStatus = 'INVALID'; + logger.error(`License verification failed: ${error.message}`); + } else { + this.licenseStatus = 'INVALID'; + logger.error('An unexpected error occurred during license verification:', error); + } + } + } + + private async loadCachedStatus() { + try { + if (fs.existsSync(CACHE_FILE_PATH)) { + const data = fs.readFileSync(CACHE_FILE_PATH, 'utf-8'); + this.cachedStatus = JSON.parse(data); + logger.info(`Successfully loaded cached license status: ${this.cachedStatus?.status}`); + } else { + // On a new installation, the cache file won't exist. We default to a valid state + logger.info(`License status cache not found. Assuming 'VALID' until first phone-home.`); + this.cachedStatus = { status: 'VALID' }; + } + } catch (error) { + logger.error(`Failed to load or initialize cached license status:`, error); + // Fallback to a valid status if parsing fails to prevent locking out users. + this.cachedStatus = { status: 'VALID' }; + } + } + + public isFeatureEnabled(feature: OpenArchiverFeature): boolean { + // A license payload must exist to know which features are granted. + if (!this.licensePayload) { + return false; + } + + // Check if the license is supposed to grant the feature. + const hasAllFeatures = this.licensePayload.features.includes(OpenArchiverFeature.ALL); + const hasSpecificFeature = this.licensePayload.features.includes(feature); + + if (!hasAllFeatures && !hasSpecificFeature) { + return false; + } + + + // Now, check the validity. The server's cached status is the highest source of truth. + if (this.cachedStatus?.status === 'REVOKED') { + if (this.cachedStatus.gracePeriodEnds) { + const gracePeriodEnd = new Date(this.cachedStatus.gracePeriodEnds); + // The grace period is active, so the feature is enabled regardless of local JWT status. + return new Date() < gracePeriodEnd; + } + // Revoked and no grace period. + return false; + } + + + + // If not revoked by the server, the local license JWT must be valid. + return this.licenseStatus === 'VALID'; } } + +export const licenseService = new LicenseService(); diff --git a/packages/enterprise/src/modules/license/license.controller.ts b/packages/enterprise/src/modules/license/license.controller.ts new file mode 100644 index 0000000..482db1f --- /dev/null +++ b/packages/enterprise/src/modules/license/license.controller.ts @@ -0,0 +1,36 @@ +import { Request, Response } from 'express'; +import { ConsolidatedLicenseStatus, OpenArchiverFeature } from '@open-archiver/types'; +import { licenseService } from './LicenseService'; +import { licenseReportingService } from './LicenseReportingService'; + +class LicenseController { + public getLicenseStatus = async (req: Request, res: Response) => { + if (!licenseService.licensePayload) { + return res.status(404).json({ error: 'License information not found.' }); + } + + const activeSeats = await licenseReportingService.countActiveSeats() + + const allPossibleFeatures: OpenArchiverFeature[] = Object.values(OpenArchiverFeature); + + const features = allPossibleFeatures.reduce((acc, feature) => { + acc[feature] = licenseService.isFeatureEnabled(feature); + return acc; + }, {} as { [key in OpenArchiverFeature]?: boolean }); + + const response: ConsolidatedLicenseStatus = { + customerName: licenseService.licensePayload.customerName, + planSeats: licenseService.licensePayload.planSeats, + expiresAt: licenseService.licensePayload.expiresAt, + remoteStatus: licenseService.cachedStatus?.status || 'UNKNOWN', + gracePeriodEnds: licenseService.cachedStatus?.gracePeriodEnds, + activeSeats: activeSeats, + isExpired: new Date(licenseService.licensePayload.expiresAt) < new Date(), + features: features, + }; + + res.status(200).json(response); + }; +} + +export const licenseController = new LicenseController(); diff --git a/packages/enterprise/src/modules/license/license.module.ts b/packages/enterprise/src/modules/license/license.module.ts new file mode 100644 index 0000000..eb39dec --- /dev/null +++ b/packages/enterprise/src/modules/license/license.module.ts @@ -0,0 +1,14 @@ +import { Express } from 'express'; +import { ArchiverModule, AuthService, config } from '@open-archiver/backend'; +import { licenseRoutes } from './license.routes'; +import { OpenArchiverFeature } from '@open-archiver/types'; + +class LicenseModule implements ArchiverModule { + name: OpenArchiverFeature = OpenArchiverFeature.STATUS; + + async initialize(app: Express, authService: AuthService): Promise { + app.use(`/${config.api.version}/enterprise/status`, licenseRoutes(authService)); + } +} + +export const licenseModule = new LicenseModule(); diff --git a/packages/enterprise/src/modules/license/license.routes.ts b/packages/enterprise/src/modules/license/license.routes.ts new file mode 100644 index 0000000..ba223c0 --- /dev/null +++ b/packages/enterprise/src/modules/license/license.routes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { licenseController } from './license.controller'; +import { requireAuth, AuthService } from '@open-archiver/backend'; + +export const licenseRoutes = (authService: AuthService): Router => { + const router = Router(); + router.use(requireAuth(authService)); + + router.get( + '/license-status' + , licenseController.getLicenseStatus); + + return router; +}; diff --git a/packages/enterprise/src/modules/license/public-key.pem b/packages/enterprise/src/modules/license/public-key.pem index 8d6ad41..cdf449d 100644 --- a/packages/enterprise/src/modules/license/public-key.pem +++ b/packages/enterprise/src/modules/license/public-key.pem @@ -1,4 +1,4 @@ -----BEGIN PUBLIC KEY----- -MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEC6I4ubMlqW28CjS4IbFnCZ8fE+etwF50 -h2KM3yh6acvko/k7234dCbJ/rwJZFq7DUlG4DABMnTg/1OwbjGZiMQ== ------END PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUusug3dvT36RcauKYmO5JtHxpSpi +X0SrmkMxGyEd18vMXBGD9piAR3MTRskQ6XOoEo0fio6s9LtgPzKkJBdyVg== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/packages/enterprise/src/modules/retention-policy/retention-policy.module.ts b/packages/enterprise/src/modules/retention-policy/retention-policy.module.ts index 5973092..0cc18a5 100644 --- a/packages/enterprise/src/modules/retention-policy/retention-policy.module.ts +++ b/packages/enterprise/src/modules/retention-policy/retention-policy.module.ts @@ -1,13 +1,13 @@ -import { Express, Request, Response } from 'express'; -import { ArchiverModule, config } from '@open-archiver/backend'; +import { Express } from 'express'; +import { ArchiverModule, AuthService, config } from '@open-archiver/backend'; +import { OpenArchiverFeature } from '@open-archiver/types'; +import { retentionPolicyRoutes } from './retention-policy.routes'; + class RetentionPolicyModule implements ArchiverModule { - name = 'retention-policy'; + name: OpenArchiverFeature = OpenArchiverFeature.RETENTION_POLICY - async initialize(app: Express): Promise { - - app.get(`/${config.api.version}/enterprise/retention-policy`, (req: Request, res: Response) => { - res.json({ status: 'Retention Policy module is active!' }); - }); + async initialize(app: Express, authService: AuthService): Promise { + app.use(`/${config.api.version}/enterprise/retention-policy`, retentionPolicyRoutes(authService)); } } diff --git a/packages/enterprise/src/modules/retention-policy/retention-policy.routes.ts b/packages/enterprise/src/modules/retention-policy/retention-policy.routes.ts new file mode 100644 index 0000000..1fef108 --- /dev/null +++ b/packages/enterprise/src/modules/retention-policy/retention-policy.routes.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { requireAuth, AuthService } from '@open-archiver/backend'; +import { featureEnabled } from '../../middleware/featureEnabled'; +import { OpenArchiverFeature } from '@open-archiver/types'; + +export const retentionPolicyRoutes = (authService: AuthService): Router => { + const router = Router(); + + // All routes in this module require authentication and the retention-policy feature + router.use(requireAuth(authService), featureEnabled(OpenArchiverFeature.RETENTION_POLICY)); + + // demonstrating route + router.get('/', (req, res) => { + res.status(200).json({ message: 'Retention policy feature is enabled.' }); + }); + + return router; +}; diff --git a/packages/enterprise/src/modules/status/status.module.ts b/packages/enterprise/src/modules/status/status.module.ts deleted file mode 100644 index 25e4e9d..0000000 --- a/packages/enterprise/src/modules/status/status.module.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Express, Request, Response } from 'express'; -import { ArchiverModule, config } from '@open-archiver/backend'; -import { LicenseService } from '../license/LicenseService'; - -class StatusModule implements ArchiverModule { - name = 'Status'; - - async initialize(app: Express): Promise { - const licenseService = new LicenseService(); - - app.get(`/${config.api.version}/enterprise/status`, (req: Request, res: Response) => { - if (licenseService.isFeatureEnabled('enterprise-status')) { - res.json({ status: 'Enterprise features are enabled!' }); - } else { - res.status(403).json({ error: 'Enterprise license required.' }); - } - }); - } -} - -export const statusModule = new StatusModule(); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 844548d..d2a6e42 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -19,6 +19,7 @@ "@sveltejs/kit": "^2.38.1", "clsx": "^2.1.1", "d3-shape": "^3.2.0", + "date-fns": "^4.1.0", "html-entities": "^2.6.0", "jose": "^6.0.1", "lucide-svelte": "^0.525.0", diff --git a/packages/frontend/src/lib/components/ui/progress/index.ts b/packages/frontend/src/lib/components/ui/progress/index.ts new file mode 100644 index 0000000..25eee61 --- /dev/null +++ b/packages/frontend/src/lib/components/ui/progress/index.ts @@ -0,0 +1,7 @@ +import Root from "./progress.svelte"; + +export { + Root, + // + Root as Progress, +}; diff --git a/packages/frontend/src/lib/components/ui/progress/progress.svelte b/packages/frontend/src/lib/components/ui/progress/progress.svelte new file mode 100644 index 0000000..6833013 --- /dev/null +++ b/packages/frontend/src/lib/components/ui/progress/progress.svelte @@ -0,0 +1,27 @@ + + + +
+
diff --git a/packages/frontend/src/lib/translations/de.json b/packages/frontend/src/lib/translations/de.json index 408602c..768da27 100644 --- a/packages/frontend/src/lib/translations/de.json +++ b/packages/frontend/src/lib/translations/de.json @@ -309,6 +309,34 @@ "previous": "Zurück", "next": "Weiter", "ingestion_source": "Ingestion-Quelle" + }, + "license_page": { + "title": "Enterprise-Lizenzstatus", + "meta_description": "Zeigen Sie den aktuellen Status Ihrer Open Archiver Enterprise-Lizenz an.", + "revoked_title": "Lizenz widerrufen", + "revoked_message": "Ihre Lizenz wurde vom Lizenzadministrator widerrufen. Enterprise-Funktionen werden deaktiviert {{grace_period}}. Bitte kontaktieren Sie Ihren Account Manager für Unterstützung.", + "revoked_grace_period": "am {{date}}", + "revoked_immediately": "sofort", + "seat_limit_exceeded_title": "Sitzplatzlimit überschritten", + "seat_limit_exceeded_message": "Ihre Lizenz gilt für {{planSeats}} Benutzer, aber Sie verwenden derzeit {{activeSeats}}. Bitte kontaktieren Sie den Vertrieb, um Ihr Abonnement anzupassen.", + "customer": "Kunde", + "license_details": "Lizenzdetails", + "license_status": "Lizenzstatus", + "active": "Aktiv", + "expired": "Abgelaufen", + "revoked": "Widerrufen", + "unknown": "Unbekannt", + "expires": "Läuft ab", + "seat_usage": "Sitzplatznutzung", + "seats_used": "{{activeSeats}} von {{planSeats}} Plätzen belegt", + "enabled_features": "Aktivierte Funktionen", + "enabled_features_description": "Die folgenden Enterprise-Funktionen sind derzeit aktiviert.", + "feature": "Funktion", + "status": "Status", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "could_not_load_title": "Lizenz konnte nicht geladen werden", + "could_not_load_message": "Ein unerwarteter Fehler ist aufgetreten." } } } diff --git a/packages/frontend/src/lib/translations/en.json b/packages/frontend/src/lib/translations/en.json index 081823e..c040369 100644 --- a/packages/frontend/src/lib/translations/en.json +++ b/packages/frontend/src/lib/translations/en.json @@ -349,6 +349,34 @@ "previous": "Previous", "next": "Next", "ingestion_source": "Ingestion Source" + }, + "license_page": { + "title": "Enterprise License Status", + "meta_description": "View the current status of your Open Archiver Enterprise license.", + "revoked_title": "License Revoked", + "revoked_message": "Your license has been revoked by the license administrator. Enterprise features will be disabled {{grace_period}}. Please contact your account manager for assistance.", + "revoked_grace_period": "on {{date}}", + "revoked_immediately": "immediately", + "seat_limit_exceeded_title": "Seat Limit Exceeded", + "seat_limit_exceeded_message": "Your license is for {{planSeats}} users, but you are currently using {{activeSeats}}. Please contact sales to adjust your subscription.", + "customer": "Customer", + "license_details": "License Details", + "license_status": "License Status", + "active": "Active", + "expired": "Expired", + "revoked": "Revoked", + "unknown": "Unknown", + "expires": "Expires", + "seat_usage": "Seat Usage", + "seats_used": "{{activeSeats}} of {{planSeats}} seats used", + "enabled_features": "Enabled Features", + "enabled_features_description": "The following enterprise features are currently enabled.", + "feature": "Feature", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "could_not_load_title": "Could Not Load License", + "could_not_load_message": "An unexpected error occurred." } } } diff --git a/packages/frontend/src/routes/+layout.ts b/packages/frontend/src/routes/+layout.ts index 7b58cb7..2618086 100644 --- a/packages/frontend/src/routes/+layout.ts +++ b/packages/frontend/src/routes/+layout.ts @@ -8,7 +8,7 @@ export const load: LayoutLoad = async ({ url, data }) => { let initLocale: SupportedLanguage = 'en'; // Default fallback - if (data.systemSettings?.language) { + if (data && data.systemSettings?.language) { initLocale = data.systemSettings.language; } diff --git a/packages/frontend/src/routes/dashboard/+error.svelte b/packages/frontend/src/routes/dashboard/+error.svelte index 34fa2b3..c5c15ff 100644 --- a/packages/frontend/src/routes/dashboard/+error.svelte +++ b/packages/frontend/src/routes/dashboard/+error.svelte @@ -1,6 +1,5 @@ diff --git a/packages/frontend/src/routes/dashboard/+layout.svelte b/packages/frontend/src/routes/dashboard/+layout.svelte index c8d83bd..4326ee1 100644 --- a/packages/frontend/src/routes/dashboard/+layout.svelte +++ b/packages/frontend/src/routes/dashboard/+layout.svelte @@ -75,6 +75,11 @@ subMenu: [{ href: '/dashboard/compliance/audit-log', label: 'Audit Log' }], position: 3, }, + { + label: $t('app.layout.admin'), + subMenu: [{ href: '/dashboard/admin/license', label: 'License status' }], + position: 4, + }, ]; function mergeNavItems(baseItems: NavItem[], enterpriseItems: NavItem[]): NavItem[] { diff --git a/packages/frontend/src/routes/dashboard/admin/jobs/[queueName]/+page.svelte b/packages/frontend/src/routes/dashboard/admin/jobs/[queueName]/+page.svelte index fd93e2f..4896272 100644 --- a/packages/frontend/src/routes/dashboard/admin/jobs/[queueName]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/jobs/[queueName]/+page.svelte @@ -131,8 +131,9 @@ {#if job.error} {/if} diff --git a/packages/frontend/src/routes/dashboard/admin/license/+page.server.ts b/packages/frontend/src/routes/dashboard/admin/license/+page.server.ts new file mode 100644 index 0000000..ddc8a65 --- /dev/null +++ b/packages/frontend/src/routes/dashboard/admin/license/+page.server.ts @@ -0,0 +1,33 @@ +import { api } from '$lib/server/api'; +import type { PageServerLoad } from './$types'; +import type { ConsolidatedLicenseStatus } from '@open-archiver/types'; +import { error } from '@sveltejs/kit'; + +export const load: PageServerLoad = async (event) => { + if (!event.locals.enterpriseMode) { + throw error(403, "This feature is only available in the Enterprise Edition. Please contact Open Archiver to upgrade.") + } + try { + const response = await api('/enterprise/status/license-status', event); + const responseText = await response.json() + if (!response.ok) { + if (response.status === 404) { + throw error(404, responseText.error || JSON.stringify(responseText)); + } + // Handle other potential server errors + throw error(response.status, 'Failed to fetch license status'); + } + + const licenseStatus: ConsolidatedLicenseStatus = responseText + + return { + licenseStatus + }; + } catch (e) { + // Catch fetch errors or re-throw kit errors + if (e instanceof Error) { + throw error(500, 'An unexpected error occurred while trying to fetch the license status.'); + } + throw e; + } +}; diff --git a/packages/frontend/src/routes/dashboard/admin/license/+page.svelte b/packages/frontend/src/routes/dashboard/admin/license/+page.svelte new file mode 100644 index 0000000..aa36053 --- /dev/null +++ b/packages/frontend/src/routes/dashboard/admin/license/+page.svelte @@ -0,0 +1,177 @@ + + + + {$t('app.license_page.title')} - Open Archiver + + + +
+

{$t('app.license_page.title')}

+ + {#if data.licenseStatus.remoteStatus === 'REVOKED'} + + +
+ + {$t('app.license_page.revoked_title')} +
+
+ +

+ {$t('app.license_page.revoked_message', { + grace_period: data.licenseStatus.gracePeriodEnds + ? $t('app.license_page.revoked_grace_period', { + date: format( + new Date(data.licenseStatus.gracePeriodEnds), + 'PPP' + ), + } as any) + : $t('app.license_page.revoked_immediately'), + } as any)} +

+
+
+ {/if} + + {#if data.licenseStatus.activeSeats > data.licenseStatus.planSeats} + + +
+ + {$t('app.license_page.seat_limit_exceeded_title')} +
+
+ +

+ {$t('app.license_page.seat_limit_exceeded_message', { + planSeats: data.licenseStatus.planSeats, + activeSeats: data.licenseStatus.activeSeats, + } as any)} +

+
+
+ {/if} + +
+ + + {$t('app.license_page.license_details')} + + +
+ {$t('app.license_page.customer')} + {data.licenseStatus.customerName} +
+
+ {$t('app.license_page.expires')} + + {format(new Date(data.licenseStatus.expiresAt), 'PPP')} + ({formatDistanceToNow(new Date(data.licenseStatus.expiresAt), { + addSuffix: true, + })}) + +
+
+ {$t('app.license_page.status')} + {#if data.licenseStatus.remoteStatus === 'VALID' && !data.licenseStatus.isExpired} + {$t('app.license_page.active')} + {:else if data.licenseStatus.isExpired} + {$t('app.license_page.expired')} + {:else if data.licenseStatus.remoteStatus === 'REVOKED'} + {$t('app.license_page.revoked')} + {:else} + {$t('app.license_page.unknown')} + {/if} +
+
+
+ + + {$t('app.license_page.seat_usage')} + + {$t('app.license_page.seats_used', { + activeSeats: data.licenseStatus.activeSeats, + planSeats: data.licenseStatus.planSeats, + } as any)} + + + + + + +
+ + + + {$t('app.license_page.enabled_features')} + + {$t('app.license_page.enabled_features_description')} + + + + + + + {$t('app.license_page.feature')} + {$t('app.license_page.status')} + + + + {#each Object.entries(data.licenseStatus.features) as [feature, enabled]} + + {feature.replace('-', ' ')} + + {#if enabled || data.licenseStatus.features.all === true} + {$t('app.license_page.enabled')} + {:else} + {$t('app.license_page.disabled')} + {/if} + + + {/each} + +
+
+
+
diff --git a/packages/frontend/src/routes/dashboard/compliance/audit-log/+page.server.ts b/packages/frontend/src/routes/dashboard/compliance/audit-log/+page.server.ts index 0eab155..fc80f98 100644 --- a/packages/frontend/src/routes/dashboard/compliance/audit-log/+page.server.ts +++ b/packages/frontend/src/routes/dashboard/compliance/audit-log/+page.server.ts @@ -2,30 +2,24 @@ import { api } from '$lib/server/api'; import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; import type { GetAuditLogsResponse } from '@open-archiver/types'; +import { error } from '@sveltejs/kit'; + export const load: PageServerLoad = async (event) => { if (!event.locals.enterpriseMode) { - throw redirect(307, '/dashboard') + throw error(403, "This feature is only available in the Enterprise Edition. Please contact Open Archiver to upgrade.") } - try { - // Forward search params from the page URL to the API request - const response = await api(`/enterprise/audit-logs?${event.url.searchParams.toString()}`, event); - - if (!response.ok) { - const error = await response.json(); - return { error: error.message, logs: [], meta: { total: 0, page: 1, limit: 20 } }; - } - - const result: GetAuditLogsResponse = await response.json(); - return { - logs: result.data, - meta: result.meta - }; - } catch (error) { - return { - error: error instanceof Error ? error.message : 'Unknown error', - logs: [], - meta: { total: 0, page: 1, limit: 20 } - }; + // Forward search params from the page URL to the API request + const response = await api(`/enterprise/audit-logs?${event.url.searchParams.toString()}`, event); + const res = await response.json(); + if (!response.ok) { + throw error(response.status, res.message || JSON.stringify(res)) } + + const result: GetAuditLogsResponse = res; + return { + logs: result.data, + meta: result.meta + }; + }; diff --git a/packages/frontend/src/routes/dashboard/compliance/audit-log/+page.svelte b/packages/frontend/src/routes/dashboard/compliance/audit-log/+page.svelte index 775f3e0..2f797b0 100644 --- a/packages/frontend/src/routes/dashboard/compliance/audit-log/+page.svelte +++ b/packages/frontend/src/routes/dashboard/compliance/audit-log/+page.svelte @@ -3,7 +3,6 @@ import * as Table from '$lib/components/ui/table'; import { Button } from '$lib/components/ui/button'; import { t } from '$lib/translations'; - import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Badge } from '$lib/components/ui/badge'; import { ArrowUpDown } from 'lucide-svelte'; import { page } from '$app/stores'; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 739482f..d9eb456 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -12,3 +12,4 @@ export * from './audit-log.types'; export * from './audit-log.enums'; export * from './integrity.types'; export * from './jobs.types'; +export * from './license.types' \ No newline at end of file diff --git a/packages/types/src/license.types.ts b/packages/types/src/license.types.ts new file mode 100644 index 0000000..863dcf3 --- /dev/null +++ b/packages/types/src/license.types.ts @@ -0,0 +1,49 @@ +/** + * Features of Open Archiver Enterprise + */ +export enum OpenArchiverFeature { + AUDIT_LOG = 'audit-log', + RETENTION_POLICY = 'retention-policy', + SSO = 'sso', + STATUS = 'status', + ALL = 'all', +} + +/** + * The payload of the offline license.jwt file. + */ +export interface LicenseFilePayload { + licenseId: string; // UUID linking to the License Server + customerName: string; + planSeats: number; + features: OpenArchiverFeature[]; + expiresAt: string; // ISO 8601 + issuedAt: string; // ISO 8601 +} + +/** + * The structure of the cached response from the License Server. + */ +export interface LicenseStatusPayload { + status: 'VALID' | 'REVOKED'; + gracePeriodEnds?: string; // ISO 8601, only present if REVOKED +} + +/** + * The consolidated license status object returned by the API. + */ +export interface ConsolidatedLicenseStatus { + // From the license.jwt file + customerName: string; + planSeats: number; + expiresAt: string; + // From the cached license-status.json + remoteStatus: 'VALID' | 'REVOKED' | 'UNKNOWN'; + gracePeriodEnds?: string; + // Calculated values + activeSeats: number; + isExpired: boolean; + features: { + [key in OpenArchiverFeature]?: boolean; + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bb8071..c793899 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -243,12 +243,18 @@ importers: '@open-archiver/backend': specifier: workspace:* version: link:../backend + '@types/node-cron': + specifier: ^3.0.11 + version: 3.0.11 express: specifier: ^5.1.0 version: 5.1.0 jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 + node-cron: + specifier: ^4.2.1 + version: 4.2.1 zod: specifier: ^4.1.5 version: 4.1.5 @@ -283,6 +289,9 @@ importers: d3-shape: specifier: ^3.2.0 version: 3.2.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 html-entities: specifier: ^2.6.0 version: 2.6.0 @@ -1868,6 +1877,9 @@ packages: '@types/multer@2.0.0': resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} + '@types/node-cron@3.0.11': + resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} + '@types/node@24.0.13': resolution: {integrity: sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==} @@ -2498,6 +2510,9 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -3609,6 +3624,10 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -6451,6 +6470,8 @@ snapshots: dependencies: '@types/express': 5.0.3 + '@types/node-cron@3.0.11': {} + '@types/node@24.0.13': dependencies: undici-types: 7.8.0 @@ -7141,6 +7162,8 @@ snapshots: data-uri-to-buffer@4.0.1: {} + date-fns@4.1.0: {} + dateformat@4.6.3: {} debug@4.4.1: @@ -8325,6 +8348,8 @@ snapshots: node-addon-api@7.1.1: {} + node-cron@4.2.1: {} + node-domexception@1.0.0: {} node-fetch@2.7.0(encoding@0.1.13):