Compare commits

..

22 Commits

Author SHA1 Message Date
Wayne
4b6c9d9e18 Copy locale files in backend build 2025-08-31 15:09:10 +03:00
Wayne
0eb0c670c9 Docs site title 2025-08-29 16:28:37 +03:00
Wayne
723a222816 Translation revamp for frontend and backend, adding systems docs 2025-08-29 16:28:04 +03:00
Wayne
739c437b0a Formatting code 2025-08-29 15:20:02 +03:00
Wayne
535fde1426 feat(backend): Implement i18n for API responses
This commit introduces internationalization (i18n) to the backend API using the `i18next` library.

Hardcoded error and response messages in the API controllers have been replaced with translation keys, which are processed by the new i18next middleware. This allows for API responses to be translated into different languages.

The following dependencies were added:
- `i18next`
- `i18next-fs-backend`
- `i18next-http-middleware`
2025-08-29 15:17:54 +03:00
Wayne
cd5f8fa313 Adding greek translation 2025-08-29 13:56:28 +03:00
Wayne
8675f90606 Merge branch 'main' into system-settings 2025-08-28 19:34:50 +03:00
Wayne
67cef40e5c feat: Add internationalization (i18n) support to frontend
This commit introduces internationalization (i18n) to the frontend using the `sveltekit-i18n` library, allowing the user interface to be translated into multiple languages.

Key changes:
- Added translation files for 10 languages (en, de, es, fr, etc.).
- Replaced hardcoded text strings throughout the frontend components and pages with translation keys.
- Added a language selector to the system settings page, allowing administrators to set the default application language.
- Updated the backend settings API to store and expose the new language configuration.
2025-08-28 19:22:21 +03:00
Wayne
159f7d8777 Multi-language support 2025-08-28 19:17:36 +03:00
Wayne
feeda60b7e Merge branch 'main' into system-settings 2025-08-24 14:09:22 +02:00
Wayne
c2782ff9c6 System settings setup 2025-08-23 20:36:54 +03:00
Wayne
32c016dbfe Resolve conflict 2025-08-22 13:51:33 +03:00
Wayne
317f034c56 Format 2025-08-22 13:47:48 +03:00
Wayne
faadc2fad6 Remove inherent behavior, index userEmail, adding docs for IAM policies 2025-08-22 13:43:00 +03:00
Wayne
3ab76f5c2d Fix: fix old "Super Admin" role in existing db 2025-08-22 00:47:51 +03:00
Wayne
5b5bb019fc Merge branch 'main' into role-based-access 2025-08-21 23:52:26 +03:00
Wayne
db38dde86f Switch to CASL, secure search, resource-level access control 2025-08-21 23:39:02 +03:00
Wayne
d81abc657b RBAC using CASL library 2025-08-20 01:08:51 +03:00
Wayne
720160a3d8 IAP API, create user/roles in frontend 2025-08-19 11:20:30 +03:00
Wayne
2987f159dd Middleware setup 2025-08-18 14:09:02 +03:00
Wayne
47324f76ea Merge branch 'main' into dev 2025-08-17 17:40:55 +03:00
Wayne
5f8d201726 Format checked, contributing.md update 2025-08-17 17:38:16 +03:00
50 changed files with 737 additions and 3469 deletions

View File

@@ -54,19 +54,17 @@ STORAGE_S3_FORCE_PATH_STYLE=false
# --- Security & Authentication ---
# Rate Limiting
# The window in milliseconds for which API requests are checked. Defaults to 60000 (1 minute).
RATE_LIMIT_WINDOW_MS=60000
# The maximum number of API requests allowed from an IP within the window. Defaults to 100.
RATE_LIMIT_MAX_REQUESTS=100
# JWT
# IMPORTANT: Change this to a long, random, and secret string in your .env file
JWT_SECRET=a-very-secret-key-that-you-should-change
JWT_EXPIRES_IN="7d"
# Set the credentials for the initial admin user.
SUPER_API_KEY=
# Master Encryption Key for sensitive data (Such as Ingestion source credentials and passwords)
# IMPORTANT: Generate a secure, random 32-byte hex string for this
# You can use `openssl rand -hex 32` to generate a key.
ENCRYPTION_KEY=

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
github: [wayneshn]

View File

@@ -78,7 +78,7 @@ Open Archiver is built on a modern, scalable, and maintainable technology stack:
```bash
git clone https://github.com/LogicLabs-OU/OpenArchiver.git
cd OpenArchiver
cd open-archiver
```
2. **Configure your environment:**

View File

@@ -6,6 +6,7 @@ services:
container_name: open-archiver
restart: unless-stopped
ports:
- '4000:4000' # Backend
- '3000:3000' # Frontend
env_file:
- .env
@@ -28,6 +29,8 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- '5432:5432'
networks:
- open-archiver-net
@@ -36,6 +39,8 @@ services:
container_name: valkey
restart: unless-stopped
command: valkey-server --requirepass ${REDIS_PASSWORD}
ports:
- '6379:6379'
volumes:
- valkeydata:/data
networks:
@@ -47,6 +52,8 @@ services:
restart: unless-stopped
environment:
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-aSampleMasterKey}
ports:
- '7700:7700'
volumes:
- meilidata:/meili_data
networks:

View File

