Compare commits

..

15 Commits

Author SHA1 Message Date
Wei S.
785cc86415 Create FUNDING.yml 2025-09-10 17:08:45 +03:00
Wei S.
6be0774bc4 Display versions: Add new version notification in footer (#101)
* feat: Add new version notification in footer

This commit implements a system to check for new application versions and notify the user.

On page load, the server-side code now fetches the latest release from the GitHub repository API. It uses `semver` to compare the current application version with the latest release tag.

If a newer version is available, an alert is displayed in the footer with a link to the release page. The current application version is also now displayed in the footer. The version check is cached for one hour to minimize API requests.

* Modify version notification

* current version 0.3.1

* Resolve conflicts

* Code formatting

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-09-10 12:09:12 +03:00
Wei S.
4a23f8f29f feat: Add new version notification in footer (#99)
This commit implements a system to check for new application versions and notify the user.

On page load, the server-side code now fetches the latest release from the GitHub repository API. It uses `semver` to compare the current application version with the latest release tag.

If a newer version is available, an alert is displayed in the footer with a link to the release page. The current application version is also now displayed in the footer. The version check is cached for one hour to minimize API requests.

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-09-09 23:36:35 +03:00
albanobattistella
074256ed59 Update it.json (#90) 2025-09-07 23:44:08 +03:00
Wei S.
7d178d786b Docs: code formatting (#92)
* Adding rate limiting docs

* update rate limiting docs

* Resolve conflict

* Code formatting

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-09-06 18:06:59 +03:00
Wei S.
4b11cd931a Docs: update rate limiting docs (#91)
* Adding rate limiting docs

* update rate limiting docs

* Resolve conflict

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-09-06 17:56:34 +03:00
scotscotmcc
0a21ad14cd Update README.md (#89)
fix folder in installation steps
2025-09-06 17:38:43 +03:00
Wei S.
63d3960f79 Adding rate limiting docs (#88)
Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-09-04 17:44:10 +03:00
Wei S.
85a526d1b6 User api key: JSON rate limiting message & status code (#87)
* feat(auth): Implement API key authentication

This commit enables API access with an API key system. This change provides a better experience for programmatic access and third-party integrations.

Key changes include:
- **API Key Management:** Users can now generate, manage, and revoke persistent API keys through a new "API Keys" section in the settings UI.
- **Authentication Middleware:** API requests are now authenticated via an `X-API-KEY` header instead of the previous `Authorization: Bearer` token.
- **Backend Implementation:** Adds a new `api_keys` database table, along with corresponding services, controllers, and routes to manage the key lifecycle securely.
- **Rate Limiting:** The API rate limiter now uses the API key to identify and track requests.
- **Documentation:** The API authentication documentation has been updated to reflect the new method.

* Add configurable API rate limiting

Two new variables are added to `.env.example`:
- `RATE_LIMIT_WINDOW_MS`: The time window in milliseconds for which requests are checked (defaults to 15 minutes).
- `RATE_LIMIT_MAX_REQUESTS`: The maximum number of requests allowed from an IP within the window (defaults to 100).

The installation documentation has been updated to reflect these new configuration options.

* Disable API operation in demo mode

* Exclude public API endpoints from rate limiting

* JSON rate limiting message & status code

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-09-04 17:32:43 +03:00
Wei S.
52a1a11973 User api key: Exclude public API endpoints from rate limiting (#86)
* feat(auth): Implement API key authentication

This commit enables API access with an API key system. This change provides a better experience for programmatic access and third-party integrations.

Key changes include:
- **API Key Management:** Users can now generate, manage, and revoke persistent API keys through a new "API Keys" section in the settings UI.
- **Authentication Middleware:** API requests are now authenticated via an `X-API-KEY` header instead of the previous `Authorization: Bearer` token.
- **Backend Implementation:** Adds a new `api_keys` database table, along with corresponding services, controllers, and routes to manage the key lifecycle securely.
- **Rate Limiting:** The API rate limiter now uses the API key to identify and track requests.
- **Documentation:** The API authentication documentation has been updated to reflect the new method.

* Add configurable API rate limiting

Two new variables are added to `.env.example`:
- `RATE_LIMIT_WINDOW_MS`: The time window in milliseconds for which requests are checked (defaults to 15 minutes).
- `RATE_LIMIT_MAX_REQUESTS`: The maximum number of requests allowed from an IP within the window (defaults to 100).

The installation documentation has been updated to reflect these new configuration options.

* Disable API operation in demo mode

* Exclude public API endpoints from rate limiting

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-09-04 17:27:57 +03:00
Wei S.
4048f47777 User api key: Disable API operation in demo mode (#85)
* feat(auth): Implement API key authentication

This commit enables API access with an API key system. This change provides a better experience for programmatic access and third-party integrations.

Key changes include:
- **API Key Management:** Users can now generate, manage, and revoke persistent API keys through a new "API Keys" section in the settings UI.
- **Authentication Middleware:** API requests are now authenticated via an `X-API-KEY` header instead of the previous `Authorization: Bearer` token.
- **Backend Implementation:** Adds a new `api_keys` database table, along with corresponding services, controllers, and routes to manage the key lifecycle securely.
- **Rate Limiting:** The API rate limiter now uses the API key to identify and track requests.
- **Documentation:** The API authentication documentation has been updated to reflect the new method.

* Add configurable API rate limiting

Two new variables are added to `.env.example`:
- `RATE_LIMIT_WINDOW_MS`: The time window in milliseconds for which requests are checked (defaults to 15 minutes).
- `RATE_LIMIT_MAX_REQUESTS`: The maximum number of requests allowed from an IP within the window (defaults to 100).

The installation documentation has been updated to reflect these new configuration options.

* Disable API operation in demo mode

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-09-04 16:56:45 +03:00
Wei S.
22b173cbe4 Feat: Implement API key authentication (#84)
* feat(auth): Implement API key authentication

This commit enables API access with an API key system. This change provides a better experience for programmatic access and third-party integrations.

Key changes include:
- **API Key Management:** Users can now generate, manage, and revoke persistent API keys through a new "API Keys" section in the settings UI.
- **Authentication Middleware:** API requests are now authenticated via an `X-API-KEY` header instead of the previous `Authorization: Bearer` token.
- **Backend Implementation:** Adds a new `api_keys` database table, along with corresponding services, controllers, and routes to manage the key lifecycle securely.
- **Rate Limiting:** The API rate limiter now uses the API key to identify and track requests.
- **Documentation:** The API authentication documentation has been updated to reflect the new method.

* Add configurable API rate limiting

Two new variables are added to `.env.example`:
- `RATE_LIMIT_WINDOW_MS`: The time window in milliseconds for which requests are checked (defaults to 15 minutes).
- `RATE_LIMIT_MAX_REQUESTS`: The maximum number of requests allowed from an IP within the window (defaults to 100).

The installation documentation has been updated to reflect these new configuration options.

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-09-04 15:07:53 +03:00
Wei S.
774b0d7a6b Bug fix: Status API response: needsSetup and Remove SUPER_API_KEY support (#83)
* Disable system settings for demo mode

* Status API response: needsSetup

* Remove SUPER_API_KEY support

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-09-03 16:30:06 +03:00
Wei S.
85607d2ab3 Disable system settings for demo mode (#78)
Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-09-01 13:29:45 +03:00
Wei S.
94021eab69 v0.3.0 release (#76)
* Remove extra ports in Docker Compose file

* Allow self-assigned cert

* Adding allow insecure cert option

* fix(IMAP): Share connections between each fetch email action

* Update docs: troubleshooting CORS error

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-09-01 12:44:22 +03:00
46 changed files with 3301 additions and 206 deletions

View File

@@ -54,17 +54,19 @@ 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 Normal file
View File

@@ -0,0 +1 @@
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 open-archiver
cd OpenArchiver
```
2. **Configure your environment:**

View File

@@ -71,6 +71,7 @@ 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,60 +1,25 @@
# API Authentication
To access protected API endpoints, you need to include a JSON Web Token (JWT) in the `Authorization` header of your requests.
To access protected API endpoints, you need to include a user-generated API key in the `X-API-KEY` header of your requests.
## Obtaining a JWT
## 1. Creating an API Key
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`.
You can create, manage, and view your API keys through the application's user interface.
**Request:**
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.**
```http
POST /api/v1/auth/login
Content-Type: application/json
## 2. Making Authenticated Requests
{
"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.
Once you have your API key, you must include it in the `X-API-KEY` header of all subsequent requests to protected API endpoints.
**Example:**
```http
GET /api/v1/dashboard/stats
Authorization: Bearer your.jwt.token
X-API-KEY: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
```
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
```
If the API key is missing, expired, or invalid, the API will respond with a `401 Unauthorized` status code.

51
docs/api/rate-limiting.md Normal file
View File

@@ -0,0 +1,51 @@
# 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,12 +105,14 @@ 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` | An API key with super admin privileges. | |
| `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`~~ (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. | |
## 3. Run the Application

View File

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

View File

@@ -60,7 +60,8 @@
"sqlite3": "^5.1.7",
"tsconfig-paths": "^4.2.0",
"xlsx": "^0.18.5",
"yauzl": "^3.2.0"
"yauzl": "^3.2.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@bull-board/api": "^6.11.0",

View File

@@ -0,0 +1,66 @@
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({ needsSetupUser });
return res.status(200).json({ needsSetup: needsSetupUser });
} catch (error) {
console.error('Status check error:', error);
return res.status(500).json({ message: req.t('errors.internalServerError') });

View File

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

View File

@@ -1,10 +1,16 @@
import rateLimit from 'express-rate-limit';
import { config } from '../../config';
// 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
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,
});

View File

@@ -2,6 +2,9 @@ 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 {
@@ -15,16 +18,30 @@ 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

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

View File

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

View File

@@ -0,0 +1,12 @@
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,10 +2,12 @@ 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

@@ -0,0 +1,11 @@
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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -127,6 +127,20 @@
"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,3 +6,4 @@ 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

@@ -0,0 +1,15 @@
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,6 +17,7 @@ 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';
@@ -28,6 +29,7 @@ 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();
@@ -43,7 +45,7 @@ if (!PORT_BACKEND || !JWT_SECRET || !JWT_EXPIRES_IN) {
// --- i18next Initialization ---
const initializeI18next = async () => {
const systemSettings = await settingsService.getSettings();
const systemSettings = await settingsService.getSystemSettings();
const defaultLanguage = systemSettings?.language || 'en';
logger.info({ language: defaultLanguage }, 'Default language');
await i18next.use(FsBackend).init({
@@ -86,10 +88,21 @@ 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 }));
@@ -105,6 +118,7 @@ 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,5 +58,12 @@
"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

@@ -0,0 +1,72 @@
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

@@ -15,11 +15,11 @@ export class SettingsService {
* If no settings exist, it initializes and returns the default settings.
* @returns The system settings.
*/
public async getSettings(): Promise<SystemSettings> {
public async getSystemSettings(): Promise<SystemSettings> {
const settings = await db.select().from(systemSettings).limit(1);
if (settings.length === 0) {
return this.createDefaultSettings();
return this.createDefaultSystemSettings();
}
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 updateSettings(newConfig: Partial<SystemSettings>): Promise<SystemSettings> {
const currentConfig = await this.getSettings();
public async updateSystemSettings(newConfig: Partial<SystemSettings>): Promise<SystemSettings> {
const currentConfig = await this.getSystemSettings();
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 createDefaultSettings(): Promise<SystemSettings> {
private async createDefaultSystemSettings(): Promise<SystemSettings> {
const [result] = await db
.insert(systemSettings)
.values({ config: DEFAULT_SETTINGS })

View File

@@ -157,7 +157,11 @@ export class ImapConnector implements IEmailConnector {
// filter out trash and all mail emails
if (mailbox.specialUse) {
const specialUse = mailbox.specialUse.toLowerCase();
if (specialUse === '\\junk' || specialUse === '\\trash' || specialUse === '\\all') {
if (
specialUse === '\\junk' ||
specialUse === '\\trash' ||
specialUse === '\\all'
) {
return false;
}
}

View File

@@ -22,6 +22,7 @@
"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",
@@ -35,6 +36,7 @@
"@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('components.email_preview.loading')}</p>
<p>{$t('app.components.email_preview.loading')}</p>
{:else if emailHtml}
<iframe
title={$t('archive.email_preview')}
title={$t('app.archive.email_preview')}
srcdoc={emailHtml()}
class="h-[600px] w-full border-none"
></iframe>
{:else if raw}
<p>{$t('components.email_preview.render_error')}</p>
<p>{$t('app.components.email_preview.render_error')}</p>
{:else}
<p class="text-gray-500">{$t('components.email_preview.not_available')}</p>
<p class="text-gray-500">{$t('app.components.email_preview.not_available')}</p>
{/if}
</div>

View File

@@ -1,18 +1,39 @@
<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 md:h-24 md:flex-row"
>
<div class="container mx-auto flex flex-col items-center justify-center gap-4 py-8 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

@@ -163,7 +163,8 @@
"not_available": "Raw .eml file not available for this email."
},
"footer": {
"all_rights_reserved": "All rights reserved."
"all_rights_reserved": "All rights reserved.",
"new_version_available": "New version available"
},
"ingestion_source_form": {
"provider_generic_imap": "Generic IMAP",
@@ -222,8 +223,36 @@
"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": "Accesso",
"login": "Accedi",
"login_tip": "Inserisci la tua email qui sotto per accedere al tuo account.",
"email": "Email",
"password": "Password"
},
"common": {
"working": "In lavorazione"
"working": "In corso"
},
"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 ed eliminerà permanentemente l'email e i suoi allegati.",
"delete_confirmation_description": "Questa azione non può essere annullata e rimuoverà permanentemente l'email e i suoi allegati.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla",
"not_found": "Email non trovata."
},
"ingestions": {
"title": "Fonti di ingestione",
"ingestion_sources": "Fonti di ingestione",
"bulk_actions": "Azioni di massa",
"force_sync": "Forza sincronizzazione",
"title": "Sorgenti di Ingestione",
"ingestion_sources": "Sorgenti 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": "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.",
"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.",
"read": "Leggi",
"docs_here": "documenti qui",
"delete_confirmation_title": "Sei sicuro di voler eliminare questa 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.",
"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.",
"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 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."
"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."
},
"search": {
"title": "Cerca",
"description": "Cerca email archiviate.",
"email_search": "Ricerca email",
"title": "Ricerca",
"description": "Ricerca email archiviate.",
"email_search": "Ricerca Email",
"placeholder": "Cerca per parola chiave, mittente, destinatario...",
"search_button": "Cerca",
"search_options": "Opzioni di ricerca",
"strategy_fuzzy": "Fuzzy",
"strategy_verbatim": "Verbatim",
"strategy_fuzzy": "Approssimativa",
"strategy_verbatim": "Esatta",
"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",
"save_changes": "Salva modifiche"
"support_email": "Email di Supporto",
"saving": "Salvataggio in corso",
"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,33 +146,10 @@
"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 ingerite",
"storage_used": "Spazio di archiviazione utilizzato",
"emails_ingested": "Email Acquisite",
"storage_used": "Spazio di Archiviazione Utilizzato",
"emails": "Email"
},
"common": {
@@ -182,35 +159,36 @@
},
"email_preview": {
"loading": "Caricamento anteprima email...",
"render_error": "Impossibile visualizzare l'anteprima dell'email.",
"not_available": "File .eml non disponibile per questa email."
"render_error": "Impossibile renderizzare l'anteprima dell'email.",
"not_available": "File .eml grezzo 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 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)",
"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)",
"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 della tua organizzazione. Se desideri importare solo caselle di posta elettronica specifiche, utilizza il connettore IMAP.",
"upload_failed": "Caricamento non riuscito, 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 nella tua organizzazione. Se vuoi importare solo caselle di posta elettronica specifiche, usa il connettore IMAP.",
"upload_failed": "Caricamento Fallito, riprova"
},
"role_form": {
"policies_json": "Policy (JSON)",
@@ -223,28 +201,61 @@
"select_role": "Seleziona un ruolo"
}
},
"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."
"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."
},
"archived_emails_page": {
"title": "Email archiviate",
"header": "Email archiviate",
"select_ingestion_source": "Seleziona una fonte di ingestione",
"header": "Email Archiviate",
"select_ingestion_source": "Seleziona una sorgente di ingestione",
"date": "Data",
"subject": "Oggetto",
"sender": "Mittente",
@@ -255,6 +266,24 @@
"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,11 +3,17 @@ 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;
try {
const response = await api('/auth/status', event);
const response = await api('/auth/status', event);
if (response.ok) {
const { needsSetup } = await response.json();
if (needsSetup && url.pathname !== '/setup') {
@@ -17,19 +23,49 @@ export const load: LayoutServerLoad = async (event) => {
if (!needsSetup && url.pathname === '/setup') {
throw redirect(307, '/signin');
}
} catch (error) {
throw error;
} 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');
}
}
const settingsResponse = await api('/settings', event);
const settings: SystemSettings | null = settingsResponse.ok
? await settingsResponse.json()
const systemSettingsResponse = await api('/settings/system', event);
const systemSettings: SystemSettings | null = systemSettingsResponse.ok
? await systemSettingsResponse.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',
settings,
systemSettings,
currentVersion: version,
newVersionInfo: newVersionInfo,
};
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
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

@@ -0,0 +1,266 @@
<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', event);
const response = await api('/settings/system', event);
if (!response.ok) {
const { message } = await response.json();
throw error(response.status, message || 'Failed to fetch system settings');
}
const settings: SystemSettings = await response.json();
const systemSettings: SystemSettings = await response.json();
return {
settings,
systemSettings,
};
};
@@ -30,7 +30,7 @@ export const actions: Actions = {
supportEmail: supportEmail ? String(supportEmail) : null,
};
const response = await api('/settings', event, {
const response = await api('/settings/system', 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.settings);
let settings = $state(data.systemSettings);
let isSaving = $state(false);
const languageOptions: { value: SupportedLanguage; label: string }[] = [

View File

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

19
pnpm-lock.yaml generated
View File

@@ -156,6 +156,9 @@ importers:
yauzl:
specifier: ^3.2.0
version: 3.2.0
zod:
specifier: ^4.1.5
version: 4.1.5
devDependencies:
'@bull-board/api':
specifier: ^6.11.0
@@ -226,6 +229,9 @@ importers:
postal-mime:
specifier: ^2.4.4
version: 2.4.4
semver:
specifier: ^7.7.2
version: 7.7.2
svelte-persisted-store:
specifier: ^0.12.0
version: 0.12.0(svelte@5.35.5)
@@ -260,6 +266,9 @@ importers:
'@types/d3-shape':
specifier: ^3.1.7
version: 3.1.7
'@types/semver':
specifier: ^7.7.1
version: 7.7.1
dotenv:
specifier: ^17.2.0
version: 17.2.0
@@ -1799,6 +1808,9 @@ packages:
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@types/send@0.17.5':
resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==}
@@ -4801,6 +4813,9 @@ packages:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
zod@4.1.5:
resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -6551,6 +6566,8 @@ snapshots:
'@types/resolve@1.20.2': {}
'@types/semver@7.7.1': {}
'@types/send@0.17.5':
dependencies:
'@types/mime': 1.3.5
@@ -9765,4 +9782,6 @@ snapshots:
compress-commons: 6.0.2
readable-stream: 4.7.0
zod@4.1.5: {}
zwitch@2.0.4: {}