mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Compare commits
13 Commits
v0.3.0
...
attachment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1a3886431 | ||
|
|
f84bc0cbb0 | ||
|
|
7d178d786b | ||
|
|
4b11cd931a | ||
|
|
0a21ad14cd | ||
|
|
63d3960f79 | ||
|
|
85a526d1b6 | ||
|
|
52a1a11973 | ||
|
|
4048f47777 | ||
|
|
22b173cbe4 | ||
|
|
774b0d7a6b | ||
|
|
85607d2ab3 | ||
|
|
94021eab69 |
23
.env.example
23
.env.example
@@ -52,19 +52,34 @@ STORAGE_S3_REGION=
|
||||
# Set to 'true' for MinIO and other non-AWS S3 services
|
||||
STORAGE_S3_FORCE_PATH_STYLE=false
|
||||
|
||||
# --- OCR Settings ---
|
||||
# Enable or disable Optical Character Recognition for attachments.
|
||||
# Default: false
|
||||
OCR_ENABLED=true
|
||||
# Comma-separated list of languages for OCR processing (e.g., eng,fra,deu,spa).
|
||||
# These must correspond to the .traineddata files mounted in the TESSERACT_PATH directory.
|
||||
# Default: "eng"
|
||||
OCR_LANGUAGES="eng"
|
||||
# The internal container path where Tesseract language data files (.traineddata) are located.
|
||||
# This path is the target for the volume mount specified in docker-compose.yml.
|
||||
# Default: "/opt/open-archiver/tessdata"
|
||||
TESSERACT_PATH="/opt/open-archiver/tessdata"
|
||||
|
||||
# --- 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=
|
||||
|
||||
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -6,12 +6,14 @@ services:
|
||||
container_name: open-archiver
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '4000:4000' # Backend
|
||||
- '3000:3000' # Frontend
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- archiver-data:/var/data/open-archiver
|
||||
# (Optional) Mount a host directory containing Tesseract language files for OCR.
|
||||
# If you do not need OCR, you can safely comment out or remove the line below.
|
||||
- ${TESSERACT_PATH:-./tessdata}:/opt/open-archiver/tessdata:ro
|
||||
depends_on:
|
||||
- postgres
|
||||
- valkey
|
||||
@@ -29,8 +31,6 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- '5432:5432'
|
||||
networks:
|
||||
- open-archiver-net
|
||||
|
||||
@@ -39,8 +39,6 @@ services:
|
||||
container_name: valkey
|
||||
restart: unless-stopped
|
||||
command: valkey-server --requirepass ${REDIS_PASSWORD}
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
- valkeydata:/data
|
||||
networks:
|
||||
@@ -52,8 +50,6 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-aSampleMasterKey}
|
||||
ports:
|
||||
- '7700:7700'
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
networks:
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
51
docs/api/rate-limiting.md
Normal 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.
|
||||
58
docs/services/indexing-service/ocr.md
Normal file
58
docs/services/indexing-service/ocr.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Attachment OCR
|
||||
|
||||
Open Archiver includes a powerful Optical Character Recognition (OCR) feature that allows it to extract text from images and scanned PDF documents during indexing. This makes the content of image-based attachments fully searchable.
|
||||
|
||||
## Overview
|
||||
|
||||
When enabled, the OCR service automatically processes common image formats and acts as a fallback for PDF files that do not contain selectable text. This is particularly useful for scanned documents, faxes, or photos of text.
|
||||
|
||||
## Enabling OCR
|
||||
|
||||
To enable the OCR feature, you must set the following environment variable in your `.env` file:
|
||||
|
||||
```ini
|
||||
OCR_ENABLED=true
|
||||
```
|
||||
|
||||
By default, this feature is disabled. If you do not need OCR, you can set this to `false` or omit the variable.
|
||||
|
||||
## Step-by-Step Language Configuration
|
||||
|
||||
The OCR service requires language data files to recognize text. You can add support for one or more languages by following these steps:
|
||||
|
||||
1. **Download Language Files**: Visit the official Tesseract `tessdata_fast` repository to find the available language files: [https://github.com/tesseract-ocr/tessdata_fast](https://github.com/tesseract-ocr/tessdata_fast). Download the `.traineddata` file for each language you need (e.g., `fra.traineddata` for French, `deu.traineddata` for German).
|
||||
|
||||
2. **Create a Directory on Host**: On your **host machine** (the machine running Docker), create a directory at any location to store your language files. For example, `/opt/openarchiver/tessdata`.
|
||||
|
||||
3. **Add Language Files**: Place the downloaded `.traineddata` files into the directory you just created.
|
||||
|
||||
4. **Configure Paths and Languages in `.env`**: Update your `.env` file with the following variables:
|
||||
- `TESSERACT_PATH`: Set this to the **full, absolute path** of the directory you created in Step 2.
|
||||
- `OCR_LANGUAGES`: Set this to a comma-separated list of the language codes you downloaded.
|
||||
|
||||
```ini
|
||||
# Example configuration in .env file
|
||||
TESSERACT_PATH="/opt/openarchiver/tessdata"
|
||||
OCR_LANGUAGES="eng,fra,deu"
|
||||
```
|
||||
|
||||
## Docker Compose Configuration
|
||||
|
||||
The system uses a Docker volume to make the language files on your host machine available to the application inside the container. The `docker-compose.yml` file is already configured to use the `TESSERACT_PATH` variable from your `.env` file.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
open-archiver:
|
||||
# ... other settings
|
||||
volumes:
|
||||
- archiver-data:/var/data/open-archiver
|
||||
# (Optional) Mount a host directory containing Tesseract language files for OCR.
|
||||
# If you do not need OCR, you can safely comment out or remove the line below.
|
||||
- ${TESSERACT_PATH:-./tessdata}:/opt/open-archiver/tessdata:ro
|
||||
```
|
||||
|
||||
This line connects the host path specified in `TESSERACT_PATH` (defaulting to `./tessdata` if not set) to the fixed `/opt/open-archiver/tessdata` path inside the container. If you have disabled OCR, you can comment out or remove the volume mount line.
|
||||
|
||||
## Performance Note
|
||||
|
||||
OCR is a CPU-intensive process. To ensure the main application remains responsive, all OCR operations are handled by background workers. The number of concurrent OCR processes is automatically scaled based on the number of available CPU cores on your system.
|
||||
@@ -42,6 +42,10 @@ You must change the following placeholder values to secure your instance:
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
### Attachment OCR Configuration
|
||||
|
||||
Open Archiver can extract text from images and scanned documents using Optical Character Recognition (OCR). For detailed instructions on how to enable and configure this feature, please see the [Attachment OCR Guide](../services/indexing-service/ocr.md).
|
||||
|
||||
### Storage Configuration
|
||||
|
||||
By default, the Docker Compose setup uses local filesystem storage, which is persisted using a Docker volume named `archiver-data`. This is suitable for most use cases.
|
||||
@@ -103,14 +107,24 @@ These variables are used by `docker-compose.yml` to configure the services.
|
||||
| `STORAGE_S3_REGION` | The region for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
|
||||
| `STORAGE_S3_FORCE_PATH_STYLE` | Force path-style addressing for S3 (optional). | `false` |
|
||||
|
||||
#### OCR Settings
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `OCR_ENABLED` | Enable or disable Optical Character Recognition for attachments. | `false` |
|
||||
| `OCR_LANGUAGES` | A comma-separated list of languages for OCR processing (e.g., `eng,fra,deu`). | `eng` |
|
||||
| `TESSERACT_PATH` | The path on the host machine where Tesseract language data files (`.traineddata`) are stored. | `./tessdata` |
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -297,3 +311,31 @@ 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.
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"mammoth": "^1.9.1",
|
||||
"meilisearch": "^0.51.0",
|
||||
"multer": "^2.0.2",
|
||||
"pdf-to-png-converter": "^3.7.1",
|
||||
"pdf2json": "^3.1.6",
|
||||
"pg": "^8.16.3",
|
||||
"pino": "^9.7.0",
|
||||
@@ -58,9 +59,11 @@
|
||||
"pst-extractor": "^1.11.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tesseract.js": "^6.0.1",
|
||||
"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",
|
||||
|
||||
66
packages/backend/src/api/controllers/api-key.controller.ts
Normal file
66
packages/backend/src/api/controllers/api-key.controller.ts
Normal 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') });
|
||||
}
|
||||
}
|
||||
@@ -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') });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
15
packages/backend/src/api/routes/api-key.routes.ts
Normal file
15
packages/backend/src/api/routes/api-key.routes.ts
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
12
packages/backend/src/config/api.ts
Normal file
12
packages/backend/src/config/api.ts
Normal 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
|
||||
},
|
||||
};
|
||||
@@ -6,4 +6,6 @@ export const app = {
|
||||
encryptionKey: process.env.ENCRYPTION_KEY,
|
||||
isDemo: process.env.IS_DEMO === 'true',
|
||||
syncFrequency: process.env.SYNC_FREQUENCY || '* * * * *', //default to 1 minute
|
||||
ocrEnabled: process.env.OCR_ENABLED === 'true',
|
||||
ocrLanguages: process.env.OCR_LANGUAGES || 'eng',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "api_keys" ADD COLUMN "key_hash" text NOT NULL;
|
||||
1171
packages/backend/src/database/migrations/meta/0018_snapshot.json
Normal file
1171
packages/backend/src/database/migrations/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1177
packages/backend/src/database/migrations/meta/0019_snapshot.json
Normal file
1177
packages/backend/src/database/migrations/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
15
packages/backend/src/database/schema/api-keys.ts
Normal file
15
packages/backend/src/database/schema/api-keys.ts
Normal 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(),
|
||||
});
|
||||
@@ -1,38 +1,88 @@
|
||||
import PDFParser from 'pdf2json';
|
||||
import mammoth from 'mammoth';
|
||||
import xlsx from 'xlsx';
|
||||
import { ocrService } from '../services/OcrService';
|
||||
import { logger } from '../config/logger';
|
||||
import { config } from '../config';
|
||||
import { pdfToPng } from 'pdf-to-png-converter';
|
||||
|
||||
function extractTextFromPdf(buffer: Buffer): Promise<string> {
|
||||
interface PdfExtractResult {
|
||||
text: string;
|
||||
hasText: boolean;
|
||||
}
|
||||
|
||||
function extractTextFromPdf(buffer: Buffer): Promise<PdfExtractResult> {
|
||||
return new Promise((resolve) => {
|
||||
const pdfParser = new PDFParser(null, true);
|
||||
let completed = false;
|
||||
|
||||
const finish = (text: string) => {
|
||||
const finish = (result: PdfExtractResult) => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
pdfParser.removeAllListeners();
|
||||
resolve(text);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
pdfParser.on('pdfParser_dataError', () => finish(''));
|
||||
pdfParser.on('pdfParser_dataReady', () => finish(pdfParser.getRawTextContent()));
|
||||
pdfParser.on('pdfParser_dataError', (err) => {
|
||||
logger.error({ err }, 'Error parsing PDF for text extraction');
|
||||
finish({ text: '', hasText: false });
|
||||
});
|
||||
|
||||
pdfParser.on('pdfParser_dataReady', (pdfData) => {
|
||||
let hasText = false;
|
||||
if (pdfData?.Pages) {
|
||||
for (const page of pdfData.Pages) {
|
||||
if (page.Texts && page.Texts.length > 0) {
|
||||
hasText = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const text = pdfParser.getRawTextContent();
|
||||
finish({ text, hasText });
|
||||
});
|
||||
|
||||
try {
|
||||
pdfParser.parseBuffer(buffer);
|
||||
} catch (err) {
|
||||
console.error('Error parsing PDF buffer', err);
|
||||
finish('');
|
||||
logger.error({ err }, 'Error parsing PDF buffer');
|
||||
finish({ text: '', hasText: false });
|
||||
}
|
||||
|
||||
// Prevent hanging if the parser never emits events
|
||||
setTimeout(() => finish(''), 10000);
|
||||
setTimeout(() => finish({ text: '', hasText: false }), 10000);
|
||||
});
|
||||
}
|
||||
|
||||
const OCR_SUPPORTED_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/tiff',
|
||||
'image/bmp',
|
||||
'image/webp',
|
||||
'image/x-portable-bitmap',
|
||||
];
|
||||
|
||||
export async function extractText(buffer: Buffer, mimeType: string): Promise<string> {
|
||||
try {
|
||||
if (mimeType === 'application/pdf') {
|
||||
return await extractTextFromPdf(buffer);
|
||||
const pdfResult = await extractTextFromPdf(buffer);
|
||||
if (!pdfResult.hasText && config.app.ocrEnabled) {
|
||||
logger.info(
|
||||
{ mimeType },
|
||||
'PDF contains no selectable text. Attempting OCR fallback...'
|
||||
);
|
||||
const pngPages = await pdfToPng(buffer);
|
||||
let ocrText = '';
|
||||
for (const pngPage of pngPages) {
|
||||
ocrText += await ocrService.recognize(pngPage.content) + '\n';
|
||||
}
|
||||
return ocrText;
|
||||
}
|
||||
return pdfResult.text;
|
||||
}
|
||||
|
||||
if (OCR_SUPPORTED_MIME_TYPES.includes(mimeType) && config.app.ocrEnabled) {
|
||||
return await ocrService.recognize(buffer);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -61,10 +111,10 @@ export async function extractText(buffer: Buffer, mimeType: string): Promise<str
|
||||
return buffer.toString('utf-8');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error extracting text from attachment with MIME type ${mimeType}:`, error);
|
||||
return ''; // Return empty string on failure
|
||||
logger.error({ err: error, mimeType }, 'Error extracting text from attachment');
|
||||
return '';
|
||||
}
|
||||
|
||||
console.warn(`Unsupported MIME type for text extraction: ${mimeType}`);
|
||||
return ''; // Return empty string for unsupported types
|
||||
logger.warn({ mimeType }, 'Unsupported MIME type for text extraction');
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
72
packages/backend/src/services/ApiKeyService.ts
Normal file
72
packages/backend/src/services/ApiKeyService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
71
packages/backend/src/services/OcrService.ts
Normal file
71
packages/backend/src/services/OcrService.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createScheduler, createWorker, Scheduler } from 'tesseract.js';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../config/logger';
|
||||
|
||||
class OcrService {
|
||||
private static instance: OcrService;
|
||||
private scheduler: Scheduler | null = null;
|
||||
private isInitialized = false;
|
||||
|
||||
private constructor() { }
|
||||
|
||||
public static getInstance(): OcrService {
|
||||
if (!OcrService.instance) {
|
||||
OcrService.instance = new OcrService();
|
||||
}
|
||||
return OcrService.instance;
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
if (this.isInitialized || !config.app.ocrEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ languages: config.app.ocrLanguages }, 'Initializing OCR Service...');
|
||||
this.scheduler = createScheduler();
|
||||
const languages = config.app.ocrLanguages.split(',');
|
||||
const numWorkers = Math.max(1, require('os').cpus().length - 1);
|
||||
|
||||
const workerPromises = Array.from({ length: numWorkers }).map(async () => {
|
||||
const worker = await createWorker(languages, 1, {
|
||||
cachePath: '/opt/open-archiver/tessdata',
|
||||
});
|
||||
this.scheduler!.addWorker(worker);
|
||||
});
|
||||
|
||||
await Promise.all(workerPromises);
|
||||
this.isInitialized = true;
|
||||
logger.info(
|
||||
`OCR Service initialized with ${numWorkers} workers for languages: [${languages.join(', ')}]`
|
||||
);
|
||||
}
|
||||
|
||||
public async recognize(buffer: Buffer): Promise<string> {
|
||||
if (!config.app.ocrEnabled) return '';
|
||||
if (!this.isInitialized) await this.initialize();
|
||||
if (!this.scheduler) {
|
||||
logger.error('OCR scheduler not available.');
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const {
|
||||
data: { text },
|
||||
} = await this.scheduler.addJob('recognize', buffer);
|
||||
return text;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Error during OCR processing');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
public async terminate(): Promise<void> {
|
||||
if (this.scheduler && this.isInitialized) {
|
||||
logger.info('Terminating OCR Service...');
|
||||
await this.scheduler.terminate();
|
||||
this.scheduler = null;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ocrService = OcrService.getInstance();
|
||||
@@ -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 })
|
||||
|
||||
@@ -26,6 +26,10 @@ 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,
|
||||
@@ -145,107 +149,112 @@ export class ImapConnector implements IEmailConnector {
|
||||
userEmail: string,
|
||||
syncState?: SyncState | null
|
||||
): AsyncGenerator<EmailObject | null> {
|
||||
// list all mailboxes first
|
||||
const mailboxes = await this.withRetry(async () => await this.client.list());
|
||||
await this.disconnect();
|
||||
try {
|
||||
// list all mailboxes first
|
||||
const mailboxes = await this.withRetry(async () => await this.client.list());
|
||||
|
||||
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') {
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
if (mailbox.exists > 0) {
|
||||
const lastMessage = await this.client.fetchOne(String(mailbox.exists), {
|
||||
uid: true,
|
||||
})) {
|
||||
if (lastUid && msg.uid <= lastUid) {
|
||||
continue;
|
||||
}
|
||||
});
|
||||
if (lastMessage && lastMessage.uid > currentMaxUid) {
|
||||
currentMaxUid = lastMessage.uid;
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.uid > this.newMaxUids[mailboxPath]) {
|
||||
this.newMaxUids[mailboxPath] = msg.uid;
|
||||
}
|
||||
// Initialize with last synced UID, not the maximum UID in mailbox
|
||||
this.newMaxUids[mailboxPath] = lastUid || 0;
|
||||
|
||||
logger.debug({ mailboxPath, uid: msg.uid }, 'Processing message');
|
||||
// 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;
|
||||
|
||||
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;
|
||||
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;
|
||||
// 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.';
|
||||
}
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Worker } from 'bullmq';
|
||||
import { connection } from '../config/redis';
|
||||
import indexEmailProcessor from '../jobs/processors/index-email.processor';
|
||||
import { ocrService } from '../services/OcrService';
|
||||
import { logger } from '../config/logger';
|
||||
|
||||
const processor = async (job: any) => {
|
||||
switch (job.name) {
|
||||
@@ -22,7 +24,14 @@ const worker = new Worker('indexing', processor, {
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Indexing worker started');
|
||||
logger.info('Indexing worker started');
|
||||
|
||||
process.on('SIGINT', () => worker.close());
|
||||
process.on('SIGTERM', () => worker.close());
|
||||
const gracefulShutdown = async () => {
|
||||
logger.info('Shutting down indexing worker...');
|
||||
await worker.close();
|
||||
await ocrService.terminate();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', gracefulShutdown);
|
||||
process.on('SIGTERM', gracefulShutdown);
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
providerConfig: source?.credentials ?? {
|
||||
type: source?.provider ?? 'generic_imap',
|
||||
secure: true,
|
||||
allowInsecureCert: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -222,6 +223,12 @@
|
||||
>
|
||||
<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"
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"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!",
|
||||
@@ -221,8 +222,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",
|
||||
|
||||
@@ -6,8 +6,9 @@ import type { SystemSettings } from '@open-archiver/types';
|
||||
|
||||
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 +18,24 @@ 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;
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
accessToken: locals.accessToken,
|
||||
isDemo: process.env.IS_DEMO === 'true',
|
||||
settings,
|
||||
systemSettings,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
let finalTheme = $theme;
|
||||
|
||||
if (finalTheme === 'system') {
|
||||
finalTheme = data.settings?.theme || 'system';
|
||||
finalTheme = data.systemSettings?.theme || 'system';
|
||||
}
|
||||
|
||||
const isDark =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
href: '/dashboard/settings/roles',
|
||||
label: $t('app.layout.roles'),
|
||||
},
|
||||
{
|
||||
href: '/dashboard/settings/api-keys',
|
||||
label: $t('app.layout.api_keys'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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 }[] = [
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface GenericImapCredentials extends BaseIngestionCredentials {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
allowInsecureCert: boolean;
|
||||
username: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
@@ -35,3 +35,11 @@ export interface Role {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
205
pnpm-lock.yaml
generated
205
pnpm-lock.yaml
generated
@@ -29,7 +29,7 @@ importers:
|
||||
version: 5.8.3
|
||||
vitepress:
|
||||
specifier: ^1.6.4
|
||||
version: 1.6.4(@algolia/client-search@5.34.1)(@types/node@24.0.13)(axios@1.10.0)(lightningcss@1.30.1)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.8.3)
|
||||
version: 1.6.4(@algolia/client-search@5.34.1)(@types/node@24.0.13)(axios@1.10.0)(idb-keyval@6.2.2)(lightningcss@1.30.1)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.8.3)
|
||||
|
||||
packages/backend:
|
||||
dependencies:
|
||||
@@ -123,6 +123,9 @@ importers:
|
||||
multer:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
pdf-to-png-converter:
|
||||
specifier: ^3.7.1
|
||||
version: 3.7.1
|
||||
pdf2json:
|
||||
specifier: ^3.1.6
|
||||
version: 3.1.6
|
||||
@@ -147,6 +150,9 @@ importers:
|
||||
sqlite3:
|
||||
specifier: ^5.1.7
|
||||
version: 5.1.7
|
||||
tesseract.js:
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1(encoding@0.1.13)
|
||||
tsconfig-paths:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
@@ -156,6 +162,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
|
||||
@@ -1171,6 +1180,70 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.78':
|
||||
resolution: {integrity: sha512-N1ikxztjrRmh8xxlG5kYm1RuNr8ZW1EINEDQsLhhuy7t0pWI/e7SH91uFVLZKCMDyjel1tyWV93b5fdCAi7ggw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.78':
|
||||
resolution: {integrity: sha512-FA3aCU3G5yGc74BSmnLJTObnZRV+HW+JBTrsU+0WVVaNyVKlb5nMvYAQuieQlRVemsAA2ek2c6nYtHh6u6bwFw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.78':
|
||||
resolution: {integrity: sha512-xVij69o9t/frixCDEoyWoVDKgE3ksLGdmE2nvBWVGmoLu94MWUlv2y4Qzf5oozBmydG5Dcm4pRHFBM7YWa1i6g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.78':
|
||||
resolution: {integrity: sha512-aSEXrLcIpBtXpOSnLhTg4jPsjJEnK7Je9KqUdAWjc7T8O4iYlxWxrXFIF8rV8J79h5jNdScgZpAUWYnEcutR3g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.78':
|
||||
resolution: {integrity: sha512-dlEPRX1hLGKaY3UtGa1dtkA1uGgFITn2mDnfI6YsLlYyLJQNqHx87D1YTACI4zFCUuLr/EzQDzuX+vnp9YveVg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.78':
|
||||
resolution: {integrity: sha512-TsCfjOPZtm5Q/NO1EZHR5pwDPSPjPEttvnv44GL32Zn1uvudssjTLbvaG1jHq81Qxm16GTXEiYLmx4jOLZQYlg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.78':
|
||||
resolution: {integrity: sha512-+cpTTb0GDshEow/5Fy8TpNyzaPsYb3clQIjgWRmzRcuteLU+CHEU/vpYvAcSo7JxHYPJd8fjSr+qqh+nI5AtmA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.78':
|
||||
resolution: {integrity: sha512-wxRcvKfvYBgtrO0Uy8OmwvjlnTcHpY45LLwkwVNIWHPqHAsyoTyG/JBSfJ0p5tWRzMOPDCDqdhpIO4LOgXjeyg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.78':
|
||||
resolution: {integrity: sha512-vQFOGwC9QDP0kXlhb2LU1QRw/humXgcbVp8mXlyBqzc/a0eijlLF9wzyarHC1EywpymtS63TAj8PHZnhTYN6hg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.78':
|
||||
resolution: {integrity: sha512-/eKlTZBtGUgpRKalzOzRr6h7KVSuziESWXgBcBnXggZmimwIJWPJlEcbrx5Tcwj8rPuZiANXQOG9pPgy9Q4LTQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.78':
|
||||
resolution: {integrity: sha512-YaBHJvT+T1DoP16puvWM6w46Lq3VhwKIJ8th5m1iEJyGh7mibk5dT7flBvMQ1EH1LYmMzXJ+OUhu+8wQ9I6u7g==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@npmcli/fs@1.1.1':
|
||||
resolution: {integrity: sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==}
|
||||
|
||||
@@ -2096,6 +2169,9 @@ packages:
|
||||
bluebird@3.4.7:
|
||||
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
|
||||
|
||||
bmp-js@0.1.0:
|
||||
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
|
||||
|
||||
body-parser@1.19.0:
|
||||
resolution: {integrity: sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3083,6 +3159,9 @@ packages:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
idb-keyval@6.2.2:
|
||||
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
@@ -3185,6 +3264,9 @@ packages:
|
||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-url@1.2.4:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
is-what@4.1.16:
|
||||
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
|
||||
engines: {node: '>=12.13'}
|
||||
@@ -3713,6 +3795,10 @@ packages:
|
||||
oniguruma-to-es@3.1.1:
|
||||
resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==}
|
||||
|
||||
opencollective-postinstall@2.0.3:
|
||||
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
|
||||
hasBin: true
|
||||
|
||||
option@0.2.4:
|
||||
resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==}
|
||||
|
||||
@@ -3755,11 +3841,19 @@ packages:
|
||||
resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
pdf-to-png-converter@3.7.1:
|
||||
resolution: {integrity: sha512-bbox+zXQ1FxhXCYwzIRikcfx4tgB6zl9gn3LYx7NyDIjnrtawZVKJ1No4/iz5PnxLTzEK8k6KYSxWFIphxgLbQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
pdf2json@3.1.6:
|
||||
resolution: {integrity: sha512-Nkwo9qeCvqVH0ZgYRUfPyj6o4o7StvNIxMFECeiz4y0uMOVyqc5Y9hjsdFVxdYCeiUjjXLQXA8KIz0iJL3HM0w==}
|
||||
engines: {node: '>=20.18.0'}
|
||||
hasBin: true
|
||||
|
||||
pdfjs-dist@5.4.149:
|
||||
resolution: {integrity: sha512-Xe8/1FMJEQPUVSti25AlDpwpUm2QAVmNOpFP0SIahaPIOKBKICaefbzogLdwey3XGGoaP4Lb9wqiw2e9Jqp0LA==}
|
||||
engines: {node: '>=20.16.0 || >=22.3.0'}
|
||||
|
||||
peberminta@0.9.0:
|
||||
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
||||
|
||||
@@ -4045,6 +4139,9 @@ packages:
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
@@ -4449,6 +4546,12 @@ packages:
|
||||
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tesseract.js-core@6.0.0:
|
||||
resolution: {integrity: sha512-1Qncm/9oKM7xgrQXZXNB+NRh19qiXGhxlrR8EwFbK5SaUbPZnS5OMtP/ghtqfd23hsr1ZvZbZjeuAGcMxd/ooA==}
|
||||
|
||||
tesseract.js@6.0.1:
|
||||
resolution: {integrity: sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==}
|
||||
|
||||
text-decoder@1.2.3:
|
||||
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
|
||||
|
||||
@@ -4717,6 +4820,9 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
wasm-feature-detect@1.8.0:
|
||||
resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==}
|
||||
|
||||
web-streams-polyfill@3.3.3:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -4801,6 +4907,12 @@ packages:
|
||||
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
zlibjs@0.3.1:
|
||||
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
|
||||
|
||||
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==}
|
||||
|
||||
@@ -5812,6 +5924,49 @@ snapshots:
|
||||
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.78':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.78':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.78':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.78':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.78':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.78':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.78':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.78':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.78':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.78':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.78':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.78
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.78
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.78
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.78
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.78
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.78
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.78
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.78
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.78
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.78
|
||||
|
||||
'@npmcli/fs@1.1.1':
|
||||
dependencies:
|
||||
'@gar/promisify': 1.1.3
|
||||
@@ -6680,7 +6835,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/integrations@12.8.2(axios@1.10.0)(focus-trap@7.6.5)(typescript@5.8.3)':
|
||||
'@vueuse/integrations@12.8.2(axios@1.10.0)(focus-trap@7.6.5)(idb-keyval@6.2.2)(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@vueuse/core': 12.8.2(typescript@5.8.3)
|
||||
'@vueuse/shared': 12.8.2(typescript@5.8.3)
|
||||
@@ -6688,6 +6843,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
axios: 1.10.0
|
||||
focus-trap: 7.6.5
|
||||
idb-keyval: 6.2.2
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
@@ -6874,6 +7030,8 @@ snapshots:
|
||||
|
||||
bluebird@3.4.7: {}
|
||||
|
||||
bmp-js@0.1.0: {}
|
||||
|
||||
body-parser@1.19.0:
|
||||
dependencies:
|
||||
bytes: 3.1.0
|
||||
@@ -7972,6 +8130,8 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
idb-keyval@6.2.2: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
imapflow@1.0.191:
|
||||
@@ -8072,6 +8232,8 @@ snapshots:
|
||||
|
||||
is-stream@2.0.1: {}
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
is-what@4.1.16: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
@@ -8637,6 +8799,8 @@ snapshots:
|
||||
regex: 6.0.1
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
opencollective-postinstall@2.0.3: {}
|
||||
|
||||
option@0.2.4: {}
|
||||
|
||||
p-map@4.0.0:
|
||||
@@ -8670,8 +8834,17 @@ snapshots:
|
||||
|
||||
path-to-regexp@8.2.0: {}
|
||||
|
||||
pdf-to-png-converter@3.7.1:
|
||||
dependencies:
|
||||
'@napi-rs/canvas': 0.1.78
|
||||
pdfjs-dist: 5.4.149
|
||||
|
||||
pdf2json@3.1.6: {}
|
||||
|
||||
pdfjs-dist@5.4.149:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.78
|
||||
|
||||
peberminta@0.9.0: {}
|
||||
|
||||
pend@1.2.0: {}
|
||||
@@ -8921,6 +9094,8 @@ snapshots:
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
regenerator-runtime@0.13.11: {}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
@@ -9423,6 +9598,22 @@ snapshots:
|
||||
mkdirp: 3.0.1
|
||||
yallist: 5.0.0
|
||||
|
||||
tesseract.js-core@6.0.0: {}
|
||||
|
||||
tesseract.js@6.0.1(encoding@0.1.13):
|
||||
dependencies:
|
||||
bmp-js: 0.1.0
|
||||
idb-keyval: 6.2.2
|
||||
is-url: 1.2.4
|
||||
node-fetch: 2.7.0(encoding@0.1.13)
|
||||
opencollective-postinstall: 2.0.3
|
||||
regenerator-runtime: 0.13.11
|
||||
tesseract.js-core: 6.0.0
|
||||
wasm-feature-detect: 1.8.0
|
||||
zlibjs: 0.3.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
text-decoder@1.2.3:
|
||||
dependencies:
|
||||
b4a: 1.6.7
|
||||
@@ -9623,7 +9814,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vite: 6.3.5(@types/node@24.0.13)(jiti@2.4.2)(lightningcss@1.30.1)
|
||||
|
||||
vitepress@1.6.4(@algolia/client-search@5.34.1)(@types/node@24.0.13)(axios@1.10.0)(lightningcss@1.30.1)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.8.3):
|
||||
vitepress@1.6.4(@algolia/client-search@5.34.1)(@types/node@24.0.13)(axios@1.10.0)(idb-keyval@6.2.2)(lightningcss@1.30.1)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.8.3):
|
||||
dependencies:
|
||||
'@docsearch/css': 3.8.2
|
||||
'@docsearch/js': 3.8.2(@algolia/client-search@5.34.1)(search-insights@2.17.3)
|
||||
@@ -9636,7 +9827,7 @@ snapshots:
|
||||
'@vue/devtools-api': 7.7.7
|
||||
'@vue/shared': 3.5.18
|
||||
'@vueuse/core': 12.8.2(typescript@5.8.3)
|
||||
'@vueuse/integrations': 12.8.2(axios@1.10.0)(focus-trap@7.6.5)(typescript@5.8.3)
|
||||
'@vueuse/integrations': 12.8.2(axios@1.10.0)(focus-trap@7.6.5)(idb-keyval@6.2.2)(typescript@5.8.3)
|
||||
focus-trap: 7.6.5
|
||||
mark.js: 8.11.1
|
||||
minisearch: 7.1.2
|
||||
@@ -9682,6 +9873,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
wasm-feature-detect@1.8.0: {}
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
@@ -9765,4 +9958,8 @@ snapshots:
|
||||
compress-commons: 6.0.2
|
||||
readable-stream: 4.7.0
|
||||
|
||||
zlibjs@0.3.1: {}
|
||||
|
||||
zod@4.1.5: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
Reference in New Issue
Block a user