@@ -71,7 +71,6 @@ export default defineConfig({
items: [
{ text: 'Overview', link: '/api/' },
{ text: 'Authentication', link: '/api/authentication' },
{ text: 'Rate Limiting', link: '/api/rate-limiting' },
{ text: 'Auth', link: '/api/auth' },
{ text: 'Archived Email', link: '/api/archived-email' },
{ text: 'Dashboard', link: '/api/dashboard' },

View File

@@ -1,25 +1,60 @@
# API Authentication
To access protected API endpoints, you need to include a user-generated API key in the `X-API-KEY` header of your requests.
To access protected API endpoints, you need to include a JSON Web Token (JWT) in the `Authorization` header of your requests.
## 1. Creating an API Key
## Obtaining a JWT
You can create, manage, and view your API keys through the application's user interface.
First, you need to authenticate with the `/api/v1/auth/login` endpoint by providing your email and password. If the credentials are correct, the API will return an `accessToken`.
1. Navigate to **Settings > API Keys** in the dashboard.
2. Click the **"Generate API Key"** button.
3. Provide a descriptive name for your key and select an expiration period.
4. The new API key will be displayed. **Copy this key immediately and store it in a secure location. You will not be able to see it again.**
**Request:**
## 2. Making Authenticated Requests
```http
POST /api/v1/auth/login
Content-Type: application/json
Once you have your API key, you must include it in the `X-API-KEY` header of all subsequent requests to protected API endpoints.
{
"email": "user@example.com",
"password": "your-password"
}
```
**Successful Response:**
```json
{
"accessToken": "your.jwt.token",
"user": {
"id": "user-id",
"email": "user@example.com",
"role": "user"
}
}
```
## Making Authenticated Requests
Once you have the `accessToken`, you must include it in the `Authorization` header of all subsequent requests to protected endpoints, using the `Bearer` scheme.
**Example:**
```http
GET /api/v1/dashboard/stats
X-API-KEY: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Authorization: Bearer your.jwt.token
```
If the API key is missing, expired, or invalid, the API will respond with a `401 Unauthorized` status code.
If the token is missing, expired, or invalid, the API will respond with a `401 Unauthorized` status code.
## Using a Super API Key
Alternatively, for server-to-server communication or scripts, you can use a super API key. This key provides unrestricted access to the API and should be kept secret.
You can set the `SUPER_API_KEY` in your `.env` file.
To authenticate using the super API key, include it in the `Authorization` header as a Bearer token.
**Example:**
```http
GET /api/v1/dashboard/stats
Authorization: Bearer your-super-secret-api-key
```

View File

@@ -1,51 +0,0 @@
# Rate Limiting
The API implements rate limiting as a security measure to protect your instance from denial-of-service (DoS) and brute-force attacks. This is a crucial feature for maintaining the security and stability of the application.
## How It Works
The rate limiter restricts the number of requests an IP address can make within a specific time frame. These limits are configurable via environment variables to suit your security needs.
By default, the limits are:
- **100 requests** per **1 minute** per IP address.
If this limit is exceeded, the API will respond with an HTTP `429 Too Many Requests` status code.
### Response Body
When an IP address is rate-limited, the API will return a JSON response with the following format:
```json
{
"status": 429,
"message": "Too many requests from this IP, please try again after 15 minutes"
}
```
## Configuration
You can customize the rate-limiting settings by setting the following environment variables in your `.env` file:
- `RATE_LIMIT_WINDOW_MS`: The time window in milliseconds. Defaults to `60000` (1 minute).
- `RATE_LIMIT_MAX_REQUESTS`: The maximum number of requests allowed per IP address within the time window. Defaults to `100`.
## Handling Rate Limits
If you are developing a client that interacts with the API, you should handle rate limiting gracefully:
1. **Check the Status Code**: Monitor for a `429` HTTP status code in responses.
2. **Implement a Retry Mechanism**: When you receive a `429` response, it is best practice to wait before retrying the request. Implementing an exponential backoff strategy is recommended.
3. **Check Headers**: The response will include the following standard headers to help you manage your request rate:
- `RateLimit-Limit`: The maximum number of requests allowed in the current window.
- `RateLimit-Remaining`: The number of requests you have left in the current window.
- `RateLimit-Reset`: The time when the rate limit window will reset, in UTC epoch seconds.
## Excluded Endpoints
Certain essential endpoints are excluded from rate limiting to ensure the application's UI remains responsive. These are:
- `/auth/status`
- `/settings/system`
These endpoints can be called as needed without affecting your rate limit count.

View File

@@ -105,14 +105,12 @@ These variables are used by `docker-compose.yml` to configure the services.
#### Security & Authentication
| Variable | Description | Default Value |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| `JWT_SECRET` | A secret key for signing JWT tokens. | `a-very-secret-key-that-you-should-change` |
| `JWT_EXPIRES_IN` | The expiration time for JWT tokens. | `7d` |
| ~~`SUPER_API_KEY`~~ (Deprecated) | An API key with super admin privileges. (The SUPER_API_KEY is deprecated since v0.3.0 after we roll out the role-based access control system.) | |
| `RATE_LIMIT_WINDOW_MS` | The window in milliseconds for which API requests are checked. | `900000` (15 minutes) |
| `RATE_LIMIT_MAX_REQUESTS` | The maximum number of API requests allowed from an IP within the window. | `100` |
| `ENCRYPTION_KEY` | A 32-byte hex string for encrypting sensitive data in the database. | |
| Variable | Description | Default Value |
| ---------------- | ------------------------------------------------------------------- | ------------------------------------------ |
| `JWT_SECRET` | A secret key for signing JWT tokens. | `a-very-secret-key-that-you-should-change` |
| `JWT_EXPIRES_IN` | The expiration time for JWT tokens. | `7d` |
| `SUPER_API_KEY` | An API key with super admin privileges. | |
| `ENCRYPTION_KEY` | A 32-byte hex string for encrypting sensitive data in the database. | |
## 3. Run the Application
@@ -299,31 +297,3 @@ After you've saved the changes, run the following command in your terminal to ap
```
After this, any new data will be saved directly into the `./data/open-archiver` folder in your project directory.
## Troubleshooting
### 403 Cross-Site POST Forbidden Error
If you are running the application behind a reverse proxy or have mapped the application to a different port (e.g., `3005:3000`), you may encounter a `403 Cross-site POST from submissions are forbidden` error when uploading files.
To resolve this, you must set the `ORIGIN` environment variable to the URL of your application. This ensures that the backend can verify the origin of requests and prevent cross-site request forgery (CSRF) attacks.
Add the following line to your `.env` file, replacing `<your_host>` and `<your_port>` with your specific values:
```bash
ORIGIN=http://<your_host>:<your_port>
```
For example, if your application is accessible at `http://localhost:3005`, you would set the variable as follows:
```bash
ORIGIN=http://localhost:3005
```
After adding the `ORIGIN` variable, restart your Docker containers for the changes to take effect:
```bash
docker-compose up -d --force-recreate
```
This will ensure that your file uploads are correctly authorized.

View File

@@ -1,6 +1,5 @@
{
"name": "open-archiver",
"version": "0.3.1",
"private": true,
"scripts": {
"dev": "dotenv -- pnpm --filter \"./packages/*\" --parallel dev",

View File

@@ -59,9 +59,8 @@
"reflect-metadata": "^0.2.2",
"sqlite3": "^5.1.7",
"tsconfig-paths": "^4.2.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yauzl": "^3.2.0",
"zod": "^4.1.5"
"xlsx": "^0.18.5",
"yauzl": "^3.2.0"
},
"devDependencies": {
"@bull-board/api": "^6.11.0",
@@ -74,6 +73,7 @@
"@types/multer": "^2.0.0",
"@types/node": "^24.0.12",
"@types/yauzl": "^2.10.3",
"bull-board": "^2.1.3",
"ts-node-dev": "^2.0.0",
"typescript": "^5.8.3"
}

View File

@@ -1,66 +0,0 @@
import { Request, Response } from 'express';
import { ApiKeyService } from '../../services/ApiKeyService';
import { z } from 'zod';
import { config } from '../../config';
const generateApiKeySchema = z.object({
name: z
.string()
.min(1, 'API kay name must be more than 1 characters')
.max(255, 'API kay name must not be more than 255 characters'),
expiresInDays: z
.number()
.int()
.positive('Only positive number is allowed')
.max(730, 'The API key must expire within 2 years / 730 days.'),
});
export class ApiKeyController {
public async generateApiKey(req: Request, res: Response) {
if (config.app.isDemo) {
return res.status(403).json({ message: req.t('errors.demoMode') });
}
try {
const { name, expiresInDays } = generateApiKeySchema.parse(req.body);
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
const userId = req.user.sub;
const key = await ApiKeyService.generate(userId, name, expiresInDays);
res.status(201).json({ key });
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ message: req.t('api.requestBodyInvalid'), errors: error.message });
}
res.status(500).json({ message: req.t('errors.internalServerError') });
}
}
public async getApiKeys(req: Request, res: Response) {
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
const userId = req.user.sub;
const keys = await ApiKeyService.getKeys(userId);
res.status(200).json(keys);
}
public async deleteApiKey(req: Request, res: Response) {
if (config.app.isDemo) {
return res.status(403).json({ message: req.t('errors.demoMode') });
}
const { id } = req.params;
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
const userId = req.user.sub;
await ApiKeyService.deleteKey(id, userId);
res.status(204).send({ message: req.t('apiKeys.deleteSuccess') });
}
}

View File

@@ -121,7 +121,7 @@ export class AuthController {
);
return res.status(200).json({ needsSetup: false });
}
return res.status(200).json({ needsSetup: needsSetupUser });
return res.status(200).json({ needsSetupUser });
} catch (error) {
console.error('Status check error:', error);
return res.status(500).json({ message: req.t('errors.internalServerError') });

View File

@@ -1,12 +1,11 @@
import type { Request, Response } from 'express';
import { SettingsService } from '../../services/SettingsService';
import { config } from '../../config';
const settingsService = new SettingsService();
export const getSystemSettings = async (req: Request, res: Response) => {
export const getSettings = async (req: Request, res: Response) => {
try {
const settings = await settingsService.getSystemSettings();
const settings = await settingsService.getSettings();
res.status(200).json(settings);
} catch (error) {
// A more specific error could be logged here
@@ -14,13 +13,10 @@ export const getSystemSettings = async (req: Request, res: Response) => {
}
};
export const updateSystemSettings = async (req: Request, res: Response) => {
export const updateSettings = async (req: Request, res: Response) => {
try {
// Basic validation can be performed here if necessary
if (config.app.isDemo) {
return res.status(403).json({ message: req.t('errors.demoMode') });
}
const updatedSettings = await settingsService.updateSystemSettings(req.body);
const updatedSettings = await settingsService.updateSettings(req.body);
res.status(200).json(updatedSettings);
} catch (error) {
// A more specific error could be logged here

View File

@@ -1,16 +1,10 @@
import rateLimit from 'express-rate-limit';
import { config } from '../../config';
const windowInMinutes = Math.ceil(config.api.rateLimit.windowMs / 60000);
export const rateLimiter = rateLimit({
windowMs: config.api.rateLimit.windowMs,
max: config.api.rateLimit.max,
message: {
status: 429,
message: `Too many requests from this IP, please try again after ${windowInMinutes} minutes`,
},
statusCode: 429,
standardHeaders: true,
legacyHeaders: false,
// Rate limiter to prevent brute-force attacks on the login endpoint
export const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Limit each IP to 10 login requests per windowMs
message: 'Too many login attempts from this IP, please try again after 15 minutes',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});

View File

@@ -2,9 +2,6 @@ import type { Request, Response, NextFunction } from 'express';
import type { AuthService } from '../../services/AuthService';
import type { AuthTokenPayload } from '@open-archiver/types';
import 'dotenv/config';
import { ApiKeyService } from '../../services/ApiKeyService';
import { UserService } from '../../services/UserService';
// By using module augmentation, we can add our custom 'user' property
// to the Express Request interface in a type-safe way.
declare global {
@@ -18,30 +15,16 @@ declare global {
export const requireAuth = (authService: AuthService) => {
return async (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
const apiKeyHeader = req.headers['x-api-key'];
if (apiKeyHeader) {
const userId = await ApiKeyService.validateKey(apiKeyHeader as string);
if (!userId) {
return res.status(401).json({ message: 'Unauthorized: Invalid API key' });
}
const user = await new UserService().findById(userId);
if (!user) {
return res.status(401).json({ message: 'Unauthorized: Invalid user' });
}
req.user = {
sub: user.id,
email: user.email,
roles: user.role ? [user.role.name] : [],
};
return next();
}
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Unauthorized: No token provided' });
}
const token = authHeader.split(' ')[1];
try {
// use a SUPER_API_KEY for all authentications. add process.env.SUPER_API_KEY conditional check in case user didn't set a SUPER_API_KEY.
if (process.env.SUPER_API_KEY && token === process.env.SUPER_API_KEY) {
next();
return;
}
const payload = await authService.verifyToken(token);
if (!payload) {
return res.status(401).json({ message: 'Unauthorized: Invalid token' });

View File

@@ -1,15 +0,0 @@
import { Router } from 'express';
import { ApiKeyController } from '../controllers/api-key.controller';
import { requireAuth } from '../middleware/requireAuth';
import { AuthService } from '../../services/AuthService';
export const apiKeyRoutes = (authService: AuthService) => {
const router = Router();
const controller = new ApiKeyController();
router.post('/', requireAuth(authService), controller.generateApiKey);
router.get('/', requireAuth(authService), controller.getApiKeys);
router.delete('/:id', requireAuth(authService), controller.deleteApiKey);
return router;
};

View File

@@ -1,4 +1,5 @@
import { Router } from 'express';
import { loginRateLimiter } from '../middleware/rateLimiter';
import type { AuthController } from '../controllers/auth.controller';
export const createAuthRouter = (authController: AuthController): Router => {
@@ -9,14 +10,14 @@ export const createAuthRouter = (authController: AuthController): Router => {
* @description Creates the initial administrator user.
* @access Public
*/
router.post('/setup', authController.setup);
router.post('/setup', loginRateLimiter, authController.setup);
/**
* @route POST /api/v1/auth/login
* @description Authenticates a user and returns a JWT.
* @access Public
*/
router.post('/login', authController.login);
router.post('/login', loginRateLimiter, authController.login);
/**
* @route GET /api/v1/auth/status

View File

@@ -11,14 +11,14 @@ export const createSettingsRouter = (authService: AuthService): Router => {
/**
* @returns SystemSettings
*/
router.get('/system', settingsController.getSystemSettings);
router.get('/', settingsController.getSettings);
// Protected route to update settings
router.put(
'/system',
'/',
requireAuth(authService),
requirePermission('manage', 'settings', 'settings.noPermissionToUpdate'),
settingsController.updateSystemSettings
settingsController.updateSettings
);
return router;

View File

@@ -1,12 +0,0 @@
import 'dotenv/config';
export const apiConfig = {
rateLimit: {
windowMs: process.env.RATE_LIMIT_WINDOW_MS
? parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10)
: 1 * 60 * 1000, // 1 minutes
max: process.env.RATE_LIMIT_MAX_REQUESTS
? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10)
: 100, // limit each IP to 100 requests per windowMs
},
};

View File

@@ -2,12 +2,10 @@ import { storage } from './storage';
import { app } from './app';
import { searchConfig } from './search';
import { connection as redisConfig } from './redis';
import { apiConfig } from './api';
export const config = {
storage,
app,
search: searchConfig,
redis: redisConfig,
api: apiConfig,
};

View File

@@ -1,11 +0,0 @@
CREATE TABLE "api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"user_id" uuid NOT NULL,
"key" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -1 +0,0 @@
ALTER TABLE "api_keys" ADD COLUMN "key_hash" text NOT NULL;

View File

@@ -127,20 +127,6 @@
"when": 1755961566627,
"tag": "0017_tranquil_shooting_star",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1756911118035,
"tag": "0018_flawless_owl",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1756937533843,
"tag": "0019_confused_scream",
"breakpoints": true
}
]
}

View File

@@ -6,4 +6,3 @@ export * from './schema/custodians';
export * from './schema/ingestion-sources';
export * from './schema/users';
export * from './schema/system-settings';
export * from './schema/api-keys';

View File

@@ -1,15 +0,0 @@
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { users } from './users';
export const apiKeys = pgTable('api_keys', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
key: text('key').notNull(), // Encrypted API key
keyHash: text('key_hash').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

View File

@@ -17,7 +17,6 @@ import { createDashboardRouter } from './api/routes/dashboard.routes';
import { createUploadRouter } from './api/routes/upload.routes';
import { createUserRouter } from './api/routes/user.routes';
import { createSettingsRouter } from './api/routes/settings.routes';
import { apiKeyRoutes } from './api/routes/api-key.routes';
import { AuthService } from './services/AuthService';
import { UserService } from './services/UserService';
import { IamService } from './services/IamService';
@@ -29,7 +28,6 @@ import FsBackend from 'i18next-fs-backend';
import i18nextMiddleware from 'i18next-http-middleware';
import path from 'path';
import { logger } from './config/logger';
import { rateLimiter } from './api/middleware/rateLimiter';
// Load environment variables
dotenv.config();
@@ -45,7 +43,7 @@ if (!PORT_BACKEND || !JWT_SECRET || !JWT_EXPIRES_IN) {
// --- i18next Initialization ---
const initializeI18next = async () => {
const systemSettings = await settingsService.getSystemSettings();
const systemSettings = await settingsService.getSettings();
const defaultLanguage = systemSettings?.language || 'en';
logger.info({ language: defaultLanguage }, 'Default language');
await i18next.use(FsBackend).init({
@@ -88,21 +86,10 @@ const iamRouter = createIamRouter(iamController, authService);
const uploadRouter = createUploadRouter(authService);
const userRouter = createUserRouter(authService);
const settingsRouter = createSettingsRouter(authService);
const apiKeyRouter = apiKeyRoutes(authService);
// upload route is added before middleware because it doesn't use the json middleware.
app.use('/v1/upload', uploadRouter);
// Middleware for all other routes
app.use((req, res, next) => {
// exclude certain API endpoints from the rate limiter, for example status, system settings
const excludedPatterns = [/^\/v\d+\/auth\/status$/, /^\/v\d+\/settings\/system$/];
for (const pattern of excludedPatterns) {
if (pattern.test(req.path)) {
return next();
}
}
rateLimiter(req, res, next);
});
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
@@ -118,7 +105,6 @@ app.use('/v1/search', searchRouter);
app.use('/v1/dashboard', dashboardRouter);
app.use('/v1/users', userRouter);
app.use('/v1/settings', settingsRouter);
app.use('/v1/api-keys', apiKeyRouter);
// Example of a protected route
app.get('/v1/protected', requireAuth(authService), (req, res) => {

View File

@@ -58,12 +58,5 @@
"invalidFilePath": "Invalid file path",
"fileNotFound": "File not found",
"downloadError": "Error downloading file"
},
"apiKeys": {
"generateSuccess": "API key generated successfully.",
"deleteSuccess": "API key deleted successfully."
},
"api": {
"requestBodyInvalid": "Invalid request body."
}
}

View File

@@ -1,72 +0,0 @@
import { randomBytes, createHash } from 'crypto';
import { db } from '../database';
import { apiKeys } from '../database/schema/api-keys';
import { CryptoService } from './CryptoService';
import { and, eq } from 'drizzle-orm';
import { ApiKey } from '@open-archiver/types';
export class ApiKeyService {
public static async generate(
userId: string,
name: string,
expiresInDays: number
): Promise<string> {
const key = randomBytes(32).toString('hex');
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
const keyHash = createHash('sha256').update(key).digest('hex');
await db.insert(apiKeys).values({
userId,
name,
key: CryptoService.encrypt(key),
keyHash,
expiresAt,
});
return key;
}
public static async getKeys(userId: string): Promise<ApiKey[]> {
const keys = await db.select().from(apiKeys).where(eq(apiKeys.userId, userId));
return keys
.map((apiKey) => {
const decryptedKey = CryptoService.decrypt(apiKey.key);
if (!decryptedKey) {
return null;
}
return {
...apiKey,
key: decryptedKey.slice(0, 5) + '*****',
expiresAt: apiKey.expiresAt.toISOString(),
createdAt: apiKey.createdAt.toISOString(),
};
})
.filter((k): k is NonNullable<typeof k> => k !== null);
}
public static async deleteKey(id: string, userId: string) {
await db.delete(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId)));
}
/**
*
* @param key API key
* @returns The owner user ID or null. null means the API key is not found.
*/
public static async validateKey(key: string): Promise<string | null> {
const keyHash = createHash('sha256').update(key).digest('hex');
const [apiKey] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash));
if (!apiKey || apiKey.expiresAt < new Date()) {
return null;
}
const decryptedKey = CryptoService.decrypt(apiKey.key);
if (decryptedKey !== key) {
// This should not happen if the hash matches, but as a security measure, we double-check.
return null;
}
return apiKey.userId;
}
}

View File

@@ -99,7 +99,7 @@ export class IndexingService {
archivedEmailId,
email.userEmail || ''
);
// console.log(document);
console.log(document);
await this.searchService.addDocuments('emails', [document], 'id');
}
@@ -129,7 +129,7 @@ export class IndexingService {
// skip attachment or fail the job
}
}
// console.log('email.userEmail', userEmail);
console.log('email.userEmail', userEmail);
return {
id: archivedEmailId,
userEmail: userEmail,
@@ -165,7 +165,7 @@ export class IndexingService {
'';
const recipients = email.recipients as DbRecipients;
// console.log('email.userEmail', email.userEmail);
console.log('email.userEmail', email.userEmail);
return {
id: email.id,
userEmail: userEmail,

View File

@@ -15,11 +15,11 @@ export class SettingsService {
* If no settings exist, it initializes and returns the default settings.
* @returns The system settings.
*/
public async getSystemSettings(): Promise<SystemSettings> {
public async getSettings(): Promise<SystemSettings> {
const settings = await db.select().from(systemSettings).limit(1);
if (settings.length === 0) {
return this.createDefaultSystemSettings();
return this.createDefaultSettings();
}
return settings[0].config;
@@ -30,8 +30,8 @@ export class SettingsService {
* @param newConfig - A partial object of the new settings configuration.
* @returns The updated system settings.
*/
public async updateSystemSettings(newConfig: Partial<SystemSettings>): Promise<SystemSettings> {
const currentConfig = await this.getSystemSettings();
public async updateSettings(newConfig: Partial<SystemSettings>): Promise<SystemSettings> {
const currentConfig = await this.getSettings();
const mergedConfig = { ...currentConfig, ...newConfig };
// Since getSettings ensures a record always exists, we can directly update.
@@ -45,7 +45,7 @@ export class SettingsService {
* This is called internally when no settings are found.
* @returns The newly created default settings.
*/
private async createDefaultSystemSettings(): Promise<SystemSettings> {
private async createDefaultSettings(): Promise<SystemSettings> {
const [result] = await db
.insert(systemSettings)
.values({ config: DEFAULT_SETTINGS })

View File

@@ -26,10 +26,6 @@ export class ImapConnector implements IEmailConnector {
host: this.credentials.host,
port: this.credentials.port,
secure: this.credentials.secure,
tls: {
rejectUnauthorized: this.credentials.allowInsecureCert,
requestCert: true,
},
auth: {
user: this.credentials.username,
pass: this.credentials.password,
@@ -149,112 +145,107 @@ export class ImapConnector implements IEmailConnector {
userEmail: string,
syncState?: SyncState | null
): AsyncGenerator<EmailObject | null> {
try {
// list all mailboxes first
const mailboxes = await this.withRetry(async () => await this.client.list());
// list all mailboxes first
const mailboxes = await this.withRetry(async () => await this.client.list());
await this.disconnect();
const processableMailboxes = mailboxes.filter((mailbox) => {
// filter out trash and all mail emails
if (mailbox.specialUse) {
const specialUse = mailbox.specialUse.toLowerCase();
if (
specialUse === '\\junk' ||
specialUse === '\\trash' ||
specialUse === '\\all'
) {
return false;
}
}
// Fallback to checking flags
if (
mailbox.flags.has('\\Noselect') ||
mailbox.flags.has('\\Trash') ||
mailbox.flags.has('\\Junk') ||
mailbox.flags.has('\\All')
) {
const processableMailboxes = mailboxes.filter((mailbox) => {
// filter out trash and all mail emails
if (mailbox.specialUse) {
const specialUse = mailbox.specialUse.toLowerCase();
if (specialUse === '\\junk' || specialUse === '\\trash' || specialUse === '\\all') {
return false;
}
}
// Fallback to checking flags
if (
mailbox.flags.has('\\Noselect') ||
mailbox.flags.has('\\Trash') ||
mailbox.flags.has('\\Junk') ||
mailbox.flags.has('\\All')
) {
return false;
}
return true;
});
return true;
});
for (const mailboxInfo of processableMailboxes) {
const mailboxPath = mailboxInfo.path;
logger.info({ mailboxPath }, 'Processing mailbox');
for (const mailboxInfo of processableMailboxes) {
const mailboxPath = mailboxInfo.path;
logger.info({ mailboxPath }, 'Processing mailbox');
try {
const mailbox = await this.withRetry(
async () => await this.client.mailboxOpen(mailboxPath)
);
const lastUid = syncState?.imap?.[mailboxPath]?.maxUid;
let currentMaxUid = lastUid || 0;
try {
const mailbox = await this.withRetry(
async () => await this.client.mailboxOpen(mailboxPath)
);
const lastUid = syncState?.imap?.[mailboxPath]?.maxUid;
let currentMaxUid = lastUid || 0;
if (mailbox.exists > 0) {
const lastMessage = await this.client.fetchOne(String(mailbox.exists), {
uid: true,
});
if (lastMessage && lastMessage.uid > currentMaxUid) {
currentMaxUid = lastMessage.uid;
}
}
// Initialize with last synced UID, not the maximum UID in mailbox
this.newMaxUids[mailboxPath] = lastUid || 0;
// Only fetch if the mailbox has messages, to avoid errors on empty mailboxes with some IMAP servers.
if (mailbox.exists > 0) {
const BATCH_SIZE = 250; // A configurable batch size
let startUid = (lastUid || 0) + 1;
const maxUidToFetch = currentMaxUid;
while (startUid <= maxUidToFetch) {
const endUid = Math.min(startUid + BATCH_SIZE - 1, maxUidToFetch);
const searchCriteria = { uid: `${startUid}:${endUid}` };
for await (const msg of this.client.fetch(searchCriteria, {
envelope: true,
source: true,
bodyStructure: true,
uid: true,
})) {
if (lastUid && msg.uid <= lastUid) {
continue;
}
if (msg.uid > this.newMaxUids[mailboxPath]) {
this.newMaxUids[mailboxPath] = msg.uid;
}
logger.debug({ mailboxPath, uid: msg.uid }, 'Processing message');
if (msg.envelope && msg.source) {
try {
yield await this.parseMessage(msg, mailboxPath);
} catch (err: any) {
logger.error(
{ err, mailboxPath, uid: msg.uid },
'Failed to parse message'
);
throw err;
}
}
}
// Move to the next batch
startUid = endUid + 1;
}
}
} catch (err: any) {
logger.error({ err, mailboxPath }, 'Failed to process mailbox');
// Check if the error indicates a persistent failure after retries
if (err.message.includes('IMAP operation failed after all retries')) {
this.statusMessage =
'Sync paused due to reaching the mail server rate limit. The process will automatically resume later.';
if (mailbox.exists > 0) {
const lastMessage = await this.client.fetchOne(String(mailbox.exists), {
uid: true,
});
if (lastMessage && lastMessage.uid > currentMaxUid) {
currentMaxUid = lastMessage.uid;
}
}
// Initialize with last synced UID, not the maximum UID in mailbox
this.newMaxUids[mailboxPath] = lastUid || 0;
// Only fetch if the mailbox has messages, to avoid errors on empty mailboxes with some IMAP servers.
if (mailbox.exists > 0) {
const BATCH_SIZE = 250; // A configurable batch size
let startUid = (lastUid || 0) + 1;
const maxUidToFetch = currentMaxUid;
while (startUid <= maxUidToFetch) {
const endUid = Math.min(startUid + BATCH_SIZE - 1, maxUidToFetch);
const searchCriteria = { uid: `${startUid}:${endUid}` };
for await (const msg of this.client.fetch(searchCriteria, {
envelope: true,
source: true,
bodyStructure: true,
uid: true,
})) {
if (lastUid && msg.uid <= lastUid) {
continue;
}
if (msg.uid > this.newMaxUids[mailboxPath]) {
this.newMaxUids[mailboxPath] = msg.uid;
}
logger.debug({ mailboxPath, uid: msg.uid }, 'Processing message');
if (msg.envelope && msg.source) {
try {
yield await this.parseMessage(msg, mailboxPath);
} catch (err: any) {
logger.error(
{ err, mailboxPath, uid: msg.uid },
'Failed to parse message'
);
throw err;
}
}
}
// Move to the next batch
startUid = endUid + 1;
}
}
} catch (err: any) {
logger.error({ err, mailboxPath }, 'Failed to process mailbox');
// Check if the error indicates a persistent failure after retries
if (err.message.includes('IMAP operation failed after all retries')) {
this.statusMessage =
'Sync paused due to reaching the mail server rate limit. The process will automatically resume later.';
}
} finally {
await this.disconnect();
}
} finally {
await this.disconnect();
}
}

View File

@@ -15,14 +15,13 @@
"dependencies": {
"@iconify/svelte": "^5.0.1",
"@open-archiver/types": "workspace:*",
"@sveltejs/kit": "^2.38.1",
"@sveltejs/kit": "^2.16.0",
"bits-ui": "^2.8.10",
"clsx": "^2.1.1",
"d3-shape": "^3.2.0",
"jose": "^6.0.1",
"lucide-svelte": "^0.525.0",
"postal-mime": "^2.4.4",
"semver": "^7.7.2",
"svelte-persisted-store": "^0.12.0",
"sveltekit-i18n": "^2.4.2",
"tailwind-merge": "^3.3.1",
@@ -36,7 +35,6 @@
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/d3-shape": "^3.1.7",
"@types/semver": "^7.7.1",
"dotenv": "^17.2.0",
"layerchart": "2.0.0-next.27",
"mode-watcher": "^1.1.0",

View File

@@ -52,16 +52,16 @@
<div class="mt-2 rounded-md border bg-white p-4">
{#if isLoading}
<p>{$t('app.components.email_preview.loading')}</p>
<p>{$t('components.email_preview.loading')}</p>
{:else if emailHtml}
<iframe
title={$t('app.archive.email_preview')}
title={$t('archive.email_preview')}
srcdoc={emailHtml()}
class="h-[600px] w-full border-none"
></iframe>
{:else if raw}
<p>{$t('app.components.email_preview.render_error')}</p>
<p>{$t('components.email_preview.render_error')}</p>
{:else}
<p class="text-gray-500">{$t('app.components.email_preview.not_available')}</p>
<p class="text-gray-500">{$t('components.email_preview.not_available')}</p>
{/if}
</div>

View File

@@ -1,39 +1,18 @@
<script lang="ts">
import { t } from '$lib/translations';
import * as Alert from '$lib/components/ui/alert';
import { Info } from 'lucide-svelte';
export let currentVersion: string;
export let newVersionInfo: { version: string; description: string; url: string } | null = null;
</script>
<footer class="bg-muted py-6 md:py-0">
<div class="container mx-auto flex flex-col items-center justify-center gap-4 py-8 md:flex-row">
<div
class="container mx-auto flex flex-col items-center justify-center gap-4 md:h-24 md:flex-row"
>
<div class="flex flex-col items-center gap-2">
{#if newVersionInfo}
<Alert.Root>
<Alert.Title class="flex items-center gap-2">
<Info class="h-4 w-4" />
{$t('app.components.footer.new_version_available')}
<a
href={newVersionInfo.url}
target="_blank"
class=" text-muted-foreground underline"
>
{newVersionInfo.description}
</a>
</Alert.Title>
</Alert.Root>
{/if}
<p class="text-balance text-center text-xs font-medium leading-loose">
© {new Date().getFullYear()}
<a href="https://openarchiver.com/" target="_blank">Open Archiver</a>. {$t(
'app.components.footer.all_rights_reserved'
)}
</p>
<p class="text-balance text-center text-xs font-medium leading-loose">
Version: {currentVersion}
</p>
</div>
</div>
</footer>

View File

@@ -49,7 +49,6 @@
providerConfig: source?.credentials ?? {
type: source?.provider ?? 'generic_imap',
secure: true,
allowInsecureCert: false,
},
});
@@ -223,12 +222,6 @@
>
<Checkbox id="secure" bind:checked={formData.providerConfig.secure} />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="secure" class="text-left"
>{$t('app.components.ingestion_source_form.allow_insecure_cert')}</Label
>
<Checkbox id="secure" bind:checked={formData.providerConfig.allowInsecureCert} />
</div>
{:else if formData.provider === 'pst_import'}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="pst-file" class="text-left"

View File

@@ -163,8 +163,7 @@
"not_available": "Raw .eml file not available for this email."
},
"footer": {
"all_rights_reserved": "All rights reserved.",
"new_version_available": "New version available"
"all_rights_reserved": "All rights reserved."
},
"ingestion_source_form": {
"provider_generic_imap": "Generic IMAP",
@@ -184,7 +183,6 @@
"port": "Port",
"username": "Username",
"use_tls": "Use TLS",
"allow_insecure_cert": "Allow insecure cert",
"pst_file": "PST File",
"eml_file": "EML File",
"heads_up": "Heads up!",
@@ -223,36 +221,8 @@
"system": "System",
"users": "Users",
"roles": "Roles",
"api_keys": "API Keys",
"logout": "Logout"
},
"api_keys_page": {
"title": "API Keys",
"header": "API Keys",
"generate_new_key": "Generate New Key",
"name": "Name",
"key": "Key",
"expires_at": "Expires At",
"created_at": "Created At",
"actions": "Actions",
"delete": "Delete",
"no_keys_found": "No API keys found.",
"generate_modal_title": "Generate New API Key",
"generate_modal_description": "Please provide a name and expiration for your new API key.",
"expires_in": "Expires In",
"select_expiration": "Select an expiration",
"30_days": "30 Days",
"60_days": "60 Days",
"6_months": "6 Months",
"12_months": "12 Months",
"24_months": "24 Months",
"generate": "Generate",
"new_api_key": "New API Key",
"failed_to_delete": "Failed to delete API key",
"api_key_deleted": "API key deleted",
"generated_title": "API Key Generated",
"generated_message": "Your API key is generated, please copy and save it in a secure place. This key will only be shown once."
},
"archived_emails_page": {
"title": "Archived emails",
"header": "Archived Emails",

View File

@@ -1,17 +1,17 @@
{
"app": {
"auth": {
"login": "Accedi",
"login": "Accesso",
"login_tip": "Inserisci la tua email qui sotto per accedere al tuo account.",
"email": "Email",
"password": "Password"
},
"common": {
"working": "In corso"
"working": "In lavorazione"
},
"archive": {
"title": "Archivio",
"no_subject": "Nessun Oggetto",
"no_subject": "Nessun oggetto",
"from": "Da",
"sent": "Inviato",
"recipients": "Destinatari",
@@ -20,27 +20,27 @@
"folder": "Cartella",
"tags": "Tag",
"size": "Dimensione",
"email_preview": "Anteprima Email",
"email_preview": "Anteprima email",
"attachments": "Allegati",
"download": "Scarica",
"actions": "Azioni",
"download_eml": "Scarica Email (.eml)",
"delete_email": "Elimina Email",
"email_thread": "Thread Email",
"download_eml": "Scarica email (.eml)",
"delete_email": "Elimina email",
"email_thread": "Thread email",
"delete_confirmation_title": "Sei sicuro di voler eliminare questa email?",
"delete_confirmation_description": "Questa azione non può essere annullata e rimuoverà permanentemente l'email e i suoi allegati.",
"delete_confirmation_description": "Questa azione non può essere annullata ed eliminerà permanentemente l'email e i suoi allegati.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla",
"not_found": "Email non trovata."
},
"ingestions": {
"title": "Sorgenti di Ingestione",
"ingestion_sources": "Sorgenti di Ingestione",
"bulk_actions": "Azioni di Massa",
"force_sync": "Forza Sincronizzazione",
"title": "Fonti di ingestione",
"ingestion_sources": "Fonti di ingestione",
"bulk_actions": "Azioni di massa",
"force_sync": "Forza sincronizzazione",
"delete": "Elimina",
"create_new": "Crea Nuovo",
"create_new": "Crea nuovo",
"name": "Nome",
"provider": "Provider",
"status": "Stato",
@@ -52,28 +52,28 @@
"open_menu": "Apri menu",
"edit": "Modifica",
"create": "Crea",
"ingestion_source": "Sorgente di Ingestione",
"edit_description": "Apporta modifiche alla tua sorgente di ingestione qui.",
"create_description": "Aggiungi una nuova sorgente di ingestione per iniziare ad archiviare le email.",
"ingestion_source": "Fonte di ingestione",
"edit_description": "Apporta modifiche alla tua fonte di ingestione qui.",
"create_description": "Aggiungi una nuova fonte di ingestione per iniziare ad archiviare le email.",
"read": "Leggi",
"docs_here": "documenti qui",
"delete_confirmation_title": "Sei sicuro di voler eliminare questa ingestione?",
"delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a questa ingestione. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi mettere in pausa l'ingestione.",
"delete_confirmation_description": "Questo eliminerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a questa ingestione. Se desideri solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa l'ingestione.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla",
"bulk_delete_confirmation_title": "Sei sicuro di voler eliminare {{count}} ingestioni selezionate?",
"bulk_delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a queste ingestioni. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi mettere in pausa le ingestioni."
"bulk_delete_confirmation_description": "Questo eliminerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a queste ingestioni. Se desideri solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa le ingestioni."
},
"search": {
"title": "Ricerca",
"description": "Ricerca email archiviate.",
"email_search": "Ricerca Email",
"title": "Cerca",
"description": "Cerca email archiviate.",
"email_search": "Ricerca email",
"placeholder": "Cerca per parola chiave, mittente, destinatario...",
"search_button": "Cerca",
"search_options": "Opzioni di ricerca",
"strategy_fuzzy": "Approssimativa",
"strategy_verbatim": "Esatta",
"strategy_fuzzy": "Fuzzy",
"strategy_verbatim": "Verbatim",
"strategy_frequency": "Frequenza",
"select_strategy": "Seleziona una strategia",
"error": "Errore",
@@ -87,18 +87,18 @@
"next": "Succ"
},
"roles": {
"title": "Gestione Ruoli",
"role_management": "Gestione Ruoli",
"create_new": "Crea Nuovo",
"title": "Gestione ruoli",
"role_management": "Gestione ruoli",
"create_new": "Crea nuovo",
"name": "Nome",
"created_at": "Creato il",
"actions": "Azioni",
"open_menu": "Apri menu",
"view_policy": "Visualizza Policy",
"view_policy": "Visualizza policy",
"edit": "Modifica",
"delete": "Elimina",
"no_roles_found": "Nessun ruolo trovato.",
"role_policy": "Policy Ruolo",
"role_policy": "Policy ruolo",
"viewing_policy_for_role": "Visualizzazione policy per il ruolo: {{name}}",
"create": "Crea",
"role": "Ruolo",
@@ -111,22 +111,22 @@
"cancel": "Annulla"
},
"system_settings": {
"title": "Impostazioni di Sistema",
"system_settings": "Impostazioni di Sistema",
"title": "Impostazioni di sistema",
"system_settings": "Impostazioni di sistema",
"description": "Gestisci le impostazioni globali dell'applicazione.",
"language": "Lingua",
"default_theme": "Tema predefinito",
"light": "Chiaro",
"dark": "Scuro",
"system": "Sistema",
"support_email": "Email di Supporto",
"saving": "Salvataggio in corso",
"save_changes": "Salva Modifiche"
"support_email": "Email di supporto",
"saving": "Salvataggio",
"save_changes": "Salva modifiche"
},
"users": {
"title": "Gestione Utenti",
"user_management": "Gestione Utenti",
"create_new": "Crea Nuovo",
"title": "Gestione utenti",
"user_management": "Gestione utenti",
"create_new": "Crea nuovo",
"name": "Nome",
"email": "Email",
"role": "Ruolo",
@@ -146,10 +146,33 @@
"confirm": "Conferma",
"cancel": "Annulla"
},
"setup": {
"title": "Configurazione",
"description": "Configura l'account amministratore iniziale per Open Archiver.",
"welcome": "Benvenuto",
"create_admin_account": "Crea il primo account amministratore per iniziare.",
"first_name": "Nome",
"last_name": "Cognome",
"email": "Email",
"password": "Password",
"creating_account": "Creazione account",
"create_account": "Crea account"
},
"layout": {
"dashboard": "Dashboard",
"ingestions": "Ingestioni",
"archived_emails": "Email archiviate",
"search": "Cerca",
"settings": "Impostazioni",
"system": "Sistema",
"users": "Utenti",
"roles": "Ruoli",
"logout": "Esci"
},
"components": {
"charts": {
"emails_ingested": "Email Acquisite",
"storage_used": "Spazio di Archiviazione Utilizzato",
"emails_ingested": "Email ingerite",
"storage_used": "Spazio di archiviazione utilizzato",
"emails": "Email"
},
"common": {
@@ -159,36 +182,35 @@
},
"email_preview": {
"loading": "Caricamento anteprima email...",
"render_error": "Impossibile renderizzare l'anteprima dell'email.",
"not_available": "File .eml grezzo non disponibile per questa email."
"render_error": "Impossibile visualizzare l'anteprima dell'email.",
"not_available": "File .eml non disponibile per questa email."
},
"footer": {
"all_rights_reserved": "Tutti i diritti riservati."
},
"ingestion_source_form": {
"provider_generic_imap": "IMAP Generico",
"provider_generic_imap": "IMAP generico",
"provider_google_workspace": "Google Workspace",
"provider_microsoft_365": "Microsoft 365",
"provider_pst_import": "Importazione PST",
"provider_eml_import": "Importazione EML",
"select_provider": "Seleziona un provider",
"service_account_key": "Chiave Account di Servizio (JSON)",
"service_account_key_placeholder": "Incolla il contenuto JSON della chiave del tuo account di servizio",
"impersonated_admin_email": "Email dell'Amministratore Impersonato",
"client_id": "ID Applicazione (Client)",
"client_secret": "Valore Segreto Client",
"client_secret_placeholder": "Inserisci il Valore segreto, non l'ID Segreto",
"tenant_id": "ID Directory (Tenant)",
"service_account_key": "Chiave account di servizio (JSON)",
"service_account_key_placeholder": "Incolla il contenuto JSON della tua chiave account di servizio",
"impersonated_admin_email": "Email amministratore impersonata",
"client_id": "ID applicazione (client)",
"client_secret": "Valore segreto client",
"client_secret_placeholder": "Inserisci il valore segreto, non l'ID segreto",
"tenant_id": "ID directory (tenant)",
"host": "Host",
"port": "Porta",
"username": "Nome Utente",
"username": "Nome utente",
"use_tls": "Usa TLS",
"allow_insecure_cert": "Consenti certificato non sicuro",
"pst_file": "File PST",
"eml_file": "File EML",
"heads_up": "Attenzione!",
"org_wide_warning": "Si prega di notare che questa è un'operazione a livello di organizzazione. Questo tipo di ingestione importerà e indicizzerà <b>tutte</b> le caselle di posta elettronica nella tua organizzazione. Se vuoi importare solo caselle di posta elettronica specifiche, usa il connettore IMAP.",
"upload_failed": "Caricamento Fallito, riprova"
"org_wide_warning": "Si prega di notare che questa è un'operazione a livello di organizzazione. Questo tipo di ingestione importerà e indicizzerà <b>tutte</b> le caselle di posta elettronica della tua organizzazione. Se desideri importare solo caselle di posta elettronica specifiche, utilizza il connettore IMAP.",
"upload_failed": "Caricamento non riuscito, riprova"
},
"role_form": {
"policies_json": "Policy (JSON)",
@@ -201,61 +223,28 @@
"select_role": "Seleziona un ruolo"
}
},
"setup": {
"title": "Configurazione",
"description": "Configura l'account amministratore iniziale per Open Archiver.",
"welcome": "Benvenuto",
"create_admin_account": "Crea il primo account amministratore per iniziare.",
"first_name": "Nome",
"last_name": "Cognome",
"email": "Email",
"password": "Password",
"creating_account": "Creazione Account",
"create_account": "Crea Account"
},
"layout": {
"dashboard": "Dashboard",
"ingestions": "Ingestioni",
"archived_emails": "Email archiviate",
"search": "Ricerca",
"settings": "Impostazioni",
"system": "Sistema",
"users": "Utenti",
"roles": "Ruoli",
"api_keys": "Chiavi API",
"logout": "Esci"
},
"api_keys_page": {
"title": "Chiavi API",
"header": "Chiavi API",
"generate_new_key": "Genera Nuova Chiave",
"name": "Nome",
"key": "Chiave",
"expires_at": "Scade il",
"created_at": "Creato il",
"actions": "Azioni",
"delete": "Elimina",
"no_keys_found": "Nessuna chiave API trovata.",
"generate_modal_title": "Genera Nuova Chiave API",
"generate_modal_description": "Fornisci un nome e una scadenza per la tua nuova chiave API.",
"expires_in": "Scade Tra",
"select_expiration": "Seleziona una scadenza",
"30_days": "30 Giorni",
"60_days": "60 Giorni",
"6_months": "6 Mesi",
"12_months": "12 Mesi",
"24_months": "24 Mesi",
"generate": "Genera",
"new_api_key": "Nuova Chiave API",
"failed_to_delete": "Impossibile eliminare la chiave API",
"api_key_deleted": "Chiave API eliminata",
"generated_title": "Chiave API Generata",
"generated_message": "La tua chiave API è stata generata, per favore copiala e salvala in un luogo sicuro. Questa chiave verrà mostrata solo una volta."
"dashboard_page": {
"title": "Dashboard",
"meta_description": "Panoramica del tuo archivio email.",
"header": "Dashboard",
"create_ingestion": "Crea un'ingestione",
"no_ingestion_header": "Non hai alcuna fonte di ingestione configurata.",
"no_ingestion_text": "Aggiungi una fonte di ingestione per iniziare ad archiviare le tue caselle di posta.",
"total_emails_archived": "Email totali archiviate",
"total_storage_used": "Spazio di archiviazione totale utilizzato",
"failed_ingestions": "Ingestioni non riuscite (ultimi 7 giorni)",
"ingestion_history": "Cronologia ingestioni",
"no_ingestion_history": "Nessuna cronologia di ingestione disponibile.",
"storage_by_source": "Archiviazione per fonte di ingestione",
"no_ingestion_sources": "Nessuna fonte di ingestione disponibile.",
"indexed_insights": "Approfondimenti indicizzati",
"top_10_senders": "Top 10 mittenti",
"no_indexed_insights": "Nessun approfondimento indicizzato disponibile."
},
"archived_emails_page": {
"title": "Email archiviate",
"header": "Email Archiviate",
"select_ingestion_source": "Seleziona una sorgente di ingestione",
"header": "Email archiviate",
"select_ingestion_source": "Seleziona una fonte di ingestione",
"date": "Data",
"subject": "Oggetto",
"sender": "Mittente",
@@ -266,24 +255,6 @@
"no_emails_found": "Nessuna email archiviata trovata.",
"prev": "Prec",
"next": "Succ"
},
"dashboard_page": {
"title": "Dashboard",
"meta_description": "Panoramica del tuo archivio email.",
"header": "Dashboard",
"create_ingestion": "Crea un'ingestione",
"no_ingestion_header": "Non hai impostato nessuna sorgente di ingestione.",
"no_ingestion_text": "Aggiungi una sorgente di ingestione per iniziare ad archiviare le tue caselle di posta.",
"total_emails_archived": "Totale Email Archiviate",
"total_storage_used": "Spazio di Archiviazione Totale Utilizzato",
"failed_ingestions": "Ingestioni Fallite (Ultimi 7 Giorni)",
"ingestion_history": "Cronologia Ingestioni",
"no_ingestion_history": "Nessuna cronologia delle ingestioni disponibile.",
"storage_by_source": "Spazio di Archiviazione per Sorgente di Ingestione",
"no_ingestion_sources": "Nessuna sorgente di ingestione disponibile.",
"indexed_insights": "Approfondimenti indicizzati",
"top_10_senders": "I 10 Mittenti Principali",
"no_indexed_insights": "Nessun approfondimento indicizzato disponibile."
}
}
}

View File

@@ -3,17 +3,11 @@ import type { LayoutServerLoad } from './$types';
import 'dotenv/config';
import { api } from '$lib/server/api';
import type { SystemSettings } from '@open-archiver/types';
import { version } from '../../../../package.json';
import semver from 'semver';
let newVersionInfo: { version: string; description: string; url: string } | null = null;
let lastChecked: Date | null = null;
export const load: LayoutServerLoad = async (event) => {
const { locals, url } = event;
const response = await api('/auth/status', event);
if (response.ok) {
try {
const response = await api('/auth/status', event);
const { needsSetup } = await response.json();
if (needsSetup && url.pathname !== '/setup') {
@@ -23,49 +17,19 @@ export const load: LayoutServerLoad = async (event) => {
if (!needsSetup && url.pathname === '/setup') {
throw redirect(307, '/signin');
}
} else {
// if auth status check fails, we can't know if the setup is complete,
// so we redirect to signin page as a safe fallback.
if (url.pathname !== '/signin') {
console.error('Failed to get auth status:', await response.text());
throw redirect(307, '/signin');
}
} catch (error) {
throw error;
}
const systemSettingsResponse = await api('/settings/system', event);
const systemSettings: SystemSettings | null = systemSettingsResponse.ok
? await systemSettingsResponse.json()
const settingsResponse = await api('/settings', event);
const settings: SystemSettings | null = settingsResponse.ok
? await settingsResponse.json()
: null;
const now = new Date();
if (!lastChecked || now.getTime() - lastChecked.getTime() > 1000 * 60 * 60) {
try {
const res = await fetch(
'https://api.github.com/repos/LogicLabs-OU/OpenArchiver/releases/latest'
);
if (res.ok) {
const latestRelease = await res.json();
const latestVersion = latestRelease.tag_name.replace('v', '');
if (semver.gt(latestVersion, version)) {
newVersionInfo = {
version: latestVersion,
description: latestRelease.name,
url: latestRelease.html_url,
};
}
}
lastChecked = now;
} catch (error) {
console.error('Failed to fetch latest version from GitHub:', error);
}
}
return {
user: locals.user,
accessToken: locals.accessToken,
isDemo: process.env.IS_DEMO === 'true',
systemSettings,
currentVersion: version,
newVersionInfo: newVersionInfo,
settings,
};
};

View File

@@ -18,7 +18,7 @@
let finalTheme = $theme;
if (finalTheme === 'system') {
finalTheme = data.systemSettings?.theme || 'system';
finalTheme = data.settings?.theme || 'system';
}
const isDark =
@@ -35,5 +35,5 @@
<main class="flex-1">
{@render children()}
</main>
<Footer currentVersion={data.currentVersion} newVersionInfo={data.newVersionInfo} />
<Footer />
</div>

View File

@@ -8,8 +8,8 @@ export const load: LayoutLoad = async ({ url, data }) => {
let initLocale: SupportedLanguage = 'en'; // Default fallback
if (data.systemSettings?.language) {
initLocale = data.systemSettings.language;
if (data.settings?.language) {
initLocale = data.settings.language;
}
console.log(initLocale);

View File

@@ -33,10 +33,6 @@
href: '/dashboard/settings/roles',
label: $t('app.layout.roles'),
},
{
href: '/dashboard/settings/api-keys',
label: $t('app.layout.api_keys'),
},
],
},
];

View File

@@ -1,49 +0,0 @@
import { api } from '$lib/server/api';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const response = await api('/api-keys', event);
const apiKeys = await response.json();
return {
apiKeys,
};
};
export const actions: Actions = {
generate: async (event) => {
const data = await event.request.formData();
const name = data.get('name') as string;
const expiresInDays = Number(data.get('expiresInDays'));
const response = await api('/api-keys', event, {
method: 'POST',
body: JSON.stringify({ name, expiresInDays }),
});
const responseBody = await response.json();
if (!response.ok) {
return {
message: responseBody.message || '',
errors: responseBody.errors,
};
}
return {
newApiKey: responseBody.key,
};
},
delete: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
await api(`/api-keys/${id}`, event, {
method: 'DELETE',
});
return {
success: true,
};
},
};

View File

@@ -1,266 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import * as Table from '$lib/components/ui/table';
import type { ActionData, PageData } from './$types';
import { t } from '$lib/translations';
import { MoreHorizontal, Trash } from 'lucide-svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
import * as Card from '$lib/components/ui/card/index.js';
import { api } from '$lib/api.client';
// Temporary type definition based on the backend schema
type ApiKey = {
id: string;
name: string;
userId: string;
key: string;
expiresAt: Date;
createdAt: Date;
updatedAt: Date;
};
let { data, form }: { data: PageData; form: ActionData } = $props();
let apiKeys = $state<ApiKey[]>(data.apiKeys);
let isDeleteDialogOpen = $state(false);
let newAPIKeyDialogOpen = $state(false);
let keyToDelete = $state<ApiKey | null>(null);
let isDeleting = $state(false);
let selectedExpiration = $state('30');
const expirationOptions = [
{ value: '30', label: $t('app.api_keys_page.30_days') },
{ value: '60', label: $t('app.api_keys_page.60_days') },
{ value: '180', label: $t('app.api_keys_page.6_months') },
{ value: '365', label: $t('app.api_keys_page.12_months') },
{ value: '730', label: $t('app.api_keys_page.24_months') },
];
const triggerContent = $derived(
expirationOptions.find((p) => p.value === selectedExpiration)?.label ??
$t('app.api_keys_page.select_expiration')
);
const openDeleteDialog = (apiKey: ApiKey) => {
keyToDelete = apiKey;
isDeleteDialogOpen = true;
};
const confirmDelete = async () => {
if (!keyToDelete) return;
isDeleting = true;
try {
const res = await api(`/api-keys/${keyToDelete.id}`, { method: 'DELETE' });
if (!res.ok) {
const errorBody = await res.json();
setAlert({
type: 'error',
title: $t('app.api_keys_page.failed_to_delete'),
message: errorBody.message || JSON.stringify(errorBody),
duration: 5000,
show: true,
});
return;
}
apiKeys = apiKeys.filter((k) => k.id !== keyToDelete!.id);
isDeleteDialogOpen = false;
keyToDelete = null;
setAlert({
type: 'success',
title: $t('app.api_keys_page.api_key_deleted'),
message: $t('app.api_keys_page.api_key_deleted'),
duration: 3000,
show: true,
});
} finally {
isDeleting = false;
}
};
$effect(() => {
if (form?.newApiKey) {
setAlert({
type: 'success',
title: $t('app.api_keys_page.generated_title'),
message: $t('app.api_keys_page.generated_message'),
duration: 3000, // Keep it on screen longer for copying
show: true,
});
}
if (form?.errors) {
setAlert({
type: 'error',
title: form.message,
message: form.errors || '',
duration: 3000, // Keep it on screen longer for copying
show: true,
});
}
});
</script>
<svelte:head>
<title>{$t('app.api_keys_page.title')} - Open Archiver</title>
</svelte:head>
<div class="">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">{$t('app.api_keys_page.title')}</h1>
<Dialog.Root bind:open={newAPIKeyDialogOpen}>
<Dialog.Trigger>
<Button>{$t('app.api_keys_page.generate_new_key')}</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('app.api_keys_page.generate_modal_title')}</Dialog.Title>
<Dialog.Description>
{$t('app.api_keys_page.generate_modal_description')}
</Dialog.Description>
</Dialog.Header>
<form
method="POST"
action="?/generate"
onsubmit={() => {
newAPIKeyDialogOpen = false;
}}
>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="name" class="text-right"
>{$t('app.api_keys_page.name')}</Label
>
<Input id="name" name="name" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="expiresInDays" class="text-right"
>{$t('app.api_keys_page.expires_in')}</Label
>
<Select.Root
name="expiresInDays"
bind:value={selectedExpiration}
type="single"
>
<Select.Trigger class="col-span-3">
{triggerContent}
</Select.Trigger>
<Select.Content>
{#each expirationOptions as option}
<Select.Item value={option.value}
>{option.label}</Select.Item
>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<Dialog.Footer>
<Button type="submit">{$t('app.api_keys_page.generate')}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
</div>
{#if form?.newApiKey}
<Card.Root class="mb-4 border-0 bg-green-200 text-green-600 shadow-none">
<Card.Header>
<Card.Title>{$t('app.api_keys_page.generated_title')}</Card.Title>
<Card.Description class=" text-green-600"
>{$t('app.api_keys_page.generated_message')}</Card.Description
>
</Card.Header>
<Card.Content>
<p>{form?.newApiKey}</p>
</Card.Content>
</Card.Root>
{/if}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('app.api_keys_page.name')}</Table.Head>
<Table.Head>{$t('app.api_keys_page.key')}</Table.Head>
<Table.Head>{$t('app.api_keys_page.expires_at')}</Table.Head>
<Table.Head>{$t('app.api_keys_page.created_at')}</Table.Head>
<Table.Head class="text-right">{$t('app.users.actions')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if apiKeys.length > 0}
{#each apiKeys as apiKey (apiKey.id)}
<Table.Row>
<Table.Cell>{apiKey.name}</Table.Cell>
<Table.Cell>{apiKey.key.substring(0, 8)}</Table.Cell>
<Table.Cell
>{new Date(apiKey.expiresAt).toLocaleDateString()}</Table.Cell
>
<Table.Cell
>{new Date(apiKey.createdAt).toLocaleDateString()}</Table.Cell
>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only">{$t('app.users.open_menu')}</span>
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label
>{$t('app.users.actions')}</DropdownMenu.Label
>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="text-destructive cursor-pointer"
onclick={() => openDeleteDialog(apiKey)}
>
<Trash class="mr-2 h-4 w-4" />
{$t('app.users.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan={5} class="h-24 text-center"
>{$t('app.api_keys_page.no_keys_found')}</Table.Cell
>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<Dialog.Root bind:open={isDeleteDialogOpen}>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>{$t('app.users.delete_confirmation_title')}</Dialog.Title>
<Dialog.Description>
{$t('app.users.delete_confirmation_description')}
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class="sm:justify-start">
<Button
type="button"
variant="destructive"
onclick={confirmDelete}
disabled={isDeleting}
>
{#if isDeleting}
{$t('app.users.deleting')}...
{:else}
{$t('app.users.confirm')}
{/if}
</Button>
<Dialog.Close>
<Button type="button" variant="secondary">{$t('app.users.cancel')}</Button>
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -4,16 +4,16 @@ import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const response = await api('/settings/system', event);
const response = await api('/settings', event);
if (!response.ok) {
const { message } = await response.json();
throw error(response.status, message || 'Failed to fetch system settings');
}
const systemSettings: SystemSettings = await response.json();
const settings: SystemSettings = await response.json();
return {
systemSettings,
settings,
};
};
@@ -30,7 +30,7 @@ export const actions: Actions = {
supportEmail: supportEmail ? String(supportEmail) : null,
};
const response = await api('/settings/system', event, {
const response = await api('/settings', event, {
method: 'PUT',
body: JSON.stringify(body),
});

View File

@@ -11,7 +11,7 @@
import { t } from '$lib/translations';
let { data, form }: { data: PageData; form: any } = $props();
let settings = $state(data.systemSettings);
let settings = $state(data.settings);
let isSaving = $state(false);
const languageOptions: { value: SupportedLanguage; label: string }[] = [

View File

@@ -44,7 +44,6 @@ export interface GenericImapCredentials extends BaseIngestionCredentials {
host: string;
port: number;
secure: boolean;
allowInsecureCert: boolean;
username: string;
password?: string;
}

View File

@@ -35,11 +35,3 @@ export interface Role {
createdAt: Date;
updatedAt: Date;
}
export interface ApiKey {
id: string;
name: string;
key: string;
expiresAt: string;
createdAt: string;
}

478
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff