License service/module

This commit is contained in:
Wayne
2025-10-22 00:04:38 +02:00
parent 874fafd0f3
commit e0e7f4cab1
35 changed files with 782 additions and 132 deletions

View 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

View File

@@ -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 }}

View File

@@ -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"]

View File

@@ -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;

View File

@@ -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';

View File

@@ -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": {

View File

@@ -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
];

View 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();
}
};
};

View File

@@ -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));

View File

@@ -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;
};

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View 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();

View 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;
};

View File

@@ -1,4 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEC6I4ubMlqW28CjS4IbFnCZ8fE+etwF50
h2KM3yh6acvko/k7234dCbJ/rwJZFq7DUlG4DABMnTg/1OwbjGZiMQ==
-----END PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUusug3dvT36RcauKYmO5JtHxpSpi
X0SrmkMxGyEd18vMXBGD9piAR3MTRskQ6XOoEo0fio6s9LtgPzKkJBdyVg==
-----END PUBLIC KEY-----

View File

@@ -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));
}
}

View File

@@ -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;
};

View File

@@ -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();

View File

@@ -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",

View File

@@ -0,0 +1,7 @@
import Root from "./progress.svelte";
export {
Root,
//
Root as Progress,
};

View File

@@ -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>

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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[] {

View File

@@ -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}

View File

@@ -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;
}
};

View File

@@ -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>

View File

@@ -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
};
};

View File

@@ -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';

View File

@@ -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'

View 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
View File

@@ -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):