mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Compare commits
3 Commits
docs
...
attachment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1a3886431 | ||
|
|
f84bc0cbb0 | ||
|
|
7d178d786b |
13
.env.example
13
.env.example
@@ -52,6 +52,19 @@ 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
|
||||
|
||||
@@ -11,6 +11,9 @@ services:
|
||||
- .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
|
||||
|
||||
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,6 +107,14 @@ 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 |
|
||||
|
||||
@@ -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,6 +59,7 @@
|
||||
"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",
|
||||
|
||||
@@ -4,54 +4,63 @@ 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."),
|
||||
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;
|
||||
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);
|
||||
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') });
|
||||
}
|
||||
}
|
||||
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);
|
||||
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);
|
||||
}
|
||||
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);
|
||||
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') });
|
||||
}
|
||||
res.status(204).send({ message: req.t('apiKeys.deleteSuccess') });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ export const rateLimiter = rateLimit({
|
||||
max: config.api.rateLimit.max,
|
||||
message: {
|
||||
status: 429,
|
||||
message: `Too many requests from this IP, please try again after ${windowInMinutes} minutes`
|
||||
message: `Too many requests from this IP, please try again after ${windowInMinutes} minutes`,
|
||||
},
|
||||
statusCode: 429,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ export const requireAuth = (authService: AuthService) => {
|
||||
req.user = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
roles: user.role ? [user.role.name] : []
|
||||
roles: user.role ? [user.role.name] : [],
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ import { requireAuth } from '../middleware/requireAuth';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const apiKeyRoutes = (authService: AuthService) => {
|
||||
const router = Router();
|
||||
const controller = new ApiKeyController();
|
||||
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);
|
||||
router.post('/', requireAuth(authService), controller.generateApiKey);
|
||||
router.get('/', requireAuth(authService), controller.getApiKeys);
|
||||
router.delete('/:id', requireAuth(authService), controller.deleteApiKey);
|
||||
|
||||
return router;
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,8 +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
|
||||
}
|
||||
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',
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,146 +1,146 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1752225352591,
|
||||
"tag": "0000_amusing_namora",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1752326803882,
|
||||
"tag": "0001_odd_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1752332648392,
|
||||
"tag": "0002_lethal_quentin_quire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1752332967084,
|
||||
"tag": "0003_petite_wrecker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1752606108876,
|
||||
"tag": "0004_sleepy_paper_doll",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1752606327253,
|
||||
"tag": "0005_chunky_sue_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1753112018514,
|
||||
"tag": "0006_majestic_caretaker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1753190159356,
|
||||
"tag": "0007_handy_archangel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1753370737317,
|
||||
"tag": "0008_eminent_the_spike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1754337938241,
|
||||
"tag": "0009_late_lenny_balinger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1754420780849,
|
||||
"tag": "0010_perpetual_lightspeed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1754422064158,
|
||||
"tag": "0011_tan_blackheart",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1754476962901,
|
||||
"tag": "0012_warm_the_stranger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1754659373517,
|
||||
"tag": "0013_classy_talkback",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1754831765718,
|
||||
"tag": "0014_foamy_vapor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1755443936046,
|
||||
"tag": "0015_wakeful_norman_osborn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1755780572342,
|
||||
"tag": "0016_lonely_mariko_yashida",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1752225352591,
|
||||
"tag": "0000_amusing_namora",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1752326803882,
|
||||
"tag": "0001_odd_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1752332648392,
|
||||
"tag": "0002_lethal_quentin_quire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1752332967084,
|
||||
"tag": "0003_petite_wrecker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1752606108876,
|
||||
"tag": "0004_sleepy_paper_doll",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1752606327253,
|
||||
"tag": "0005_chunky_sue_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1753112018514,
|
||||
"tag": "0006_majestic_caretaker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1753190159356,
|
||||
"tag": "0007_handy_archangel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1753370737317,
|
||||
"tag": "0008_eminent_the_spike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1754337938241,
|
||||
"tag": "0009_late_lenny_balinger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1754420780849,
|
||||
"tag": "0010_perpetual_lightspeed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1754422064158,
|
||||
"tag": "0011_tan_blackheart",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1754476962901,
|
||||
"tag": "0012_warm_the_stranger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1754659373517,
|
||||
"tag": "0013_classy_talkback",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1754831765718,
|
||||
"tag": "0014_foamy_vapor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1755443936046,
|
||||
"tag": "0015_wakeful_norman_osborn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1755780572342,
|
||||
"tag": "0016_lonely_mariko_yashida",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ 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(),
|
||||
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 '';
|
||||
}
|
||||
|
||||
@@ -95,10 +95,7 @@ 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$/
|
||||
];
|
||||
const excludedPatterns = [/^\/v\d+\/auth\/status$/, /^\/v\d+\/settings\/system$/];
|
||||
for (const pattern of excludedPatterns) {
|
||||
if (pattern.test(req.path)) {
|
||||
return next();
|
||||
|
||||
@@ -6,67 +6,67 @@ 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');
|
||||
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
|
||||
});
|
||||
await db.insert(apiKeys).values({
|
||||
userId,
|
||||
name,
|
||||
key: CryptoService.encrypt(key),
|
||||
keyHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return key;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
public static async getKeys(userId: string): Promise<ApiKey[]> {
|
||||
const keys = await db.select().from(apiKeys).where(eq(apiKeys.userId, userId));
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
return apiKey.userId;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -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);
|
||||
|
||||
@@ -2,49 +2,48 @@ 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();
|
||||
const response = await api('/api-keys', event);
|
||||
const apiKeys = await response.json();
|
||||
|
||||
return {
|
||||
apiKeys,
|
||||
};
|
||||
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'));
|
||||
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 response = await api('/api-keys', event, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, expiresInDays }),
|
||||
});
|
||||
|
||||
const responseBody = await response.json();
|
||||
const responseBody = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
message: responseBody.message || '',
|
||||
errors: responseBody.errors
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
await api(`/api-keys/${id}`, event, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
197
pnpm-lock.yaml
generated
197
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
|
||||
@@ -1174,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==}
|
||||
|
||||
@@ -2099,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'}
|
||||
@@ -3086,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==}
|
||||
|
||||
@@ -3188,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'}
|
||||
@@ -3716,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==}
|
||||
|
||||
@@ -3758,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==}
|
||||
|
||||
@@ -4048,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==}
|
||||
|
||||
@@ -4452,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==}
|
||||
|
||||
@@ -4720,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'}
|
||||
@@ -4804,6 +4907,9 @@ 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==}
|
||||
|
||||
@@ -5818,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
|
||||
@@ -6686,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)
|
||||
@@ -6694,6 +6843,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
axios: 1.10.0
|
||||
focus-trap: 7.6.5
|
||||
idb-keyval: 6.2.2
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
@@ -6880,6 +7030,8 @@ snapshots:
|
||||
|
||||
bluebird@3.4.7: {}
|
||||
|
||||
bmp-js@0.1.0: {}
|
||||
|
||||
body-parser@1.19.0:
|
||||
dependencies:
|
||||
bytes: 3.1.0
|
||||
@@ -7978,6 +8130,8 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
idb-keyval@6.2.2: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
imapflow@1.0.191:
|
||||
@@ -8078,6 +8232,8 @@ snapshots:
|
||||
|
||||
is-stream@2.0.1: {}
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
is-what@4.1.16: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
@@ -8643,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:
|
||||
@@ -8676,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: {}
|
||||
@@ -8927,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
|
||||
@@ -9429,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
|
||||
@@ -9629,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)
|
||||
@@ -9642,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
|
||||
@@ -9688,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: {}
|
||||
@@ -9771,6 +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