mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
License service/module
This commit is contained in:
41
.github/workflows/docker-deployment-enterprise.yml
vendored
Normal file
41
.github/workflows/docker-deployment-enterprise.yml
vendored
Normal file
@@ -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
|
||||
2
.github/workflows/docker-deployment.yml
vendored
2
.github/workflows/docker-deployment.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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<void>;
|
||||
name: string;
|
||||
name: OpenArchiverFeature;
|
||||
}
|
||||
|
||||
export let authService: AuthService;
|
||||
|
||||
@@ -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'
|
||||
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';
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
31
packages/enterprise/src/middleware/featureEnabled.ts
Normal file
31
packages/enterprise/src/middleware/featureEnabled.ts
Normal file
@@ -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();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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<void> {
|
||||
app.use(`/${config.api.version}/enterprise/audit-logs`, auditLogRoutes(authService));
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<number> {
|
||||
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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
14
packages/enterprise/src/modules/license/license.module.ts
Normal file
14
packages/enterprise/src/modules/license/license.module.ts
Normal file
@@ -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<void> {
|
||||
app.use(`/${config.api.version}/enterprise/status`, licenseRoutes(authService));
|
||||
}
|
||||
}
|
||||
|
||||
export const licenseModule = new LicenseModule();
|
||||
14
packages/enterprise/src/modules/license/license.routes.ts
Normal file
14
packages/enterprise/src/modules/license/license.routes.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEC6I4ubMlqW28CjS4IbFnCZ8fE+etwF50
|
||||
h2KM3yh6acvko/k7234dCbJ/rwJZFq7DUlG4DABMnTg/1OwbjGZiMQ==
|
||||
-----END PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUusug3dvT36RcauKYmO5JtHxpSpi
|
||||
X0SrmkMxGyEd18vMXBGD9piAR3MTRskQ6XOoEo0fio6s9LtgPzKkJBdyVg==
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -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<void> {
|
||||
|
||||
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<void> {
|
||||
app.use(`/${config.api.version}/enterprise/retention-policy`, retentionPolicyRoutes(authService));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<void> {
|
||||
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();
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./progress.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Progress,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { Progress as ProgressPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
max = 100,
|
||||
value,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<ProgressPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="progress"
|
||||
class={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
|
||||
{value}
|
||||
{max}
|
||||
{...restProps}
|
||||
>
|
||||
<div
|
||||
data-slot="progress-indicator"
|
||||
class="bg-primary h-full w-full flex-1 transition-all"
|
||||
style="transform: translateX(-{100 - (100 * (value ?? 0)) / (max ?? 1)}%)"
|
||||
></div>
|
||||
</ProgressPrimitive.Root>
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
|
||||
import * as Alert from '$lib/components/ui/alert/index.js';
|
||||
</script>
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -131,8 +131,9 @@
|
||||
</Table.Row>
|
||||
{#if job.error}
|
||||
<Table.Row id={`error-${job.id}`} class="hidden">
|
||||
<Table.Cell colspan={7}>
|
||||
<pre class="rounded-md bg-gray-100 p-4">{job.error}</pre>
|
||||
<Table.Cell colspan={7} class="p-0">
|
||||
<pre
|
||||
class="max-w-full text-wrap rounded-md bg-gray-100 p-4 text-xs">{job.error}</pre>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '$lib/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '$lib/components/ui/table';
|
||||
import { AlertTriangle } from 'lucide-svelte';
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { t } from '$lib/translations';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const seatUsagePercentage =
|
||||
data.licenseStatus.planSeats > 0
|
||||
? (data.licenseStatus.activeSeats / data.licenseStatus.planSeats) * 100
|
||||
: 0;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('app.license_page.title')} - Open Archiver</title>
|
||||
<meta name="description" content={$t('app.license_page.meta_description')} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold">{$t('app.license_page.title')}</h1>
|
||||
|
||||
{#if data.licenseStatus.remoteStatus === 'REVOKED'}
|
||||
<Card class="border-destructive">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-3">
|
||||
<AlertTriangle class="text-destructive h-6 w-6" />
|
||||
<CardTitle class="text-destructive"
|
||||
>{$t('app.license_page.revoked_title')}</CardTitle
|
||||
>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>
|
||||
{$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)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if data.licenseStatus.activeSeats > data.licenseStatus.planSeats}
|
||||
<Card class="border-yellow-500">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-3">
|
||||
<AlertTriangle class="h-6 w-6 text-yellow-500" />
|
||||
<CardTitle class="text-yellow-600"
|
||||
>{$t('app.license_page.seat_limit_exceeded_title')}</CardTitle
|
||||
>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>
|
||||
{$t('app.license_page.seat_limit_exceeded_message', {
|
||||
planSeats: data.licenseStatus.planSeats,
|
||||
activeSeats: data.licenseStatus.activeSeats,
|
||||
} as any)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">{$t('app.license_page.license_details')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">{$t('app.license_page.customer')}</span>
|
||||
<span class="font-medium">{data.licenseStatus.customerName}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">{$t('app.license_page.expires')}</span>
|
||||
<span class="font-medium">
|
||||
{format(new Date(data.licenseStatus.expiresAt), 'PPP')}
|
||||
({formatDistanceToNow(new Date(data.licenseStatus.expiresAt), {
|
||||
addSuffix: true,
|
||||
})})
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">{$t('app.license_page.status')}</span>
|
||||
{#if data.licenseStatus.remoteStatus === 'VALID' && !data.licenseStatus.isExpired}
|
||||
<Badge variant="default" class="bg-green-500 text-white"
|
||||
>{$t('app.license_page.active')}</Badge
|
||||
>
|
||||
{:else if data.licenseStatus.isExpired}
|
||||
<Badge variant="destructive">{$t('app.license_page.expired')}</Badge>
|
||||
{:else if data.licenseStatus.remoteStatus === 'REVOKED'}
|
||||
<Badge variant="destructive">{$t('app.license_page.revoked')}</Badge>
|
||||
{:else}
|
||||
<Badge variant="secondary">{$t('app.license_page.unknown')}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">{$t('app.license_page.seat_usage')}</CardTitle>
|
||||
<CardDescription>
|
||||
{$t('app.license_page.seats_used', {
|
||||
activeSeats: data.licenseStatus.activeSeats,
|
||||
planSeats: data.licenseStatus.planSeats,
|
||||
} as any)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Progress value={seatUsagePercentage} class="w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{$t('app.license_page.enabled_features')}</CardTitle>
|
||||
<CardDescription>
|
||||
{$t('app.license_page.enabled_features_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{$t('app.license_page.feature')}</TableHead>
|
||||
<TableHead class="text-right">{$t('app.license_page.status')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each Object.entries(data.licenseStatus.features) as [feature, enabled]}
|
||||
<TableRow>
|
||||
<TableCell class="font-medium capitalize"
|
||||
>{feature.replace('-', ' ')}</TableCell
|
||||
>
|
||||
<TableCell class="text-right">
|
||||
{#if enabled || data.licenseStatus.features.all === true}
|
||||
<Badge variant="default" class="bg-green-500 text-white"
|
||||
>{$t('app.license_page.enabled')}</Badge
|
||||
>
|
||||
{:else}
|
||||
<Badge variant="destructive"
|
||||
>{$t('app.license_page.disabled')}</Badge
|
||||
>
|
||||
{/if}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -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
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'
|
||||
49
packages/types/src/license.types.ts
Normal file
49
packages/types/src/license.types.ts
Normal file
@@ -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;
|
||||
};
|
||||
}
|
||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user