mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 08:41:57 +02:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3434e8d6ef | ||
|
|
7dac3b2bfd | ||
|
|
cf121989ae | ||
|
|
2df5c9240d | ||
|
|
24afd13858 | ||
|
|
c2006dfa94 | ||
|
|
399059a773 | ||
|
|
0cff788656 | ||
|
|
ddb4d56107 | ||
|
|
42b0f6e5f1 | ||
|
|
6e1ebbbfd7 | ||
|
|
1e048fdbc1 | ||
|
|
b71dd55e25 | ||
|
|
d372ef7566 | ||
|
|
e9a65f9672 | ||
|
|
ce3f379b7a | ||
|
|
37a778cb6d | ||
|
|
26a760b232 | ||
|
|
6be0774bc4 | ||
|
|
4a23f8f29f | ||
|
|
074256ed59 | ||
|
|
7d178d786b | ||
|
|
4b11cd931a | ||
|
|
0a21ad14cd | ||
|
|
63d3960f79 | ||
|
|
85a526d1b6 | ||
|
|
52a1a11973 | ||
|
|
4048f47777 | ||
|
|
22b173cbe4 | ||
|
|
774b0d7a6b | ||
|
|
85607d2ab3 | ||
|
|
94021eab69 | ||
|
|
faefdac44a | ||
|
|
392f51dabc | ||
|
|
baff1195c7 | ||
|
|
f1da17e484 | ||
|
|
a2c55f36ee | ||
|
|
9fdba4cd61 | ||
|
|
108c646596 | ||
|
|
61e44c81f7 | ||
|
|
f651aeab0e | ||
|
|
3fb4290934 | ||
|
|
8c33b63bdf | ||
|
|
2b325f3461 | ||
|
|
4d3c164bc0 | ||
|
|
7288286fd9 |
40
.env.example
40
.env.example
@@ -4,8 +4,15 @@
|
||||
NODE_ENV=development
|
||||
PORT_BACKEND=4000
|
||||
PORT_FRONTEND=3000
|
||||
# The public-facing URL of your application. This is used by the backend to configure CORS.
|
||||
APP_URL=http://localhost:3000
|
||||
# This is used by the SvelteKit Node adapter to determine the server's public-facing URL.
|
||||
# It should always be set to the value of APP_URL.
|
||||
ORIGIN=$APP_URL
|
||||
# The frequency of continuous email syncing. Default is every minutes, but you can change it to another value based on your needs.
|
||||
SYNC_FREQUENCY='* * * * *'
|
||||
# Set to 'true' to include Junk and Trash folders in the email archive. Defaults to false.
|
||||
ALL_INCLUSIVE_ARCHIVE=false
|
||||
|
||||
# --- Docker Compose Service Configuration ---
|
||||
# These variables are used by docker-compose.yml to configure the services. Leave them unchanged if you use Docker services for Postgresql, Valkey (Redis) and Meilisearch. If you decide to use your own instances of these services, you can substitute them with your own connection credentials.
|
||||
@@ -19,7 +26,8 @@ DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/$
|
||||
# Meilisearch
|
||||
MEILI_MASTER_KEY=aSampleMasterKey
|
||||
MEILI_HOST=http://meilisearch:7700
|
||||
|
||||
# The number of emails to batch together for indexing. Defaults to 500.
|
||||
MEILI_INDEXING_BATCH=500
|
||||
|
||||
|
||||
# Redis (We use Valkey, which is Redis-compatible and open source)
|
||||
@@ -28,6 +36,8 @@ REDIS_PORT=6379
|
||||
REDIS_PASSWORD=defaultredispassword
|
||||
# If you run Valkey service from Docker Compose, set the REDIS_TLS_ENABLED variable to false.
|
||||
REDIS_TLS_ENABLED=false
|
||||
# Redis username. Only required if not using the default user.
|
||||
REDIS_USER=notdefaultuser
|
||||
|
||||
|
||||
# --- Storage Settings ---
|
||||
@@ -39,7 +49,9 @@ BODY_SIZE_LIMIT=100M
|
||||
# --- Local Storage Settings ---
|
||||
# The path inside the container where files will be stored.
|
||||
# This is mapped to a Docker volume for persistence.
|
||||
# This is only used if STORAGE_TYPE is 'local'.
|
||||
# This is not an optional variable, it is where the Open Archiver service stores application data. Set this even if you are using S3 storage.
|
||||
# Make sure the user that runs the Open Archiver service has read and write access to this path.
|
||||
# Important: It is recommended to create this path manually before installation, otherwise you may face permission and ownership problems.
|
||||
STORAGE_LOCAL_ROOT_PATH=/var/data/open-archiver
|
||||
|
||||
# --- S3-Compatible Storage Settings ---
|
||||
@@ -52,19 +64,37 @@ STORAGE_S3_REGION=
|
||||
# Set to 'true' for MinIO and other non-AWS S3 services
|
||||
STORAGE_S3_FORCE_PATH_STYLE=false
|
||||
|
||||
# --- Storage Encryption ---
|
||||
# IMPORTANT: Generate a secure, random 32-byte hex string for this key.
|
||||
# You can use `openssl rand -hex 32` to generate a key.
|
||||
# This key is used for AES-256 encryption of files at rest.
|
||||
# This is an optional variable, if not set, files will not be encrypted.
|
||||
STORAGE_ENCRYPTION_KEY=
|
||||
|
||||
# --- Security & Authentication ---
|
||||
|
||||
# Enable or disable deletion of emails and ingestion sources. Defaults to false.
|
||||
ENABLE_DELETION=false
|
||||
|
||||
# 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=
|
||||
|
||||
|
||||
# Apache Tika Integration
|
||||
# ONLY active if TIKA_URL is set
|
||||
TIKA_URL=http://tika:9998
|
||||
|
||||
27
.github/CLA-v2.md
vendored
Normal file
27
.github/CLA-v2.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Contributor License Agreement (CLA)
|
||||
|
||||
Version: 2
|
||||
|
||||
This Agreement is for your protection as a Contributor as well as the protection of the maintainers of the Open Archiver software; it does not change your rights to use your own Contributions for any other purpose. Open Archiver is developed and maintained by LogicLabs OÜ, a private limited company established under the laws of the Republic of Estonia.
|
||||
|
||||
You accept and agree to the following terms and conditions for Your present and future Contributions submitted to LogicLabs OÜ. Except for the license granted herein to LogicLabs OÜ and recipients of software distributed by LogicLabs OÜ, You reserve all right, title, and interest in and to Your Contributions.
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with LogicLabs OÜ. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor.
|
||||
|
||||
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to LogicLabs OÜ for inclusion in, or documentation of, any of the products owned or managed by LogicLabs OÜ (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to LogicLabs OÜ or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, LogicLabs OÜ for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You grant to LogicLabs OÜ and to recipients of software distributed by LogicLabs OÜ a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You grant to LogicLabs OÜ and to recipients of software distributed by LogicLabs OÜ a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to LogicLabs OÜ, or that your employer has executed a separate Contributor License Agreement with LogicLabs OÜ.
|
||||
|
||||
5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
|
||||
|
||||
6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
7. Should You wish to submit work that is not Your original creation, You may submit it to LogicLabs OÜ separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
|
||||
|
||||
8. You agree to notify LogicLabs OÜ of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [wayneshn]
|
||||
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**System:**
|
||||
|
||||
- Open Archiver Version:
|
||||
|
||||
**Relevant logs:**
|
||||
Any relevant logs (Redact sensitive information)
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is.
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
4
.github/workflows/cla.yml
vendored
4
.github/workflows/cla.yml
vendored
@@ -23,8 +23,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
with:
|
||||
path-to-signatures: 'signatures/version1/cla.json'
|
||||
path-to-document: 'https://github.com/LogicLabs-OU/OpenArchiver/tree/main/.github/CLA.md'
|
||||
path-to-signatures: 'signatures/version2/cla.json'
|
||||
path-to-document: 'https://github.com/LogicLabs-OU/OpenArchiver/blob/main/.github/CLA-v2.md'
|
||||
branch: 'main'
|
||||
allowlist: 'wayneshn'
|
||||
|
||||
|
||||
2
.github/workflows/docker-deployment.yml
vendored
2
.github/workflows/docker-deployment.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
file: ./apps/open-archiver/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: logiclabshq/open-archiver:${{ steps.sha.outputs.sha }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,3 +24,7 @@ pnpm-debug.log
|
||||
# Vitepress
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/cache
|
||||
|
||||
|
||||
# TS
|
||||
**/tsconfig.tsbuildinfo
|
||||
|
||||
140
LICENSE
140
LICENSE
@@ -200,23 +200,23 @@ You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
- **a)** The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
- **b)** The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section 7.
|
||||
This requirement modifies the requirement in section 4 to
|
||||
“keep intact all notices”.
|
||||
- **c)** You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
- **d)** If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
- **a)** The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
- **b)** The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section 7.
|
||||
This requirement modifies the requirement in section 4 to
|
||||
“keep intact all notices”.
|
||||
- **c)** You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
- **d)** If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
@@ -235,42 +235,42 @@ of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
- **a)** Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
- **b)** Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either **(1)** a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or **(2)** access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
- **c)** Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
- **d)** Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
- **e)** Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
- **a)** Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
- **b)** Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either **(1)** a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or **(2)** access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
- **c)** Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
- **d)** Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
- **e)** Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
@@ -344,23 +344,23 @@ Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
- **a)** Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
- **b)** Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
- **c)** Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
- **d)** Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
- **e)** Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
- **f)** Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
- **a)** Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
- **b)** Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
- **c)** Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
- **d)** Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
- **e)** Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
- **f)** Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered “further
|
||||
restrictions” within the meaning of section 10. If the Program as you
|
||||
|
||||
11
README.md
11
README.md
@@ -7,7 +7,7 @@
|
||||
[](https://redis.io)
|
||||
[](https://svelte.dev/)
|
||||
|
||||
**A secure, sovereign, and open-source platform for email archiving and eDiscovery.**
|
||||
**A secure, sovereign, and open-source platform for email archiving.**
|
||||
|
||||
Open Archiver provides a robust, self-hosted solution for archiving, storing, indexing, and searching emails from major platforms, including Google Workspace (Gmail), Microsoft 365, PST files, as well as generic IMAP-enabled email inboxes. Use Open Archiver to keep a permanent, tamper-proof record of your communication history, free from vendor lock-in.
|
||||
|
||||
@@ -46,13 +46,16 @@ Password: openarchiver_demo
|
||||
- Microsoft 365
|
||||
- PST files
|
||||
- Zipped .eml files
|
||||
- Mbox files
|
||||
|
||||
- **Secure & Efficient Storage**: Emails are stored in the standard `.eml` format. The system uses deduplication and compression to minimize storage costs. All data is encrypted at rest.
|
||||
- **Secure & Efficient Storage**: Emails are stored in the standard `.eml` format. The system uses deduplication and compression to minimize storage costs. All files are encrypted at rest.
|
||||
- **Pluggable Storage Backends**: Support both local filesystem storage and S3-compatible object storage (like AWS S3 or MinIO).
|
||||
- **Powerful Search & eDiscovery**: A high-performance search engine indexes the full text of emails and attachments (PDF, DOCX, etc.).
|
||||
- **Thread discovery**: The ability to discover if an email belongs to a thread/conversation and present the context.
|
||||
- **Compliance & Retention**: Define granular retention policies to automatically manage the lifecycle of your data. Place legal holds on communications to prevent deletion during litigation (TBD).
|
||||
- **Comprehensive Auditing**: An immutable audit trail logs all system activities, ensuring you have a clear record of who accessed what and when (TBD).
|
||||
- **File Hash and Encryption**: Email and attachment file hash values are stored in the meta database upon ingestion, meaning any attempt to alter the file content will be identified, ensuring legal and regulatory compliance.
|
||||
- - Each archived email comes with an "Integrity Report" feature that indicates if the files are original.
|
||||
- **Comprehensive Auditing**: An immutable audit trail logs all system activities, ensuring you have a clear record of who accessed what and when.
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
@@ -78,7 +81,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:**
|
||||
|
||||
@@ -1,54 +1,50 @@
|
||||
# Dockerfile for Open Archiver
|
||||
# Dockerfile for the OSS version of Open Archiver
|
||||
|
||||
# 1. Build Stage: Install all dependencies and build the project
|
||||
FROM node:22-alpine AS build
|
||||
ARG BASE_IMAGE=node:22-alpine
|
||||
|
||||
# 0. Base Stage: Define all common dependencies and setup
|
||||
FROM ${BASE_IMAGE} AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install -g pnpm
|
||||
|
||||
# Copy manifests and lockfile
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY packages/backend/package.json ./packages/backend/
|
||||
COPY packages/frontend/package.json ./packages/frontend/
|
||||
COPY packages/types/package.json ./packages/types/
|
||||
COPY apps/open-archiver/package.json ./apps/open-archiver/
|
||||
|
||||
# 1. Build Stage: Install all dependencies and build the project
|
||||
FROM base AS build
|
||||
COPY packages/frontend/svelte.config.js ./packages/frontend/
|
||||
|
||||
# Install all dependencies. Use --shamefully-hoist to create a flat node_modules structure
|
||||
RUN pnpm install --shamefully-hoist --frozen-lockfile --prod=false
|
||||
# Install all dependencies.
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --shamefully-hoist --frozen-lockfile --prod=false
|
||||
|
||||
# Copy the rest of the source code
|
||||
COPY . .
|
||||
|
||||
# Build all packages.
|
||||
RUN pnpm build
|
||||
# Build the OSS packages.
|
||||
RUN pnpm build:oss
|
||||
|
||||
# 2. Production Stage: Install only production dependencies and copy built artifacts
|
||||
FROM node:22-alpine AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Copy manifests and lockfile
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY packages/backend/package.json ./packages/backend/
|
||||
COPY packages/frontend/package.json ./packages/frontend/
|
||||
COPY packages/types/package.json ./packages/types/
|
||||
|
||||
# Install production dependencies
|
||||
# RUN pnpm install --shamefully-hoist --frozen-lockfile --prod=true
|
||||
FROM base AS production
|
||||
|
||||
# Copy built application from build stage
|
||||
COPY --from=build /app/packages/backend/dist ./packages/backend/dist
|
||||
COPY --from=build /app/packages/frontend/build ./packages/frontend/build
|
||||
COPY --from=build /app/packages/types/dist ./packages/types/dist
|
||||
COPY --from=build /app/packages/backend/drizzle.config.ts ./packages/backend/drizzle.config.ts
|
||||
COPY --from=build /app/packages/backend/src/database/migrations ./packages/backend/src/database/migrations
|
||||
COPY --from=build /app/packages/frontend/build ./packages/frontend/build
|
||||
COPY --from=build /app/packages/types/dist ./packages/types/dist
|
||||
COPY --from=build /app/apps/open-archiver/dist ./apps/open-archiver/dist
|
||||
|
||||
# Copy the entrypoint script and make it executable
|
||||
COPY docker/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 4000
|
||||
@@ -58,4 +54,4 @@ EXPOSE 3000
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
# Start the application
|
||||
CMD ["pnpm", "docker-start"]
|
||||
CMD ["pnpm", "docker-start:oss"]
|
||||
24
apps/open-archiver/index.ts
Normal file
24
apps/open-archiver/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createServer, logger } from '@open-archiver/backend';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function start() {
|
||||
// --- Environment Variable Validation ---
|
||||
const { PORT_BACKEND } = process.env;
|
||||
|
||||
if (!PORT_BACKEND) {
|
||||
throw new Error('Missing required environment variables for the backend: PORT_BACKEND.');
|
||||
}
|
||||
// Create the server instance (passing no modules for the default OSS version)
|
||||
const app = await createServer([]);
|
||||
|
||||
app.listen(PORT_BACKEND, () => {
|
||||
logger.info({}, `✅ Open Archiver (OSS) running on port ${PORT_BACKEND}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
logger.error({ error }, 'Failed to start the server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
18
apps/open-archiver/package.json
Normal file
18
apps/open-archiver/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "open-archiver-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-archiver/backend": "workspace:*",
|
||||
"dotenv": "^17.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dotenv": "^8.2.3",
|
||||
"ts-node-dev": "^2.0.0"
|
||||
}
|
||||
}
|
||||
8
apps/open-archiver/tsconfig.json
Normal file
8
apps/open-archiver/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"references": [{ "path": "../../packages/backend" }]
|
||||
}
|
||||
BIN
assets/screenshots/integrity-report.png
Normal file
BIN
assets/screenshots/integrity-report.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
BIN
assets/screenshots/job-queue.png
Normal file
BIN
assets/screenshots/job-queue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
@@ -6,12 +6,11 @@ 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
|
||||
- ${STORAGE_LOCAL_ROOT_PATH}:${STORAGE_LOCAL_ROOT_PATH}
|
||||
depends_on:
|
||||
- postgres
|
||||
- valkey
|
||||
@@ -29,8 +28,6 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- '5432:5432'
|
||||
networks:
|
||||
- open-archiver-net
|
||||
|
||||
@@ -39,8 +36,6 @@ services:
|
||||
container_name: valkey
|
||||
restart: unless-stopped
|
||||
command: valkey-server --requirepass ${REDIS_PASSWORD}
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
- valkeydata:/data
|
||||
networks:
|
||||
@@ -52,13 +47,19 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-aSampleMasterKey}
|
||||
ports:
|
||||
- '7700:7700'
|
||||
MEILI_SCHEDULE_SNAPSHOT: ${MEILI_SCHEDULE_SNAPSHOT:-86400}
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
networks:
|
||||
- open-archiver-net
|
||||
|
||||
tika:
|
||||
image: apache/tika:3.2.2.0-full
|
||||
container_name: tika
|
||||
restart: always
|
||||
networks:
|
||||
- open-archiver-net
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
driver: local
|
||||
@@ -66,8 +67,6 @@ volumes:
|
||||
driver: local
|
||||
meilidata:
|
||||
driver: local
|
||||
archiver-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
open-archiver-net:
|
||||
|
||||
0
docker/docker-entrypoint.sh
Normal file → Executable file
0
docker/docker-entrypoint.sh
Normal file → Executable file
@@ -10,8 +10,9 @@ export default defineConfig({
|
||||
'data-website-id': '2c8b452e-eab5-4f82-8ead-902d8f8b976f',
|
||||
},
|
||||
],
|
||||
['link', { rel: 'icon', href: '/logo-sq.svg' }],
|
||||
],
|
||||
title: 'Open Archiver',
|
||||
title: 'Open Archiver Docs',
|
||||
description: 'Official documentation for the Open Archiver project.',
|
||||
themeConfig: {
|
||||
search: {
|
||||
@@ -32,6 +33,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: 'Get Started', link: '/' },
|
||||
{ text: 'Installation', link: '/user-guides/installation' },
|
||||
{ text: 'Email Integrity Check', link: '/user-guides/integrity-check' },
|
||||
{
|
||||
text: 'Email Providers',
|
||||
link: '/user-guides/email-providers/',
|
||||
@@ -51,6 +53,31 @@ export default defineConfig({
|
||||
},
|
||||
{ text: 'EML Import', link: '/user-guides/email-providers/eml' },
|
||||
{ text: 'PST Import', link: '/user-guides/email-providers/pst' },
|
||||
{ text: 'Mbox Import', link: '/user-guides/email-providers/mbox' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Settings',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
text: 'System',
|
||||
link: '/user-guides/settings/system',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Upgrading and Migration',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
text: 'Upgrading',
|
||||
link: '/user-guides/upgrade-and-migration/upgrade',
|
||||
},
|
||||
{
|
||||
text: 'Meilisearch Upgrade',
|
||||
link: '/user-guides/upgrade-and-migration/meilisearch-upgrade',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -60,12 +87,15 @@ 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' },
|
||||
{ text: 'Ingestion', link: '/api/ingestion' },
|
||||
{ text: 'Integrity Check', link: '/api/integrity' },
|
||||
{ text: 'Search', link: '/api/search' },
|
||||
{ text: 'Storage', link: '/api/storage' },
|
||||
{ text: 'Jobs', link: '/api/jobs' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -73,6 +103,11 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: 'Overview', link: '/services/' },
|
||||
{ text: 'Storage Service', link: '/services/storage-service' },
|
||||
{ text: 'OCR Service', link: '/services/ocr-service' },
|
||||
{
|
||||
text: 'IAM Service',
|
||||
items: [{ text: 'IAM Policies', link: '/services/iam-service/iam-policy' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -19,11 +19,45 @@ The request body should be a `CreateIngestionSourceDto` object.
|
||||
```typescript
|
||||
interface CreateIngestionSourceDto {
|
||||
name: string;
|
||||
provider: 'google' | 'microsoft' | 'generic_imap';
|
||||
provider: 'google_workspace' | 'microsoft_365' | 'generic_imap' | 'pst_import' | 'eml_import' | 'mbox_import';
|
||||
providerConfig: IngestionCredentials;
|
||||
}
|
||||
```
|
||||
|
||||
#### Example: Creating an Mbox Import Source with File Upload
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Mbox Import",
|
||||
"provider": "mbox_import",
|
||||
"providerConfig": {
|
||||
"type": "mbox_import",
|
||||
"uploadedFileName": "emails.mbox",
|
||||
"uploadedFilePath": "open-archiver/tmp/uuid-emails.mbox"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Example: Creating an Mbox Import Source with Local File Path
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Mbox Import",
|
||||
"provider": "mbox_import",
|
||||
"providerConfig": {
|
||||
"type": "mbox_import",
|
||||
"localFilePath": "/path/to/emails.mbox"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** When using `localFilePath`, the file will not be deleted after import. When using `uploadedFilePath` (via the upload API), the file will be automatically deleted after import. The same applies to `pst_import` and `eml_import` providers.
|
||||
|
||||
**Important regarding `localFilePath`:** When running OpenArchiver in a Docker container (which is the standard deployment), `localFilePath` refers to the path **inside the Docker container**, not on the host machine.
|
||||
To use a local file:
|
||||
1. **Recommended:** Place your file inside the directory defined by `STORAGE_LOCAL_ROOT_PATH` (e.g., inside a `temp` folder). Since this directory is already mounted as a volume, the file will be accessible at the same path inside the container.
|
||||
2. **Alternative:** Mount a specific directory containing your files as a volume in `docker-compose.yml`. For example, add `- /path/to/my/files:/imports` to the `volumes` section and use `/imports/myfile.pst` as the `localFilePath`.
|
||||
|
||||
#### Responses
|
||||
|
||||
- **201 Created:** The newly created ingestion source.
|
||||
|
||||
51
docs/api/integrity.md
Normal file
51
docs/api/integrity.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Integrity Check API
|
||||
|
||||
The Integrity Check API provides an endpoint to verify the cryptographic hash of an archived email and its attachments against the stored values in the database. This allows you to ensure that the stored files have not been tampered with or corrupted since they were archived.
|
||||
|
||||
## Check Email Integrity
|
||||
|
||||
Verifies the integrity of a specific archived email and all of its associated attachments.
|
||||
|
||||
- **URL:** `/api/v1/integrity/:id`
|
||||
- **Method:** `GET`
|
||||
- **URL Params:**
|
||||
- `id=[string]` (required) - The UUID of the archived email to check.
|
||||
- **Permissions:** `read:archive`
|
||||
- **Success Response:**
|
||||
- **Code:** 200 OK
|
||||
- **Content:** `IntegrityCheckResult[]`
|
||||
|
||||
### Response Body `IntegrityCheckResult`
|
||||
|
||||
An array of objects, each representing the result of an integrity check for a single file (either the email itself or an attachment).
|
||||
|
||||
| Field | Type | Description |
|
||||
| :--------- | :------------------------ | :-------------------------------------------------------------------------- |
|
||||
| `type` | `'email' \| 'attachment'` | The type of the file being checked. |
|
||||
| `id` | `string` | The UUID of the email or attachment. |
|
||||
| `filename` | `string` (optional) | The filename of the attachment. This field is only present for attachments. |
|
||||
| `isValid` | `boolean` | `true` if the current hash matches the stored hash, otherwise `false`. |
|
||||
| `reason` | `string` (optional) | A reason for the failure. Only present if `isValid` is `false`. |
|
||||
|
||||
### Example Response
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type": "email",
|
||||
"id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
||||
"isValid": true
|
||||
},
|
||||
{
|
||||
"type": "attachment",
|
||||
"id": "b2c3d4e5-f6a7-8901-2345-67890abcdef1",
|
||||
"filename": "document.pdf",
|
||||
"isValid": false,
|
||||
"reason": "Stored hash does not match current hash."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- **Error Response:**
|
||||
- **Code:** 404 Not Found
|
||||
- **Content:** `{ "message": "Archived email not found" }`
|
||||
128
docs/api/jobs.md
Normal file
128
docs/api/jobs.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Jobs API
|
||||
|
||||
The Jobs API provides endpoints for monitoring the job queues and the jobs within them.
|
||||
|
||||
## Overview
|
||||
|
||||
Open Archiver uses a job queue system to handle asynchronous tasks like email ingestion and indexing. The system is built on Redis and BullMQ and uses a producer-consumer pattern.
|
||||
|
||||
### Job Statuses
|
||||
|
||||
Jobs can have one of the following statuses:
|
||||
|
||||
- **active:** The job is currently being processed.
|
||||
- **completed:** The job has been completed successfully.
|
||||
- **failed:** The job has failed after all retry attempts.
|
||||
- **delayed:** The job is delayed and will be processed at a later time.
|
||||
- **waiting:** The job is waiting to be processed.
|
||||
- **paused:** The job is paused and will not be processed until it is resumed.
|
||||
|
||||
### Errors
|
||||
|
||||
When a job fails, the `failedReason` and `stacktrace` fields will contain information about the error. The `error` field will also be populated with the `failedReason` for easier access.
|
||||
|
||||
### Job Preservation
|
||||
|
||||
Jobs are preserved for a limited time after they are completed or failed. This means that the job counts and the jobs that you see in the API are for a limited time.
|
||||
|
||||
- **Completed jobs:** The last 1000 completed jobs are preserved.
|
||||
- **Failed jobs:** The last 5000 failed jobs are preserved.
|
||||
|
||||
## Get All Queues
|
||||
|
||||
- **Endpoint:** `GET /v1/jobs/queues`
|
||||
- **Description:** Retrieves a list of all job queues and their job counts.
|
||||
- **Permissions:** `manage:all`
|
||||
- **Responses:**
|
||||
- `200 OK`: Returns a list of queue overviews.
|
||||
- `401 Unauthorized`: If the user is not authenticated.
|
||||
- `403 Forbidden`: If the user does not have the required permissions.
|
||||
|
||||
### Response Body
|
||||
|
||||
```json
|
||||
{
|
||||
"queues": [
|
||||
{
|
||||
"name": "ingestion",
|
||||
"counts": {
|
||||
"active": 0,
|
||||
"completed": 56,
|
||||
"failed": 4,
|
||||
"delayed": 3,
|
||||
"waiting": 0,
|
||||
"paused": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "indexing",
|
||||
"counts": {
|
||||
"active": 0,
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
"delayed": 0,
|
||||
"waiting": 0,
|
||||
"paused": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Get Queue Jobs
|
||||
|
||||
- **Endpoint:** `GET /v1/jobs/queues/:queueName`
|
||||
- **Description:** Retrieves a list of jobs within a specific queue, with pagination and filtering by status.
|
||||
- **Permissions:** `manage:all`
|
||||
- **URL Parameters:**
|
||||
- `queueName` (string, required): The name of the queue to retrieve jobs from.
|
||||
- **Query Parameters:**
|
||||
- `status` (string, optional): The status of the jobs to retrieve. Can be one of `active`, `completed`, `failed`, `delayed`, `waiting`, `paused`. Defaults to `failed`.
|
||||
- `page` (number, optional): The page number to retrieve. Defaults to `1`.
|
||||
- `limit` (number, optional): The number of jobs to retrieve per page. Defaults to `10`.
|
||||
- **Responses:**
|
||||
- `200 OK`: Returns a detailed view of the queue, including a paginated list of jobs.
|
||||
- `401 Unauthorized`: If the user is not authenticated.
|
||||
- `403 Forbidden`: If the user does not have the required permissions.
|
||||
- `404 Not Found`: If the specified queue does not exist.
|
||||
|
||||
### Response Body
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ingestion",
|
||||
"counts": {
|
||||
"active": 0,
|
||||
"completed": 56,
|
||||
"failed": 4,
|
||||
"delayed": 3,
|
||||
"waiting": 0,
|
||||
"paused": 0
|
||||
},
|
||||
"jobs": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "initial-import",
|
||||
"data": {
|
||||
"ingestionSourceId": "clx1y2z3a0000b4d2e5f6g7h8"
|
||||
},
|
||||
"state": "failed",
|
||||
"failedReason": "Error: Connection timed out",
|
||||
"timestamp": 1678886400000,
|
||||
"processedOn": 1678886401000,
|
||||
"finishedOn": 1678886402000,
|
||||
"attemptsMade": 5,
|
||||
"stacktrace": ["..."],
|
||||
"returnValue": null,
|
||||
"ingestionSourceId": "clx1y2z3a0000b4d2e5f6g7h8",
|
||||
"error": "Error: Connection timed out"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 1,
|
||||
"totalPages": 1,
|
||||
"totalJobs": 4,
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
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.
|
||||
78
docs/enterprise/audit-log/api.md
Normal file
78
docs/enterprise/audit-log/api.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Audit Log: API Endpoints
|
||||
|
||||
The audit log feature exposes two API endpoints for retrieving and verifying audit log data. Both endpoints require authentication and are only accessible to users with the appropriate permissions.
|
||||
|
||||
## Get Audit Logs
|
||||
|
||||
Retrieves a paginated list of audit log entries, with support for filtering and sorting.
|
||||
|
||||
- **Endpoint:** `GET /api/v1/enterprise/audit-logs`
|
||||
- **Method:** `GET`
|
||||
- **Authentication:** Required
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| ------------ | -------- | --------------------------------------------------------------------------- |
|
||||
| `page` | `number` | The page number to retrieve. Defaults to `1`. |
|
||||
| `limit` | `number` | The number of entries to retrieve per page. Defaults to `20`. |
|
||||
| `startDate` | `date` | The start date for the date range filter. |
|
||||
| `endDate` | `date` | The end date for the date range filter. |
|
||||
| `actor` | `string` | The actor identifier to filter by. |
|
||||
| `actionType` | `string` | The action type to filter by (e.g., `LOGIN`, `CREATE`). |
|
||||
| `sort` | `string` | The sort order for the results. Can be `asc` or `desc`. Defaults to `desc`. |
|
||||
|
||||
### Response Body
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"previousHash": null,
|
||||
"timestamp": "2025-10-03T00:00:00.000Z",
|
||||
"actorIdentifier": "e8026a75-b58a-4902-8858-eb8780215f82",
|
||||
"actorIp": "::1",
|
||||
"actionType": "LOGIN",
|
||||
"targetType": "User",
|
||||
"targetId": "e8026a75-b58a-4902-8858-eb8780215f82",
|
||||
"details": {},
|
||||
"currentHash": "..."
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"limit": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Verify Audit Log Integrity
|
||||
|
||||
Initiates a verification process to check the integrity of the entire audit log chain.
|
||||
|
||||
- **Endpoint:** `POST /api/v1/enterprise/audit-logs/verify`
|
||||
- **Method:** `POST`
|
||||
- **Authentication:** Required
|
||||
|
||||
### Response Body
|
||||
|
||||
**Success**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"message": "Audit log integrity verified successfully."
|
||||
}
|
||||
```
|
||||
|
||||
**Failure**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"message": "Audit log chain is broken!",
|
||||
"logId": 123
|
||||
}
|
||||
```
|
||||
31
docs/enterprise/audit-log/audit-service.md
Normal file
31
docs/enterprise/audit-log/audit-service.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Audit Log: Backend Implementation
|
||||
|
||||
The backend implementation of the audit log is handled by the `AuditService`, located in `packages/backend/src/services/AuditService.ts`. This service encapsulates all the logic for creating, retrieving, and verifying audit log entries.
|
||||
|
||||
## Hashing and Verification Logic
|
||||
|
||||
The core of the audit log's immutability lies in its hashing and verification logic.
|
||||
|
||||
### Hash Calculation
|
||||
|
||||
The `calculateHash` method is responsible for generating a SHA-256 hash of a log entry. To ensure consistency, it performs the following steps:
|
||||
|
||||
1. **Canonical Object Creation:** It constructs a new object with a fixed property order, ensuring that the object's structure is always the same.
|
||||
2. **Timestamp Normalization:** It converts the `timestamp` to milliseconds since the epoch (`getTime()`) to avoid any precision-related discrepancies between the application and the database.
|
||||
3. **Canonical Stringification:** It uses a custom `canonicalStringify` function to create a JSON string representation of the object. This function sorts the object keys, ensuring that the output is always the same, regardless of the in-memory property order.
|
||||
4. **Hash Generation:** It computes a SHA-256 hash of the canonical string.
|
||||
|
||||
### Verification Process
|
||||
|
||||
The `verifyAuditLog` method is designed to be highly scalable and efficient, even with millions of log entries. It processes the logs in manageable chunks (e.g., 1000 at a time) to avoid loading the entire table into memory.
|
||||
|
||||
The verification process involves the following steps:
|
||||
|
||||
1. **Iterative Processing:** It fetches the logs in batches within a `while` loop.
|
||||
2. **Chain Verification:** For each log entry, it compares the `previousHash` with the `currentHash` of the preceding log. If they do not match, the chain is broken, and the verification fails.
|
||||
3. **Hash Recalculation:** It recalculates the hash of the current log entry using the same `calculateHash` method used during creation.
|
||||
4. **Integrity Check:** It compares the recalculated hash with the `currentHash` stored in the database. If they do not match, the log entry has been tampered with, and the verification fails.
|
||||
|
||||
## Service Integration
|
||||
|
||||
The `AuditService` is integrated into the application through the `AuditLogModule` (`packages/enterprise/src/modules/audit-log/audit-log.module.ts`), which registers the API routes for the audit log feature. The service's `createAuditLog` method is called from various other services throughout the application to record significant events.
|
||||
39
docs/enterprise/audit-log/guide.md
Normal file
39
docs/enterprise/audit-log/guide.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Audit Log: User Interface
|
||||
|
||||
The audit log user interface provides a comprehensive view of all significant events that have occurred within the Open Archiver system. It is designed to be intuitive and user-friendly, allowing administrators to easily monitor and review system activity.
|
||||
|
||||
## Viewing Audit Logs
|
||||
|
||||
The main audit log page displays a table of log entries, with the following columns:
|
||||
|
||||
- **Timestamp:** The date and time of the event.
|
||||
- **Actor:** The identifier of the user or system process that performed the action.
|
||||
- **IP Address:** The IP address from which the action was initiated.
|
||||
- **Action:** The type of action performed, displayed as a color-coded badge for easy identification.
|
||||
- **Target Type:** The type of resource that was affected.
|
||||
- **Target ID:** The unique identifier of the affected resource.
|
||||
- **Details:** A truncated preview of the event's details. The full JSON object is displayed in a pop-up card on hover.
|
||||
|
||||
## Filtering and Sorting
|
||||
|
||||
The table can be sorted by timestamp by clicking the "Timestamp" header. This allows you to view the logs in either chronological or reverse chronological order.
|
||||
|
||||
## Pagination
|
||||
|
||||
Pagination controls are available below the table, allowing you to navigate through the entire history of audit log entries.
|
||||
|
||||
## Verifying Log Integrity
|
||||
|
||||
The "Verify Log Integrity" button allows you to initiate a verification process to check the integrity of the entire audit log chain. This process recalculates the hash of each log entry and compares it to the stored hash, ensuring that the cryptographic chain is unbroken and no entries have been tampered with.
|
||||
|
||||
### Verification Responses
|
||||
|
||||
- **Success:** A success notification is displayed, confirming that the audit log integrity has been verified successfully. This means that the log chain is complete and no entries have been tampered with.
|
||||
|
||||
- **Failure:** An error notification is displayed, indicating that the audit log chain is broken or an entry has been tampered with. The notification will include the ID of the log entry where the issue was detected. There are two types of failures:
|
||||
- **Audit log chain is broken:** This means that the `previousHash` of a log entry does not match the `currentHash` of the preceding entry. This indicates that one or more log entries may have been deleted or inserted into the chain.
|
||||
- **Audit log entry is tampered!:** This means that the recalculated hash of a log entry does not match its stored `currentHash`. This indicates that the data within the log entry has been altered.
|
||||
|
||||
## Viewing Log Details
|
||||
|
||||
You can view the full details of any log entry by clicking on its row in the table. This will open a dialog containing all the information associated with the log entry, including the previous and current hashes.
|
||||
27
docs/enterprise/audit-log/index.md
Normal file
27
docs/enterprise/audit-log/index.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Audit Log
|
||||
|
||||
The Audit Log is an enterprise-grade feature designed to provide a complete, immutable, and verifiable record of every significant action that occurs within the Open Archiver system. Its primary purpose is to ensure compliance with strict regulatory standards, such as the German GoBD, by establishing a tamper-proof chain of evidence for all activities.
|
||||
|
||||
## Core Principles
|
||||
|
||||
To fulfill its compliance and security functions, the audit log adheres to the following core principles:
|
||||
|
||||
### 1. Immutability
|
||||
|
||||
Every log entry is cryptographically chained to the previous one. Each new entry contains a SHA-256 hash of the preceding entry's hash, creating a verifiable chain. Any attempt to alter or delete a past entry would break this chain and be immediately detectable through the verification process.
|
||||
|
||||
### 2. Completeness
|
||||
|
||||
The system is designed to log every significant event without exception. This includes not only user-initiated actions (like logins, searches, and downloads) but also automated system processes, such as data ingestion and policy-based deletions.
|
||||
|
||||
### 3. Attribution
|
||||
|
||||
Each log entry is unambiguously linked to the actor that initiated the event. This could be a specific authenticated user, an external auditor, or an automated system process. The actor's identifier and source IP address are recorded to ensure full traceability.
|
||||
|
||||
### 4. Clarity and Detail
|
||||
|
||||
Log entries are structured to be detailed and human-readable, providing sufficient context for an auditor to understand the event without needing specialized system knowledge. This includes the action performed, the target resource affected, and a JSON object with specific, contextual details of the event.
|
||||
|
||||
### 5. Verifiability
|
||||
|
||||
The integrity of the entire audit log can be verified at any time. A dedicated process iterates through the logs from the beginning, recalculating the hash of each entry and comparing it to the stored hash, ensuring the cryptographic chain is unbroken and no entries have been tampered with.
|
||||
@@ -1,141 +0,0 @@
|
||||
# IAM Policies Guide
|
||||
|
||||
This document provides a comprehensive guide to the Identity and Access Management (IAM) policies in Open Archiver. Our policy structure is inspired by AWS IAM, providing a powerful and flexible way to manage permissions.
|
||||
|
||||
## 1. Policy Structure
|
||||
|
||||
A policy is a JSON object that consists of one or more statements. Each statement includes an `Effect`, `Action`, and `Resource`.
|
||||
|
||||
```json
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["archive:read", "archive:search"],
|
||||
"Resource": ["archive/all"]
|
||||
}
|
||||
```
|
||||
|
||||
- **`Effect`**: Specifies whether the statement results in an `Allow` or `Deny`. An explicit `Deny` always overrides an `Allow`.
|
||||
- **`Action`**: A list of operations that the policy grants or denies permission to perform. Actions are formatted as `service:operation`.
|
||||
- **`Resource`**: A list of resources to which the actions apply. Resources are specified in a hierarchical format. Wildcards (`*`) can be used.
|
||||
|
||||
## 2. Wildcard Support
|
||||
|
||||
Our IAM system supports wildcards (`*`) in both `Action` and `Resource` fields to provide flexible permission management, as defined in the `PolicyValidator`.
|
||||
|
||||
### Action Wildcards
|
||||
|
||||
You can use wildcards to grant broad permissions for actions:
|
||||
|
||||
- **Global Wildcard (`*`)**: A standalone `*` in the `Action` field grants permission for all possible actions across all services.
|
||||
```json
|
||||
"Action": ["*"]
|
||||
```
|
||||
- **Service-Level Wildcard (`service:*`)**: A wildcard at the end of an action string grants permission for all actions within that specific service.
|
||||
```json
|
||||
"Action": ["archive:*"]
|
||||
```
|
||||
|
||||
### Resource Wildcards
|
||||
|
||||
Wildcards can also be used to specify resources:
|
||||
|
||||
- **Global Wildcard (`*`)**: A standalone `*` in the `Resource` field applies the policy to all resources in the system.
|
||||
```json
|
||||
"Resource": ["*"]
|
||||
```
|
||||
- **Partial Wildcards**: Some services allow wildcards at specific points in the resource path to refer to all resources of a certain type. For example, to target all ingestion sources:
|
||||
```json
|
||||
"Resource": ["ingestion-source/*"]
|
||||
```
|
||||
|
||||
## 3. Actions and Resources by Service
|
||||
|
||||
The following sections define the available actions and resources, categorized by their respective services.
|
||||
|
||||
### Service: `archive`
|
||||
|
||||
The `archive` service pertains to all actions related to accessing and managing archived emails.
|
||||
|
||||
**Actions:**
|
||||
|
||||
| Action | Description |
|
||||
| :--------------- | :--------------------------------------------------------------------- |
|
||||
| `archive:read` | Grants permission to read the content and metadata of archived emails. |
|
||||
| `archive:search` | Grants permission to perform search queries against the email archive. |
|
||||
| `archive:export` | Grants permission to export search results or individual emails. |
|
||||
|
||||
**Resources:**
|
||||
|
||||
| Resource | Description |
|
||||
| :------------------------------------ | :--------------------------------------------------------------------------------------- |
|
||||
| `archive/all` | Represents the entire email archive. |
|
||||
| `archive/ingestion-source/{sourceId}` | Scopes the action to emails from a specific ingestion source. |
|
||||
| `archive/mailbox/{email}` | Scopes the action to a single, specific mailbox, usually identified by an email address. |
|
||||
| `archive/custodian/{custodianId}` | Scopes the action to emails belonging to a specific custodian. |
|
||||
|
||||
---
|
||||
|
||||
### Service: `ingestion`
|
||||
|
||||
The `ingestion` service covers the management of email ingestion sources.
|
||||
|
||||
**Actions:**
|
||||
|
||||
| Action | Description |
|
||||
| :----------------------- | :--------------------------------------------------------------------------- |
|
||||
| `ingestion:createSource` | Grants permission to create a new ingestion source. |
|
||||
| `ingestion:readSource` | Grants permission to view the details of ingestion sources. |
|
||||
| `ingestion:updateSource` | Grants permission to modify the configuration of an ingestion source. |
|
||||
| `ingestion:deleteSource` | Grants permission to delete an ingestion source. |
|
||||
| `ingestion:manageSync` | Grants permission to trigger, pause, or force a sync on an ingestion source. |
|
||||
|
||||
**Resources:**
|
||||
|
||||
| Resource | Description |
|
||||
| :---------------------------- | :-------------------------------------------------------- |
|
||||
| `ingestion-source/*` | Represents all ingestion sources. |
|
||||
| `ingestion-source/{sourceId}` | Scopes the action to a single, specific ingestion source. |
|
||||
|
||||
---
|
||||
|
||||
### Service: `system`
|
||||
|
||||
The `system` service is for managing system-level settings, users, and roles.
|
||||
|
||||
**Actions:**
|
||||
|
||||
| Action | Description |
|
||||
| :---------------------- | :-------------------------------------------------- |
|
||||
| `system:readSettings` | Grants permission to view system settings. |
|
||||
| `system:updateSettings` | Grants permission to modify system settings. |
|
||||
| `system:readUsers` | Grants permission to list and view user accounts. |
|
||||
| `system:createUser` | Grants permission to create new user accounts. |
|
||||
| `system:updateUser` | Grants permission to modify existing user accounts. |
|
||||
| `system:deleteUser` | Grants permission to delete user accounts. |
|
||||
| `system:assignRole` | Grants permission to assign roles to users. |
|
||||
|
||||
**Resources:**
|
||||
|
||||
| Resource | Description |
|
||||
| :--------------------- | :---------------------------------------------------- |
|
||||
| `system/settings` | Represents the system configuration. |
|
||||
| `system/users` | Represents all user accounts within the system. |
|
||||
| `system/user/{userId}` | Scopes the action to a single, specific user account. |
|
||||
|
||||
---
|
||||
|
||||
### Service: `dashboard`
|
||||
|
||||
The `dashboard` service relates to viewing analytics and overview information.
|
||||
|
||||
**Actions:**
|
||||
|
||||
| Action | Description |
|
||||
| :--------------- | :-------------------------------------------------------------- |
|
||||
| `dashboard:read` | Grants permission to view all dashboard widgets and statistics. |
|
||||
|
||||
**Resources:**
|
||||
|
||||
| Resource | Description |
|
||||
| :------------ | :------------------------------------------ |
|
||||
| `dashboard/*` | Represents all components of the dashboard. |
|
||||
289
docs/services/iam-service/iam-policy.md
Normal file
289
docs/services/iam-service/iam-policy.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# IAM Policy
|
||||
|
||||
This document provides a guide to creating and managing IAM policies in Open Archiver. It is intended for developers and administrators who need to configure granular access control for users and roles.
|
||||
|
||||
## Policy Structure
|
||||
|
||||
IAM policies are defined as an array of JSON objects, where each object represents a single permission rule. The structure of a policy object is as follows:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "read" OR ["read", "create"],
|
||||
"subject": "ingestion" OR ["ingestion", "dashboard"],
|
||||
"conditions": {
|
||||
"field_name": "value"
|
||||
},
|
||||
"inverted": false OR true,
|
||||
}
|
||||
```
|
||||
|
||||
- `action`: The action(s) to be performed on the subject. Can be a single string or an array of strings.
|
||||
- `subject`: The resource(s) or entity on which the action is to be performed. Can be a single string or an array of strings.
|
||||
- `conditions`: (Optional) A set of conditions that must be met for the permission to be granted.
|
||||
- `inverted`: (Optional) When set to `true`, this inverts the rule, turning it from a "can" rule into a "cannot" rule. This is useful for creating exceptions to broader permissions.
|
||||
|
||||
## Actions
|
||||
|
||||
The following actions are available for use in IAM policies:
|
||||
|
||||
- `manage`: A wildcard action that grants all permissions on a subject (`create`, `read`, `update`, `delete`, `search`, `sync`).
|
||||
- `create`: Allows the user to create a new resource.
|
||||
- `read`: Allows the user to view a resource.
|
||||
- `update`: Allows the user to modify an existing resource.
|
||||
- `delete`: Allows the user to delete a resource.
|
||||
- `search`: Allows the user to search for resources.
|
||||
- `sync`: Allows the user to synchronize a resource.
|
||||
|
||||
## Subjects
|
||||
|
||||
The following subjects are available for use in IAM policies:
|
||||
|
||||
- `all`: A wildcard subject that represents all resources.
|
||||
- `archive`: Represents archived emails.
|
||||
- `ingestion`: Represents ingestion sources.
|
||||
- `settings`: Represents system settings.
|
||||
- `users`: Represents user accounts.
|
||||
- `roles`: Represents user roles.
|
||||
- `dashboard`: Represents the dashboard.
|
||||
|
||||
## Advanced Conditions with MongoDB-Style Queries
|
||||
|
||||
Conditions are the key to creating fine-grained access control rules. They are defined as a JSON object where each key represents a field on the subject, and the value defines the criteria for that field.
|
||||
|
||||
All conditions within a single rule are implicitly joined with an **AND** logic. This means that for a permission to be granted, the resource must satisfy _all_ specified conditions.
|
||||
|
||||
The power of this system comes from its use of a subset of [MongoDB's query language](https://www.mongodb.com/docs/manual/), which provides a flexible and expressive way to define complex rules. These rules are translated into native queries for both the PostgreSQL database (via Drizzle ORM) and the Meilisearch engine.
|
||||
|
||||
### Supported Operators and Examples
|
||||
|
||||
Here is a detailed breakdown of the supported operators with examples.
|
||||
|
||||
#### `$eq` (Equal)
|
||||
|
||||
This is the default operator. If you provide a simple key-value pair, it is treated as an equality check.
|
||||
|
||||
```json
|
||||
// This rule...
|
||||
{ "status": "active" }
|
||||
|
||||
// ...is equivalent to this:
|
||||
{ "status": { "$eq": "active" } }
|
||||
```
|
||||
|
||||
**Use Case**: Grant access to an ingestion source only if its status is `active`.
|
||||
|
||||
#### `$ne` (Not Equal)
|
||||
|
||||
Matches documents where the field value is not equal to the specified value.
|
||||
|
||||
```json
|
||||
{ "provider": { "$ne": "pst_import" } }
|
||||
```
|
||||
|
||||
**Use Case**: Allow a user to see all ingestion sources except for PST imports.
|
||||
|
||||
#### `$in` (In Array)
|
||||
|
||||
Matches documents where the field value is one of the values in the specified array.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": {
|
||||
"$in": ["INGESTION_ID_1", "INGESTION_ID_2"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use Case**: Grant an auditor access to a specific list of ingestion sources.
|
||||
|
||||
#### `$nin` (Not In Array)
|
||||
|
||||
Matches documents where the field value is not one of the values in the specified array.
|
||||
|
||||
```json
|
||||
{ "provider": { "$nin": ["pst_import", "eml_import"] } }
|
||||
```
|
||||
|
||||
**Use Case**: Hide all manual import sources from a specific user role.
|
||||
|
||||
#### `$lt` / `$lte` (Less Than / Less Than or Equal)
|
||||
|
||||
Matches documents where the field value is less than (`$lt`) or less than or equal to (`$lte`) the specified value. This is useful for numeric or date-based comparisons.
|
||||
|
||||
```json
|
||||
{ "sentAt": { "$lt": "2024-01-01T00:00:00.000Z" } }
|
||||
```
|
||||
|
||||
#### `$gt` / `$gte` (Greater Than / Greater Than or Equal)
|
||||
|
||||
Matches documents where the field value is greater than (`$gt`) or greater than or equal to (`$gte`) the specified value.
|
||||
|
||||
```json
|
||||
{ "sentAt": { "$lt": "2024-01-01T00:00:00.000Z" } }
|
||||
```
|
||||
|
||||
#### `$exists`
|
||||
|
||||
Matches documents that have (or do not have) the specified field.
|
||||
|
||||
```json
|
||||
// Grant access only if a 'lastSyncStatusMessage' exists
|
||||
{ "lastSyncStatusMessage": { "$exists": true } }
|
||||
```
|
||||
|
||||
## Inverted Rules: Creating Exceptions with `cannot`
|
||||
|
||||
By default, all rules are "can" rules, meaning they grant permissions. However, you can create a "cannot" rule by adding `"inverted": true` to a policy object. This is extremely useful for creating exceptions to broader permissions.
|
||||
|
||||
A common pattern is to grant broad access and then use an inverted rule to carve out a specific restriction.
|
||||
|
||||
**Use Case**: Grant a user access to all ingestion sources _except_ for one specific source.
|
||||
|
||||
This is achieved with two rules:
|
||||
|
||||
1. A "can" rule that grants `read` access to the `ingestion` subject.
|
||||
2. An inverted "cannot" rule that denies `read` access for the specific ingestion `id`.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": "read",
|
||||
"subject": "ingestion"
|
||||
},
|
||||
{
|
||||
"inverted": true,
|
||||
"action": "read",
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"id": "SPECIFIC_INGESTION_ID_TO_EXCLUDE"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Policy Evaluation Logic
|
||||
|
||||
The system evaluates policies by combining all relevant rules for a user. The logic is simple:
|
||||
|
||||
- A user has permission if at least one `can` rule allows it.
|
||||
- A permission is denied if a `cannot` (`"inverted": true`) rule explicitly forbids it, even if a `can` rule allows it. `cannot` rules always take precedence.
|
||||
|
||||
### Dynamic Policies with Placeholders
|
||||
|
||||
To create dynamic policies that are specific to the current user, you can use the `${user.id}` placeholder in the `conditions` object. This placeholder will be replaced with the ID of the current user at runtime.
|
||||
|
||||
## Special Permissions for User and Role Management
|
||||
|
||||
It is important to note that while `read` access to `users` and `roles` can be granted granularly, any actions that modify these resources (`create`, `update`, `delete`) are restricted to Super Admins.
|
||||
|
||||
A user must have the `{ "action": "manage", "subject": "all" }` permission (Typically a Super Admin role) to manage users and roles. This is a security measure to prevent unauthorized changes to user accounts and permissions.
|
||||
|
||||
## Policy Examples
|
||||
|
||||
Here are several examples based on the default roles in the system, demonstrating how to combine actions, subjects, and conditions to achieve specific access control scenarios.
|
||||
|
||||
### Administrator
|
||||
|
||||
This policy grants a user full access to all resources using wildcards.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "all"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### End-User
|
||||
|
||||
This policy allows a user to view the dashboard, create new ingestion sources, and fully manage the ingestion sources they own.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": "read",
|
||||
"subject": "dashboard"
|
||||
},
|
||||
{
|
||||
"action": "create",
|
||||
"subject": "ingestion"
|
||||
},
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"userId": "${user.id}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "archive",
|
||||
"conditions": {
|
||||
"ingestionSource.userId": "${user.id}" // also needs to give permission to archived emails created by the user
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Global Read-Only Auditor
|
||||
|
||||
This policy grants read and search access across most of the application's resources, making it suitable for an auditor who needs to view data without modifying it.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": ["read", "search"],
|
||||
"subject": ["ingestion", "archive", "dashboard", "users", "roles"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Ingestion Admin
|
||||
|
||||
This policy grants full control over all ingestion sources and archives, but no other resources.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "ingestion"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Auditor for Specific Ingestion Sources
|
||||
|
||||
This policy demonstrates how to grant access to a specific list of ingestion sources using the `$in` operator.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": ["read", "search"],
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"id": {
|
||||
"$in": ["INGESTION_ID_1", "INGESTION_ID_2"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Limit Access to a Specific Mailbox
|
||||
|
||||
This policy grants a user access to a specific ingestion source, but only allows them to see emails belonging to a single user within that source.
|
||||
|
||||
This is achieved by defining two specific `can` rules: The rule grants `read` and `search` access to the `archive` subject, but the `userEmail` must match.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": ["read", "search"],
|
||||
"subject": "archive",
|
||||
"conditions": {
|
||||
"userEmail": "user1@example.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
96
docs/services/ocr-service.md
Normal file
96
docs/services/ocr-service.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# OCR Service
|
||||
|
||||
The OCR (Optical Character Recognition) and text extraction service is responsible for extracting plain text content from various file formats, such as PDFs, Office documents, and more. This is a crucial component for making email attachments searchable.
|
||||
|
||||
## Overview
|
||||
|
||||
The system employs a two-pronged approach for text extraction:
|
||||
|
||||
1. **Primary Extractor (Apache Tika)**: A powerful and versatile toolkit that can extract text from a wide variety of file formats. It is the recommended method for its superior performance and format support.
|
||||
2. **Legacy Extractor**: A fallback mechanism that uses a combination of libraries (`pdf2json`, `mammoth`, `xlsx`) for common file types like PDF, DOCX, and XLSX. This is used when Apache Tika is not configured.
|
||||
|
||||
The main logic resides in `packages/backend/src/helpers/textExtractor.ts`, which decides which extraction method to use based on the application's configuration.
|
||||
|
||||
## Configuration
|
||||
|
||||
To enable the primary text extraction method, you must configure the URL of an Apache Tika server instance in your environment variables.
|
||||
|
||||
In your `.env` file, set the `TIKA_URL`:
|
||||
|
||||
```env
|
||||
# .env.example
|
||||
|
||||
# Apache Tika Integration
|
||||
# ONLY active if TIKA_URL is set
|
||||
TIKA_URL=http://tika:9998
|
||||
```
|
||||
|
||||
If `TIKA_URL` is not set, the system will automatically fall back to the legacy extraction methods. The service performs a health check on startup to verify connectivity with the Tika server.
|
||||
|
||||
## File Size Limits
|
||||
|
||||
To prevent excessive memory usage and processing time, the service imposes a general size limit on files submitted for text extraction. Files larger than the configured limit will be skipped.
|
||||
|
||||
- **With Apache Tika**: The maximum file size is **100MB**.
|
||||
- **With Legacy Fallback**: The maximum file size is **50MB**.
|
||||
|
||||
## Supported File Formats
|
||||
|
||||
The service's ability to extract text depends on whether it's using Apache Tika or the legacy fallback methods.
|
||||
|
||||
### With Apache Tika
|
||||
|
||||
When `TIKA_URL` is configured, the service can process a vast range of file formats. Apache Tika is designed for broad compatibility and supports hundreds of file types, including but not limited to:
|
||||
|
||||
- Portable Document Format (PDF)
|
||||
- Microsoft Office formats (DOC, DOCX, PPT, PPTX, XLS, XLSX)
|
||||
- OpenDocument Formats (ODT, ODS, ODP)
|
||||
- Rich Text Format (RTF)
|
||||
- Plain Text (TXT, CSV, JSON, XML, HTML)
|
||||
- Image formats with OCR capabilities (PNG, JPEG, TIFF)
|
||||
- Archive formats (ZIP, TAR, GZ)
|
||||
- Email formats (EML, MSG)
|
||||
|
||||
For a complete and up-to-date list, please refer to the official [Apache Tika documentation](https://tika.apache.org/3.2.3/formats.html).
|
||||
|
||||
### With Legacy Fallback
|
||||
|
||||
When Tika is not configured, text extraction is limited to the following formats:
|
||||
|
||||
- `application/pdf` (PDF)
|
||||
- `application/vnd.openxmlformats-officedocument.wordprocessingml.document` (DOCX)
|
||||
- `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (XLSX)
|
||||
- Plain text formats such as `text/*`, `application/json`, and `application/xml`.
|
||||
|
||||
## Features of the Tika Integration (`OcrService`)
|
||||
|
||||
The `OcrService` (`packages/backend/src/services/OcrService.ts`) provides several enhancements to make text extraction efficient and robust.
|
||||
|
||||
### Caching
|
||||
|
||||
To avoid redundant processing of the same file, the service implements a simple LRU (Least Recently Used) cache.
|
||||
|
||||
- **Cache Key**: A SHA-256 hash of the file's buffer is used as the cache key.
|
||||
- **Functionality**: If a file with the same hash is processed again, the text content is served directly from the cache, saving significant processing time.
|
||||
- **Statistics**: The service keeps track of cache hits, misses, and the hit rate for performance monitoring.
|
||||
|
||||
### Concurrency Management (Semaphore)
|
||||
|
||||
Extracting text from large files can be resource-intensive. To prevent the Tika server from being overwhelmed by multiple requests for the _same file_ simultaneously (e.g., during a large import), a semaphore mechanism is used.
|
||||
|
||||
- **Functionality**: If a request for a specific file (identified by its hash) is already in progress, any subsequent requests for the same file will wait for the first one to complete and then use its result.
|
||||
- **Benefit**: This deduplicates parallel processing efforts and reduces unnecessary load on the Tika server.
|
||||
|
||||
### Health Check and DNS Fallback
|
||||
|
||||
- **Availability Check**: The service includes a `checkTikaAvailability` method to verify that the Tika server is reachable and operational. This check is performed on application startup.
|
||||
- **DNS Fallback**: For convenience in Docker environments, if the Tika URL uses the hostname `tika` (e.g., `http://tika:9998`), the service will automatically attempt a fallback to `localhost` if the initial connection fails.
|
||||
|
||||
## Legacy Fallback Methods
|
||||
|
||||
When Tika is not available, the `extractTextLegacy` function in `textExtractor.ts` handles extraction for a limited set of MIME types:
|
||||
|
||||
- `application/pdf`: Processed using `pdf2json`. Includes a 50MB size limit and a 5-second timeout to prevent memory issues.
|
||||
- `application/vnd.openxmlformats-officedocument.wordprocessingml.document` (DOCX): Processed using `mammoth`.
|
||||
- `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (XLSX): Processed using `xlsx`.
|
||||
- Plain text formats (`text/*`, `application/json`, `application/xml`): Converted directly from the buffer.
|
||||
@@ -30,7 +30,14 @@ archive.zip
|
||||
2. Click the **Create New** button.
|
||||
3. Select **EML Import** as the provider.
|
||||
4. Enter a name for the ingestion source.
|
||||
5. Click the **Choose File** button and select the zip archive containing your EML files.
|
||||
5. **Choose Import Method:**
|
||||
* **Upload File:** Click **Choose File** and select the zip archive containing your EML files. (Best for smaller archives)
|
||||
* **Local Path:** Enter the path to the zip file **inside the container**. (Best for large archives)
|
||||
|
||||
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
|
||||
> * **Recommended:** Place your zip file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.zip` and enter `/data/temp/emails.zip` as the path.
|
||||
> * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
|
||||
|
||||
6. Click the **Submit** button.
|
||||
|
||||
OpenArchiver will then start importing the EML files from the zip archive. The ingestion process may take some time, depending on the size of the archive.
|
||||
|
||||
@@ -9,3 +9,4 @@ Choose your provider from the list below to get started:
|
||||
- [Generic IMAP Server](./imap.md)
|
||||
- [EML Import](./eml.md)
|
||||
- [PST Import](./pst.md)
|
||||
- [Mbox Import](./mbox.md)
|
||||
|
||||
35
docs/user-guides/email-providers/mbox.md
Normal file
35
docs/user-guides/email-providers/mbox.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Mbox Ingestion
|
||||
|
||||
Mbox is a common format for storing email messages. This guide will walk you through the process of ingesting mbox files into OpenArchiver.
|
||||
|
||||
## 1. Exporting from Your Email Client
|
||||
|
||||
Most email clients that support mbox exports will allow you to export a folder of emails as a single `.mbox` file. Here are the general steps:
|
||||
|
||||
- **Mozilla Thunderbird**: Right-click on a folder, select **ImportExportTools NG**, and then choose **Export folder**.
|
||||
- **Gmail**: You can use Google Takeout to export your emails in mbox format.
|
||||
- **Other Clients**: Refer to your email client's documentation for instructions on how to export emails to an mbox file.
|
||||
|
||||
## 2. Uploading to OpenArchiver
|
||||
|
||||
Once you have your `.mbox` file, you can upload it to OpenArchiver through the web interface.
|
||||
|
||||
1. Navigate to the **Ingestion** page.
|
||||
2. Click on the **New Ingestion** button.
|
||||
3. Select **Mbox** as the source type.
|
||||
4. **Choose Import Method:**
|
||||
* **Upload File:** Upload your `.mbox` file.
|
||||
* **Local Path:** Enter the path to the mbox file **inside the container**.
|
||||
|
||||
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
|
||||
> * **Recommended:** Place your mbox file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.mbox` and enter `/data/temp/emails.mbox` as the path.
|
||||
> * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
|
||||
|
||||
## 3. Folder Structure
|
||||
|
||||
OpenArchiver will attempt to preserve the original folder structure of your emails. This is done by inspecting the following email headers:
|
||||
|
||||
- `X-Gmail-Labels`: Used by Gmail to store labels.
|
||||
- `X-Folder`: A custom header used by some email clients like Thunderbird.
|
||||
|
||||
If neither of these headers is present, the emails will be ingested into the root of the archive.
|
||||
@@ -15,7 +15,14 @@ To ensure a successful import, you should prepare your PST file according to the
|
||||
2. Click the **Create New** button.
|
||||
3. Select **PST Import** as the provider.
|
||||
4. Enter a name for the ingestion source.
|
||||
5. Click the **Choose File** button and select the PST file.
|
||||
5. **Choose Import Method:**
|
||||
* **Upload File:** Click **Choose File** and select the PST file from your computer. (Best for smaller files)
|
||||
* **Local Path:** Enter the path to the PST file **inside the container**. (Best for large files)
|
||||
|
||||
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
|
||||
> * **Recommended:** Place your file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/archive.pst` and enter `/data/temp/archive.pst` as the path.
|
||||
> * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
|
||||
|
||||
6. Click the **Submit** button.
|
||||
|
||||
OpenArchiver will then start importing the emails from the PST file. The ingestion process may take some time, depending on the size of the file.
|
||||
|
||||
@@ -17,7 +17,22 @@ git clone https://github.com/LogicLabs-OU/OpenArchiver.git
|
||||
cd OpenArchiver
|
||||
```
|
||||
|
||||
## 2. Configure Your Environment
|
||||
## 2. Create a Directory for Local Storage (Important)
|
||||
|
||||
Before configuring the application, you **must** create a directory on your host machine where Open Archiver will store its data (such as emails and attachments). Manually creating this directory helps prevent potential permission issues.
|
||||
|
||||
Foe examples, you can use this path `/var/data/open-archiver`.
|
||||
|
||||
Run the following commands to create the directory and set the correct permissions:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/data/open-archiver
|
||||
sudo chown -R $(id -u):$(id -g) /var/data/open-archiver
|
||||
```
|
||||
|
||||
This ensures the directory is owned by your current user, which is necessary for the application to have write access. You will set this path in your `.env` file in the next step.
|
||||
|
||||
## 3. Configure Your Environment
|
||||
|
||||
The application is configured using environment variables. You'll need to create a `.env` file to store your configuration.
|
||||
|
||||
@@ -29,9 +44,15 @@ cp .env.example.docker .env
|
||||
|
||||
Now, open the `.env` file in a text editor and customize the settings.
|
||||
|
||||
### Important Configuration
|
||||
### Key Configuration Steps
|
||||
|
||||
You must change the following placeholder values to secure your instance:
|
||||
1. **Set the Storage Path**: Find the `STORAGE_LOCAL_ROOT_PATH` variable and set it to the path you just created.
|
||||
|
||||
```env
|
||||
STORAGE_LOCAL_ROOT_PATH=/var/data/open-archiver
|
||||
```
|
||||
|
||||
2. **Secure Your Instance**: You must change the following placeholder values to secure your instance:
|
||||
|
||||
- `POSTGRES_PASSWORD`: A strong, unique password for the database.
|
||||
- `REDIS_PASSWORD`: A strong, unique password for the Valkey/Redis service.
|
||||
@@ -41,6 +62,10 @@ You must change the following placeholder values to secure your instance:
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
- `STORAGE_ENCRYPTION_KEY`: **(Optional but Recommended)** A 32-byte hex string for encrypting emails and attachments at rest. If this key is not provided, storage encryption will be disabled. You can generate one with:
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
### Storage Configuration
|
||||
|
||||
@@ -65,29 +90,34 @@ Here is a complete list of environment variables available for configuration:
|
||||
|
||||
#### Application Settings
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `NODE_ENV` | The application environment. | `development` |
|
||||
| `PORT_BACKEND` | The port for the backend service. | `4000` |
|
||||
| `PORT_FRONTEND` | The port for the frontend service. | `3000` |
|
||||
| `SYNC_FREQUENCY` | The frequency of continuous email syncing. See [cron syntax](https://crontab.guru/) for more details. | `* * * * *` |
|
||||
| Variable | Description | Default Value |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------- |
|
||||
| `NODE_ENV` | The application environment. | `development` |
|
||||
| `PORT_BACKEND` | The port for the backend service. | `4000` |
|
||||
| `PORT_FRONTEND` | The port for the frontend service. | `3000` |
|
||||
| `APP_URL` | The public-facing URL of your application. This is used by the backend to configure CORS. | `http://localhost:3000` |
|
||||
| `ORIGIN` | Used by the SvelteKit Node adapter to determine the server's public-facing URL. It should always be set to the value of `APP_URL` (e.g., `ORIGIN=$APP_URL`). | `http://localhost:3000` |
|
||||
| `SYNC_FREQUENCY` | The frequency of continuous email syncing. See [cron syntax](https://crontab.guru/) for more details. | `* * * * *` |
|
||||
| `ALL_INCLUSIVE_ARCHIVE` | Set to `true` to include all emails, including Junk and Trash folders, in the email archive. | `false` |
|
||||
|
||||
#### Docker Compose Service Configuration
|
||||
|
||||
These variables are used by `docker-compose.yml` to configure the services.
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| ------------------- | ----------------------------------------------- | -------------------------------------------------------- |
|
||||
| `POSTGRES_DB` | The name of the PostgreSQL database. | `open_archive` |
|
||||
| `POSTGRES_USER` | The username for the PostgreSQL database. | `admin` |
|
||||
| `POSTGRES_PASSWORD` | The password for the PostgreSQL database. | `password` |
|
||||
| `DATABASE_URL` | The connection URL for the PostgreSQL database. | `postgresql://admin:password@postgres:5432/open_archive` |
|
||||
| `MEILI_MASTER_KEY` | The master key for Meilisearch. | `aSampleMasterKey` |
|
||||
| `MEILI_HOST` | The host for the Meilisearch service. | `http://meilisearch:7700` |
|
||||
| `REDIS_HOST` | The host for the Valkey (Redis) service. | `valkey` |
|
||||
| `REDIS_PORT` | The port for the Valkey (Redis) service. | `6379` |
|
||||
| `REDIS_PASSWORD` | The password for the Valkey (Redis) service. | `defaultredispassword` |
|
||||
| `REDIS_TLS_ENABLED` | Enable or disable TLS for Redis. | `false` |
|
||||
| Variable | Description | Default Value |
|
||||
| ---------------------- | ---------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `POSTGRES_DB` | The name of the PostgreSQL database. | `open_archive` |
|
||||
| `POSTGRES_USER` | The username for the PostgreSQL database. | `admin` |
|
||||
| `POSTGRES_PASSWORD` | The password for the PostgreSQL database. | `password` |
|
||||
| `DATABASE_URL` | The connection URL for the PostgreSQL database. | `postgresql://admin:password@postgres:5432/open_archive` |
|
||||
| `MEILI_MASTER_KEY` | The master key for Meilisearch. | `aSampleMasterKey` |
|
||||
| `MEILI_HOST` | The host for the Meilisearch service. | `http://meilisearch:7700` |
|
||||
| `MEILI_INDEXING_BATCH` | The number of emails to batch together for indexing. | `500` |
|
||||
| `REDIS_HOST` | The host for the Valkey (Redis) service. | `valkey` |
|
||||
| `REDIS_PORT` | The port for the Valkey (Redis) service. | `6379` |
|
||||
| `REDIS_USER` | Optional Redis username if ACLs are used. | |
|
||||
| `REDIS_PASSWORD` | The password for the Valkey (Redis) service. | `defaultredispassword` |
|
||||
| `REDIS_TLS_ENABLED` | Enable or disable TLS for Redis. | `false` |
|
||||
|
||||
#### Storage Settings
|
||||
|
||||
@@ -95,24 +125,34 @@ These variables are used by `docker-compose.yml` to configure the services.
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------- |
|
||||
| `STORAGE_TYPE` | The storage backend to use (`local` or `s3`). | `local` |
|
||||
| `BODY_SIZE_LIMIT` | The maximum request body size for uploads. Can be a number in bytes or a string with a unit (e.g., `100M`). | `100M` |
|
||||
| `STORAGE_LOCAL_ROOT_PATH` | The root path for local file storage. | `/var/data/open-archiver` |
|
||||
| `STORAGE_LOCAL_ROOT_PATH` | The root path for Open Archiver app data. | `/var/data/open-archiver` |
|
||||
| `STORAGE_S3_ENDPOINT` | The endpoint for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
|
||||
| `STORAGE_S3_BUCKET` | The bucket name for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
|
||||
| `STORAGE_S3_ACCESS_KEY_ID` | The access key ID for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
|
||||
| `STORAGE_S3_SECRET_ACCESS_KEY` | The secret access key for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
|
||||
| `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` |
|
||||
| `STORAGE_ENCRYPTION_KEY` | A 32-byte hex string for AES-256 encryption of files at rest. If not set, files will not be encrypted. | |
|
||||
|
||||
#### 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 |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
|
||||
| `ENABLE_DELETION` | Enable or disable deletion of emails and ingestion sources. If this option is not set, or is set to any value other than `true`, deletion will be disabled for the entire instance. | `false` |
|
||||
| `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
|
||||
#### Apache Tika Integration
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ |
|
||||
| `TIKA_URL` | Optional. The URL of an Apache Tika server for advanced text extraction from attachments. If not set, the application falls back to built-in parsers for PDF, Word, and Excel files. | `http://tika:9998` |
|
||||
|
||||
## 4. Run the Application
|
||||
|
||||
Once you have configured your `.env` file, you can start all the services using Docker Compose:
|
||||
|
||||
@@ -132,13 +172,15 @@ You can check the status of the running containers with:
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
## 4. Access the Application
|
||||
## 5. Access the Application
|
||||
|
||||
Once the services are running, you can access the Open Archiver web interface by navigating to `http://localhost:3000` in your web browser.
|
||||
|
||||
You can log in with the `ADMIN_EMAIL` and `ADMIN_PASSWORD` you configured in your `.env` file.
|
||||
Upon first visit, you will be redirected to the `/setup` page where you can set up your admin account. Make sure you are the first person who accesses the instance.
|
||||
|
||||
## 5. Next Steps
|
||||
If you are not redirected to the `/setup` page but instead see the login page, there might be something wrong with the database. Restart the service and try again.
|
||||
|
||||
## 6. Next Steps
|
||||
|
||||
After successfully deploying and logging into Open Archiver, the next step is to configure your ingestion sources to start archiving emails.
|
||||
|
||||
@@ -210,9 +252,9 @@ If you are using local storage to store your emails, based on your `docker-compo
|
||||
|
||||
Run this command to see all the volumes on your system:
|
||||
|
||||
```bash
|
||||
docker volume ls
|
||||
```
|
||||
```bash
|
||||
docker volume ls
|
||||
```
|
||||
|
||||
2. **Identify the correct volume**:
|
||||
|
||||
@@ -222,28 +264,28 @@ Look through the list for a volume name that ends with `_archiver-data`. The par
|
||||
|
||||
Once you've identified the correct volume name, use it in the `inspect` command. For example:
|
||||
|
||||
```bash
|
||||
docker volume inspect <your_volume_name_here>
|
||||
```
|
||||
```bash
|
||||
docker volume inspect <your_volume_name_here>
|
||||
```
|
||||
|
||||
This will give you the correct `Mountpoint` path where your data is being stored. It will look something like this (the exact path will vary depending on your system):
|
||||
|
||||
```json
|
||||
{
|
||||
"CreatedAt": "2025-07-25T11:22:19Z",
|
||||
"Driver": "local",
|
||||
"Labels": {
|
||||
"com.docker.compose.config-hash": "---",
|
||||
"com.docker.compose.project": "---",
|
||||
"com.docker.compose.version": "2.38.2",
|
||||
"com.docker.compose.volume": "us8wwos0o4ok4go4gc8cog84_archiver-data"
|
||||
},
|
||||
"Mountpoint": "/var/lib/docker/volumes/us8wwos0o4ok4go4gc8cog84_archiver-data/_data",
|
||||
"Name": "us8wwos0o4ok4go4gc8cog84_archiver-data",
|
||||
"Options": null,
|
||||
"Scope": "local"
|
||||
}
|
||||
```
|
||||
```json
|
||||
{
|
||||
"CreatedAt": "2025-07-25T11:22:19Z",
|
||||
"Driver": "local",
|
||||
"Labels": {
|
||||
"com.docker.compose.config-hash": "---",
|
||||
"com.docker.compose.project": "---",
|
||||
"com.docker.compose.version": "2.38.2",
|
||||
"com.docker.compose.volume": "us8wwos0o4ok4go4gc8cog84_archiver-data"
|
||||
},
|
||||
"Mountpoint": "/var/lib/docker/volumes/us8wwos0o4ok4go4gc8cog84_archiver-data/_data",
|
||||
"Name": "us8wwos0o4ok4go4gc8cog84_archiver-data",
|
||||
"Options": null,
|
||||
"Scope": "local"
|
||||
}
|
||||
```
|
||||
|
||||
In this example, the data is located at `/var/lib/docker/volumes/us8wwos0o4ok4go4gc8cog84_archiver-data/_data`. You can then `cd` into that directory to see your files.
|
||||
|
||||
@@ -257,43 +299,43 @@ Here’s how you can do it:
|
||||
|
||||
Open the `docker-compose.yml` file and find the `open-archiver` service. You're going to change the `volumes` section.
|
||||
|
||||
**Change this:**
|
||||
**Change this:**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
open-archiver:
|
||||
# ... other config
|
||||
volumes:
|
||||
- archiver-data:/var/data/open-archiver
|
||||
```
|
||||
```yaml
|
||||
services:
|
||||
open-archiver:
|
||||
# ... other config
|
||||
volumes:
|
||||
- archiver-data:/var/data/open-archiver
|
||||
```
|
||||
|
||||
**To this:**
|
||||
**To this:**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
open-archiver:
|
||||
# ... other config
|
||||
volumes:
|
||||
- ./data/open-archiver:/var/data/open-archiver
|
||||
```
|
||||
```yaml
|
||||
services:
|
||||
open-archiver:
|
||||
# ... other config
|
||||
volumes:
|
||||
- ./data/open-archiver:/var/data/open-archiver
|
||||
```
|
||||
|
||||
You'll also want to remove the `archiver-data` volume definition at the bottom of the file, since it's no longer needed.
|
||||
|
||||
**Remove this whole block:**
|
||||
**Remove this whole block:**
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
# ... other volumes
|
||||
archiver-data:
|
||||
driver: local
|
||||
```
|
||||
```yaml
|
||||
volumes:
|
||||
# ... other volumes
|
||||
archiver-data:
|
||||
driver: local
|
||||
```
|
||||
|
||||
2. **Restart your containers**:
|
||||
|
||||
After you've saved the changes, run the following command in your terminal to apply them. The `--force-recreate` flag will ensure the container is recreated with the new volume settings.
|
||||
|
||||
```bash
|
||||
docker-compose up -d --force-recreate
|
||||
```
|
||||
```bash
|
||||
docker-compose up -d --force-recreate
|
||||
```
|
||||
|
||||
After this, any new data will be saved directly into the `./data/open-archiver` folder in your project directory.
|
||||
|
||||
37
docs/user-guides/integrity-check.md
Normal file
37
docs/user-guides/integrity-check.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Integrity Check
|
||||
|
||||
Open Archiver allows you to verify the integrity of your archived emails and their attachments. This guide explains how the integrity check works and what the results mean.
|
||||
|
||||
## How It Works
|
||||
|
||||
When an email is archived, Open Archiver calculates a unique cryptographic signature (a SHA256 hash) for the email's raw `.eml` file and for each of its attachments. These signatures are stored in the database alongside the email's metadata.
|
||||
|
||||
The integrity check feature recalculates these signatures for the stored files and compares them to the original signatures stored in the database. This process allows you to verify that the content of your archived emails has not been altered, corrupted, or tampered with since the moment they were archived.
|
||||
|
||||
## The Integrity Report
|
||||
|
||||
When you view an email in the Open Archiver interface, an integrity report is automatically generated and displayed. This report provides a clear, at-a-glance status for the email file and each of its attachments.
|
||||
|
||||
### Statuses
|
||||
|
||||
- **Valid (Green Badge):** A "Valid" status means that the current signature of the file matches the original signature stored in the database. This is the expected status and indicates that the file's integrity is intact.
|
||||
|
||||
- **Invalid (Red Badge):** An "Invalid" status means that the current signature of the file does _not_ match the original signature. This indicates that the file's content has changed since it was archived.
|
||||
|
||||
### Reasons for an "Invalid" Status
|
||||
|
||||
If a file is marked as "Invalid," you can hover over the badge to see a reason for the failure. Common reasons include:
|
||||
|
||||
- **Stored hash does not match current hash:** This is the most common reason and indicates that the file's content has been modified. This could be due to accidental changes, data corruption, or unauthorized tampering.
|
||||
|
||||
- **Could not read attachment file from storage:** This message indicates that the file could not be read from its storage location. This could be due to a storage system issue, a file permission problem, or because the file has been deleted.
|
||||
|
||||
## What to Do If an Integrity Check Fails
|
||||
|
||||
If you encounter an "Invalid" status for an email or attachment, it is important to investigate the issue. Here are some steps you can take:
|
||||
|
||||
1. **Check Storage:** Verify that the file exists in its storage location and that its permissions are correct.
|
||||
2. **Review Audit Logs:** If you have audit logging enabled, review the logs for any unauthorized access or modifications to the file.
|
||||
3. **Restore from Backup:** If you suspect data corruption, you may need to restore the affected file from a backup.
|
||||
|
||||
The integrity check feature is a crucial tool for ensuring the long-term reliability and trustworthiness of your email archive. By regularly monitoring the integrity of your archived data, you can be confident that your records are accurate and complete.
|
||||
32
docs/user-guides/settings/system.md
Normal file
32
docs/user-guides/settings/system.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# System Settings
|
||||
|
||||
System settings allow administrators to configure the global look and theme of the application. These settings apply to all users.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Language
|
||||
|
||||
This setting determines the default display language for the application UI. The selected language will be used for all interface elements, including menus, labels, and messages.
|
||||
|
||||
> **Important:** When the language is changed, the backend (API) language will only change after a restart of the server. The frontend will update immediately.
|
||||
|
||||
Supported languages:
|
||||
|
||||
- English
|
||||
- German
|
||||
- French
|
||||
- Estonian
|
||||
- Spanish
|
||||
- Italian
|
||||
- Portuguese
|
||||
- Dutch
|
||||
- Greek
|
||||
- Japanese
|
||||
|
||||
### Default Theme
|
||||
|
||||
This setting controls the default color theme for the application. Users can choose between light, dark, or system default. The system default theme will sync with the user's operating system theme.
|
||||
|
||||
### Support Email
|
||||
|
||||
This setting allows administrators to provide a public-facing email address for user support inquiries. This email address may be displayed on error pages or in other areas where users may need to contact support.
|
||||
75
docs/user-guides/troubleshooting/cors-errors.md
Normal file
75
docs/user-guides/troubleshooting/cors-errors.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Troubleshooting CORS Errors
|
||||
|
||||
Cross-Origin Resource Sharing (CORS) is a security feature that controls how web applications in one domain can request and interact with resources in another. If not configured correctly, you may encounter errors when performing actions like uploading files.
|
||||
|
||||
This guide will help you diagnose and resolve common CORS-related issues.
|
||||
|
||||
## Symptoms
|
||||
|
||||
You may be experiencing a CORS issue if you see one of the following errors in your browser's developer console or in the application's logs:
|
||||
|
||||
- `TypeError: fetch failed`
|
||||
- `Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource.`
|
||||
- `Unexpected token 'C', "Cross-site"... is not valid JSON`
|
||||
- A JSON error response similar to the following:
|
||||
```json
|
||||
{
|
||||
"message": "CORS Error: This origin is not allowed.",
|
||||
"requiredOrigin": "http://localhost:3000",
|
||||
"receivedOrigin": "https://localhost:3000"
|
||||
}
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
These errors typically occur when the URL you are using to access the application in your browser does not exactly match the `APP_URL` configured in your `.env` file.
|
||||
|
||||
This can happen for several reasons:
|
||||
|
||||
- You are accessing the application via a different port.
|
||||
- You are using a reverse proxy that changes the protocol (e.g., from `http` to `https`).
|
||||
- The SvelteKit server, in a production build, is incorrectly guessing its public-facing URL.
|
||||
|
||||
## Solution
|
||||
|
||||
The solution is to ensure that the application's frontend and backend are correctly configured with the public-facing URL of your instance. This is done by setting two environment variables: `APP_URL` and `ORIGIN`.
|
||||
|
||||
1. **Open your `.env` file** in a text editor.
|
||||
|
||||
2. **Set `APP_URL`**: Define the `APP_URL` variable with the exact URL you use to access the application in your browser.
|
||||
|
||||
```env
|
||||
APP_URL=http://your-domain-or-ip:3000
|
||||
```
|
||||
|
||||
3. **Set `ORIGIN`**: The SvelteKit server requires a specific `ORIGIN` variable to correctly identify itself. This should always be set to the value of your `APP_URL`.
|
||||
|
||||
```env
|
||||
ORIGIN=$APP_URL
|
||||
```
|
||||
|
||||
By using `$APP_URL`, you ensure that both variables are always in sync.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
If you are running the application locally on port `3000`, your configuration should look like this:
|
||||
|
||||
```env
|
||||
APP_URL=http://localhost:3000
|
||||
ORIGIN=$APP_URL
|
||||
```
|
||||
|
||||
If your application is behind a reverse proxy and is accessible at `https://archive.mycompany.com`, your configuration should be:
|
||||
|
||||
```env
|
||||
APP_URL=https://archive.mycompany.com
|
||||
ORIGIN=$APP_URL
|
||||
```
|
||||
|
||||
After making these changes to your `.env` file, you must restart the application for them to take effect:
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate
|
||||
```
|
||||
|
||||
This will ensure that the backend's CORS policy and the frontend server's origin are correctly aligned, resolving the errors.
|
||||
141
docs/user-guides/upgrade-and-migration/meilisearch-upgrade.md
Normal file
141
docs/user-guides/upgrade-and-migration/meilisearch-upgrade.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Upgrading Meilisearch
|
||||
|
||||
Meilisearch, the search engine used by Open Archiver, requires a manual data migration process when upgrading to a new version. This is because Meilisearch databases are only compatible with the specific version that created them.
|
||||
|
||||
If an Open Archiver upgrade includes a major Meilisearch version change, you will need to migrate your search index by following the process below.
|
||||
|
||||
## Experimental: Dumpless Upgrade
|
||||
|
||||
> **Warning:** This feature is currently **experimental**. We do not recommend using it for production environments until it is marked as stable. Please use the [standard migration process](#standard-migration-process-recommended) instead. Proceed with caution.
|
||||
|
||||
Meilisearch recently introduced an experimental "dumpless" upgrade method. This allows you to migrate the database to a new Meilisearch version without manually creating and importing a dump. However, please note that **dumpless upgrades are not currently atomic**. If the process fails, your database may become corrupted, resulting in data loss.
|
||||
|
||||
**Prerequisite: Create a Snapshot**
|
||||
|
||||
Before attempting a dumpless upgrade, you **must** take a snapshot of your instance. This ensures you have a recovery point if the upgrade fails. Learn how to create snapshots in the [official Meilisearch documentation](https://www.meilisearch.com/docs/learn/data_backup/snapshots).
|
||||
|
||||
### How to Enable
|
||||
|
||||
To perform a dumpless upgrade, you need to configure your Meilisearch instance with the experimental flag. You can do this in one of two ways:
|
||||
|
||||
**Option 1: Using an Environment Variable**
|
||||
|
||||
Add the `MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE` environment variable to your `docker-compose.yml` file for the Meilisearch service.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.x # The new version you want to upgrade to
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
- MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE=true
|
||||
```
|
||||
|
||||
**Option 2: Using a CLI Option**
|
||||
|
||||
Alternatively, you can pass the `--experimental-dumpless-upgrade` flag in the command section of your `docker-compose.yml`.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.x # The new version you want to upgrade to
|
||||
command: meilisearch --experimental-dumpless-upgrade
|
||||
```
|
||||
|
||||
After updating your configuration, restart your container:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Meilisearch will attempt to migrate your database to the new version automatically.
|
||||
|
||||
---
|
||||
|
||||
## Standard Migration Process (Recommended)
|
||||
|
||||
For self-hosted instances using Docker Compose, the recommended migration process involves creating a data dump from your current Meilisearch instance, upgrading the Docker image, and then importing that dump into the new version.
|
||||
|
||||
### Step 1: Create a Dump
|
||||
|
||||
Before upgrading, you must create a dump of your existing Meilisearch data. You can do this by sending a POST request to the `/dumps` endpoint of the Meilisearch API.
|
||||
|
||||
1. **Find your Meilisearch container name**:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Look for the service name that corresponds to Meilisearch, usually `meilisearch`.
|
||||
|
||||
2. **Execute the dump command**:
|
||||
You will need your Meilisearch Admin API key, which can be found in your `.env` file as `MEILI_MASTER_KEY`.
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:7700/dumps' \
|
||||
-H "Authorization: Bearer YOUR_MEILI_MASTER_KEY"
|
||||
```
|
||||
|
||||
This will start the dump creation process. The dump file will be created inside the `meili_data` volume used by the Meilisearch container.
|
||||
|
||||
3. **Monitor the dump status**:
|
||||
The dump creation request returns a `taskUid`. You can use this to check the status of the dump.
|
||||
|
||||
For more details on dump and import, see the [official Meilisearch documentation](https://www.meilisearch.com/docs/learn/update_and_migration/updating).
|
||||
|
||||
### Step 2: Upgrade Your Open Archiver Instance
|
||||
|
||||
Once the dump is successfully created, you can proceed with the standard Open Archiver upgrade process.
|
||||
|
||||
1. **Pull the latest changes and Docker images**:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
2. **Stop the running services**:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Step 3: Import the Dump
|
||||
|
||||
Now, you need to restart the services while telling Meilisearch to import from your dump file.
|
||||
|
||||
1. **Modify `docker-compose.yml`**:
|
||||
You need to temporarily add the `--import-dump` flag to the Meilisearch service command. Find the `meilisearch` service in your `docker-compose.yml` and modify the `command` section.
|
||||
|
||||
You will need the name of your dump file. It will be a `.dump` file located in the directory mapped to `/meili_data` inside the container.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
meilisearch:
|
||||
# ... other service config
|
||||
command:
|
||||
[
|
||||
'--master-key=${MEILI_MASTER_KEY}',
|
||||
'--env=production',
|
||||
'--import-dump=/meili_data/dumps/YOUR_DUMP_FILE.dump',
|
||||
]
|
||||
```
|
||||
|
||||
2. **Restart the services**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Meilisearch will now start and import the data from the dump file. This may take some time depending on the size of your index.
|
||||
|
||||
### Step 4: Clean Up
|
||||
|
||||
Once the import is complete and you have verified that your search is working correctly, you should remove the `--import-dump` flag from your `docker-compose.yml` to prevent it from running on every startup.
|
||||
|
||||
1. **Remove the `--import-dump` line** from the `command` section of the `meilisearch` service in `docker-compose.yml`.
|
||||
2. **Restart the services** one last time:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Your Meilisearch instance is now upgraded and running with your migrated data.
|
||||
|
||||
For more advanced scenarios or troubleshooting, please refer to the **[official Meilisearch migration guide](https://www.meilisearch.com/docs/learn/update_and_migration/updating)**.
|
||||
42
docs/user-guides/upgrade-and-migration/upgrade.md
Normal file
42
docs/user-guides/upgrade-and-migration/upgrade.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Upgrading Your Instance
|
||||
|
||||
This guide provides instructions for upgrading your Open Archiver instance to the latest version.
|
||||
|
||||
## Checking for New Versions
|
||||
|
||||
Open Archiver automatically checks for new versions and will display a notification in the footer of the web interface when an update is available. You can find a list of all releases and their release notes on the [GitHub Releases](https://github.com/LogicLabs-OU/OpenArchiver/releases) page.
|
||||
|
||||
## Upgrading Your Instance
|
||||
|
||||
To upgrade your Open Archiver instance, follow these steps:
|
||||
|
||||
1. **Pull the latest changes from the repository**:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
2. **Pull the latest Docker images**:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
3. **Restart the services with the new images**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This will restart your Open Archiver instance with the latest version of the application.
|
||||
|
||||
## Migrating Data
|
||||
|
||||
When you upgrade to a new version, database migrations are applied automatically when the application starts up. This ensures that your database schema is always up-to-date with the latest version of the application.
|
||||
|
||||
No manual intervention is required for database migrations.
|
||||
|
||||
## Upgrading Meilisearch
|
||||
|
||||
When an Open Archiver update includes a major version change for Meilisearch, you will need to manually migrate your search data. This process is not covered by the standard upgrade commands.
|
||||
|
||||
For detailed instructions, please see the [Meilisearch Upgrade Guide](./meilisearch-upgrade.md).
|
||||
79
open-archiver.yml
Normal file
79
open-archiver.yml
Normal file
@@ -0,0 +1,79 @@
|
||||
# documentation: https://openarchiver.com
|
||||
# slogan: A self-hosted, open-source email archiving solution with full-text search capability.
|
||||
# tags: email archiving,email,compliance,search
|
||||
# logo: svgs/openarchiver.svg
|
||||
# port: 3000
|
||||
|
||||
services:
|
||||
open-archiver:
|
||||
image: logiclabshq/open-archiver:latest
|
||||
environment:
|
||||
- SERVICE_URL_3000
|
||||
- SERVICE_URL=${SERVICE_URL_3000}
|
||||
- PORT_BACKEND=${PORT_BACKEND:-4000}
|
||||
- PORT_FRONTEND=${PORT_FRONTEND:-3000}
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
- SYNC_FREQUENCY=${SYNC_FREQUENCY:-* * * * *}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-open_archive}
|
||||
- POSTGRES_USER=${POSTGRES_USER:-admin}
|
||||
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
- MEILI_MASTER_KEY=${SERVICE_PASSWORD_MEILISEARCH}
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- REDIS_HOST=valkey
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_USER=default
|
||||
- REDIS_PASSWORD=${SERVICE_PASSWORD_VALKEY}
|
||||
- REDIS_TLS_ENABLED=false
|
||||
- STORAGE_TYPE=${STORAGE_TYPE:-local}
|
||||
- STORAGE_LOCAL_ROOT_PATH=${STORAGE_LOCAL_ROOT_PATH:-/var/data/open-archiver}
|
||||
- BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-100M}
|
||||
- STORAGE_S3_ENDPOINT=${STORAGE_S3_ENDPOINT}
|
||||
- STORAGE_S3_BUCKET=${STORAGE_S3_BUCKET}
|
||||
- STORAGE_S3_ACCESS_KEY_ID=${STORAGE_S3_ACCESS_KEY_ID}
|
||||
- STORAGE_S3_SECRET_ACCESS_KEY=${STORAGE_S3_SECRET_ACCESS_KEY}
|
||||
- STORAGE_S3_REGION=${STORAGE_S3_REGION}
|
||||
- STORAGE_S3_FORCE_PATH_STYLE=${STORAGE_S3_FORCE_PATH_STYLE:-false}
|
||||
- JWT_SECRET=${SERVICE_BASE64_128_JWT}
|
||||
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-7d}
|
||||
- ENCRYPTION_KEY=${SERVICE_BASE64_64_ENCRYPTIONKEY}
|
||||
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000}
|
||||
- RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS:-100}
|
||||
volumes:
|
||||
- archiver-data:/var/data/open-archiver
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_started
|
||||
meilisearch:
|
||||
condition: service_started
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
|
||||
- LC_ALL=C
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
|
||||
interval: 10s
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine
|
||||
command: valkey-server --requirepass ${SERVICE_PASSWORD_VALKEY}
|
||||
volumes:
|
||||
- valkeydata:/data
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.15
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=${SERVICE_PASSWORD_MEILISEARCH}
|
||||
- MEILI_SCHEDULE_SNAPSHOT=86400
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
17
package.json
17
package.json
@@ -1,16 +1,24 @@
|
||||
{
|
||||
"name": "open-archiver",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE file",
|
||||
"scripts": {
|
||||
"dev": "dotenv -- pnpm --filter \"./packages/*\" --parallel dev",
|
||||
"build": "pnpm --filter \"./packages/*\" build",
|
||||
"start": "dotenv -- pnpm --filter \"./packages/*\" --parallel start",
|
||||
"build:oss": "pnpm --filter \"./packages/*\" --filter \"!./packages/enterprise\" --filter \"./apps/open-archiver\" build",
|
||||
"build:enterprise": "cross-env VITE_ENTERPRISE_MODE=true pnpm build",
|
||||
"start:oss": "dotenv -- concurrently \"node apps/open-archiver/dist/index.js\" \"pnpm --filter @open-archiver/frontend start\"",
|
||||
"start:enterprise": "dotenv -- concurrently \"node apps/open-archiver-enterprise/dist/index.js\" \"pnpm --filter @open-archiver/frontend start\"",
|
||||
"dev:enterprise": "cross-env VITE_ENTERPRISE_MODE=true dotenv -- pnpm --filter \"@open-archiver/*\" --filter \"open-archiver-enterprise-app\" --parallel dev",
|
||||
"dev:oss": "dotenv -- pnpm --filter \"./packages/*\" --filter \"!./packages/@open-archiver/enterprise\" --filter \"open-archiver-app\" --parallel dev",
|
||||
"build": "pnpm --filter \"./packages/*\" --filter \"./apps/*\" build",
|
||||
"start": "dotenv -- pnpm --filter \"open-archiver-app\" --parallel start",
|
||||
"start:workers": "dotenv -- concurrently \"pnpm --filter @open-archiver/backend start:ingestion-worker\" \"pnpm --filter @open-archiver/backend start:indexing-worker\" \"pnpm --filter @open-archiver/backend start:sync-scheduler\"",
|
||||
"start:workers:dev": "dotenv -- concurrently \"pnpm --filter @open-archiver/backend start:ingestion-worker:dev\" \"pnpm --filter @open-archiver/backend start:indexing-worker:dev\" \"pnpm --filter @open-archiver/backend start:sync-scheduler:dev\"",
|
||||
"db:generate": "dotenv -- pnpm --filter @open-archiver/backend db:generate",
|
||||
"db:migrate": "dotenv -- pnpm --filter @open-archiver/backend db:migrate",
|
||||
"db:migrate:dev": "dotenv -- pnpm --filter @open-archiver/backend db:migrate:dev",
|
||||
"docker-start": "concurrently \"pnpm start:workers\" \"pnpm start\"",
|
||||
"docker-start:oss": "concurrently \"pnpm start:workers\" \"pnpm start:oss\"",
|
||||
"docker-start:enterprise": "concurrently \"pnpm start:workers\" \"pnpm start:enterprise\"",
|
||||
"docs:dev": "vitepress dev docs --port 3009",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs",
|
||||
@@ -22,6 +30,7 @@
|
||||
"dotenv-cli": "8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
"name": "@open-archiver/backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE file",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts ",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"build": "tsc && pnpm copy-assets",
|
||||
"dev": "tsc --watch",
|
||||
"copy-assets": "cp -r src/locales dist/locales",
|
||||
"start:ingestion-worker": "node dist/workers/ingestion.worker.js",
|
||||
"start:indexing-worker": "node dist/workers/indexing.worker.js",
|
||||
"start:sync-scheduler": "node dist/jobs/schedulers/sync-scheduler.js",
|
||||
@@ -22,6 +24,7 @@
|
||||
"@aws-sdk/client-s3": "^3.844.0",
|
||||
"@aws-sdk/lib-storage": "^3.844.0",
|
||||
"@azure/msal-node": "^3.6.3",
|
||||
"@casl/ability": "^6.7.3",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@open-archiver/types": "workspace:*",
|
||||
"archiver": "^7.0.1",
|
||||
@@ -29,6 +32,7 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"bullmq": "^5.56.3",
|
||||
"busboy": "^1.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"cross-fetch": "^4.1.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"dotenv": "^17.2.0",
|
||||
@@ -39,6 +43,9 @@
|
||||
"express-validator": "^7.2.1",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"googleapis": "^152.0.0",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"i18next-http-middleware": "^3.8.0",
|
||||
"imapflow": "^1.0.191",
|
||||
"jose": "^6.0.11",
|
||||
"mailparser": "^3.7.4",
|
||||
@@ -53,23 +60,22 @@
|
||||
"pst-extractor": "^1.11.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"yauzl": "^3.2.0"
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"yauzl": "^3.2.0",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bull-board/api": "^6.11.0",
|
||||
"@bull-board/express": "^6.11.0",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/microsoft-graph": "^2.40.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.0.12",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"bull-board": "^2.1.3",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
74
packages/backend/src/api/controllers/api-key.controller.ts
Normal file
74
packages/backend/src/api/controllers/api-key.controller.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiKeyService } from '../../services/ApiKeyService';
|
||||
import { z } from 'zod';
|
||||
import { UserService } from '../../services/UserService';
|
||||
|
||||
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 {
|
||||
private userService = new UserService();
|
||||
public generateApiKey = async (req: Request, res: Response) => {
|
||||
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 actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const key = await ApiKeyService.generate(
|
||||
userId,
|
||||
name,
|
||||
expiresInDays,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
|
||||
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 getApiKeys = async (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 deleteApiKey = async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const userId = req.user.sub;
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
await ApiKeyService.deleteKey(id, userId, actor, req.ip || 'unknown');
|
||||
|
||||
res.status(204).send({ message: req.t('apiKeys.deleteSuccess') });
|
||||
};
|
||||
}
|
||||
@@ -1,57 +1,86 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ArchivedEmailService } from '../../services/ArchivedEmailService';
|
||||
import { config } from '../../config';
|
||||
import { UserService } from '../../services/UserService';
|
||||
import { checkDeletionEnabled } from '../../helpers/deletionGuard';
|
||||
|
||||
export class ArchivedEmailController {
|
||||
private userService = new UserService();
|
||||
public getArchivedEmails = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { ingestionSourceId } = req.params;
|
||||
const page = parseInt(req.query.page as string, 10) || 1;
|
||||
const limit = parseInt(req.query.limit as string, 10) || 10;
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
|
||||
const result = await ArchivedEmailService.getArchivedEmails(
|
||||
ingestionSourceId,
|
||||
page,
|
||||
limit
|
||||
limit,
|
||||
userId
|
||||
);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('Get archived emails error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public getArchivedEmailById = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const email = await ArchivedEmailService.getArchivedEmailById(id);
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
|
||||
const email = await ArchivedEmailService.getArchivedEmailById(
|
||||
id,
|
||||
userId,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
if (!email) {
|
||||
return res.status(404).json({ message: 'Archived email not found' });
|
||||
return res.status(404).json({ message: req.t('archivedEmail.notFound') });
|
||||
}
|
||||
return res.status(200).json(email);
|
||||
} catch (error) {
|
||||
console.error(`Get archived email by id ${req.params.id} error:`, error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public deleteArchivedEmail = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
}
|
||||
try {
|
||||
checkDeletionEnabled();
|
||||
const { id } = req.params;
|
||||
await ArchivedEmailService.deleteArchivedEmail(id);
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
await ArchivedEmailService.deleteArchivedEmail(id, actor, req.ip || 'unknown');
|
||||
return res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error(`Delete archived email ${req.params.id} error:`, error);
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Archived email not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('archivedEmail.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
import { UserService } from '../../services/UserService';
|
||||
import { IamService } from '../../services/IamService';
|
||||
import { db } from '../../database';
|
||||
import * as schema from '../../database/schema';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import 'dotenv/config';
|
||||
import { AuthorizationService } from '../../services/AuthorizationService';
|
||||
import { CaslPolicy } from '@open-archiver/types';
|
||||
|
||||
export class AuthController {
|
||||
#authService: AuthService;
|
||||
@@ -24,7 +27,7 @@ export class AuthController {
|
||||
const { email, password, first_name, last_name } = req.body;
|
||||
|
||||
if (!email || !password || !first_name || !last_name) {
|
||||
return res.status(400).json({ message: 'Email, password, and name are required' });
|
||||
return res.status(400).json({ message: req.t('auth.setup.allFieldsRequired') });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -34,18 +37,18 @@ export class AuthController {
|
||||
const userCount = Number(userCountResult[0].count);
|
||||
|
||||
if (userCount > 0) {
|
||||
return res.status(403).json({ message: 'Setup has already been completed.' });
|
||||
return res.status(403).json({ message: req.t('auth.setup.alreadyCompleted') });
|
||||
}
|
||||
|
||||
const newUser = await this.#userService.createAdminUser(
|
||||
{ email, password, first_name, last_name },
|
||||
true
|
||||
);
|
||||
const result = await this.#authService.login(email, password);
|
||||
const result = await this.#authService.login(email, password, req.ip || 'unknown');
|
||||
return res.status(201).json(result);
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,32 +56,60 @@ export class AuthController {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ message: 'Email and password are required' });
|
||||
return res.status(400).json({ message: req.t('auth.login.emailAndPasswordRequired') });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.#authService.login(email, password);
|
||||
const result = await this.#authService.login(email, password, req.ip || 'unknown');
|
||||
|
||||
if (!result) {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
return res.status(401).json({ message: req.t('auth.login.invalidCredentials') });
|
||||
}
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public status = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const userCountResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.users);
|
||||
const userCount = Number(userCountResult[0].count);
|
||||
const needsSetup = userCount === 0;
|
||||
const users = await db.select().from(schema.users);
|
||||
|
||||
/**
|
||||
* Check the situation where the only user has "Super Admin" role, but they don't actually have Super Admin permission because the role was set up in an earlier version, we need to change that "Super Admin" role to the one used in the current version.
|
||||
*/
|
||||
if (users.length === 1) {
|
||||
const iamService = new IamService();
|
||||
const userRoles = await iamService.getRolesForUser(users[0].id);
|
||||
if (userRoles.some((r) => r.name === 'Super Admin')) {
|
||||
const authorizationService = new AuthorizationService();
|
||||
const hasAdminPermission = await authorizationService.can(
|
||||
users[0].id,
|
||||
'manage',
|
||||
'all'
|
||||
);
|
||||
if (!hasAdminPermission) {
|
||||
const suerAdminPolicies: CaslPolicy[] = [
|
||||
{
|
||||
action: 'manage',
|
||||
subject: 'all',
|
||||
},
|
||||
];
|
||||
await db
|
||||
.update(schema.roles)
|
||||
.set({
|
||||
policies: suerAdminPolicies,
|
||||
slug: 'predefined_super_admin',
|
||||
})
|
||||
.where(eq(schema.roles.name, 'Super Admin'));
|
||||
}
|
||||
}
|
||||
}
|
||||
// in case user uses older version with admin user variables, we will create the admin user using those variables.
|
||||
if (needsSetup && process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD) {
|
||||
const needsSetupUser = users.length === 0;
|
||||
if (needsSetupUser && process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD) {
|
||||
await this.#userService.createAdminUser(
|
||||
{
|
||||
email: process.env.ADMIN_EMAIL,
|
||||
@@ -90,10 +121,10 @@ export class AuthController {
|
||||
);
|
||||
return res.status(200).json({ needsSetup: false });
|
||||
}
|
||||
return res.status(200).json({ needsSetup });
|
||||
return res.status(200).json({ needsSetup: needsSetupUser });
|
||||
} catch (error) {
|
||||
console.error('Status check error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { IamService } from '../../services/IamService';
|
||||
import { PolicyValidator } from '../../iam-policy/policy-validator';
|
||||
import type { PolicyStatement } from '@open-archiver/types';
|
||||
import type { CaslPolicy } from '@open-archiver/types';
|
||||
import { logger } from '../../config/logger';
|
||||
|
||||
export class IamController {
|
||||
#iamService: IamService;
|
||||
@@ -12,10 +13,15 @@ export class IamController {
|
||||
|
||||
public getRoles = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const roles = await this.#iamService.getRoles();
|
||||
let roles = await this.#iamService.getRoles();
|
||||
if (!roles.some((r) => r.slug?.includes('predefined_'))) {
|
||||
// create pre defined roles
|
||||
logger.info({}, 'Creating predefined roles');
|
||||
await this.createDefaultRoles();
|
||||
}
|
||||
res.status(200).json(roles);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to get roles.' });
|
||||
res.status(500).json({ message: req.t('iam.failedToGetRoles') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,45 +33,119 @@ export class IamController {
|
||||
if (role) {
|
||||
res.status(200).json(role);
|
||||
} else {
|
||||
res.status(404).json({ error: 'Role not found.' });
|
||||
res.status(404).json({ message: req.t('iam.roleNotFound') });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to get role.' });
|
||||
res.status(500).json({ message: req.t('iam.failedToGetRole') });
|
||||
}
|
||||
};
|
||||
|
||||
public createRole = async (req: Request, res: Response): Promise<void> => {
|
||||
const { name, policy } = req.body;
|
||||
public createRole = async (req: Request, res: Response) => {
|
||||
const { name, policies } = req.body;
|
||||
|
||||
if (!name || !policy) {
|
||||
res.status(400).json({ error: 'Missing required fields: name and policy.' });
|
||||
if (!name || !policies) {
|
||||
res.status(400).json({ message: req.t('iam.missingRoleFields') });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const statement of policy) {
|
||||
const { valid, reason } = PolicyValidator.isValid(statement as PolicyStatement);
|
||||
if (!valid) {
|
||||
res.status(400).json({ error: `Invalid policy statement: ${reason}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const role = await this.#iamService.createRole(name, policy);
|
||||
for (const statement of policies) {
|
||||
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
|
||||
if (!valid) {
|
||||
res.status(400).json({ message: `${req.t('iam.invalidPolicy')} ${reason}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const role = await this.#iamService.createRole(name, policies);
|
||||
res.status(201).json(role);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to create role.' });
|
||||
console.log(error);
|
||||
res.status(500).json({ message: req.t('iam.failedToCreateRole') });
|
||||
}
|
||||
};
|
||||
|
||||
public deleteRole = async (req: Request, res: Response): Promise<void> => {
|
||||
public deleteRole = async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
await this.#iamService.deleteRole(id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete role.' });
|
||||
res.status(500).json({ message: req.t('iam.failedToDeleteRole') });
|
||||
}
|
||||
};
|
||||
|
||||
public updateRole = async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const { name, policies } = req.body;
|
||||
|
||||
if (!name && !policies) {
|
||||
res.status(400).json({ message: req.t('iam.missingUpdateFields') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (policies) {
|
||||
for (const statement of policies) {
|
||||
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
|
||||
if (!valid) {
|
||||
res.status(400).json({ message: `${req.t('iam.invalidPolicy')} ${reason}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const role = await this.#iamService.updateRole(id, { name, policies });
|
||||
res.status(200).json(role);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: req.t('iam.failedToUpdateRole') });
|
||||
}
|
||||
};
|
||||
|
||||
private createDefaultRoles = async () => {
|
||||
try {
|
||||
// end user who can manage its own data, and create new ingestions.
|
||||
await this.#iamService.createRole(
|
||||
'End user',
|
||||
[
|
||||
{
|
||||
action: 'read',
|
||||
subject: 'dashboard',
|
||||
},
|
||||
{
|
||||
action: 'create',
|
||||
subject: 'ingestion',
|
||||
},
|
||||
{
|
||||
action: 'manage',
|
||||
subject: 'ingestion',
|
||||
conditions: {
|
||||
userId: '${user.id}',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: 'manage',
|
||||
subject: 'archive',
|
||||
conditions: {
|
||||
'ingestionSource.userId': '${user.id}',
|
||||
},
|
||||
},
|
||||
],
|
||||
'predefined_end_user'
|
||||
);
|
||||
// read only
|
||||
await this.#iamService.createRole(
|
||||
'Read only',
|
||||
[
|
||||
{
|
||||
action: ['read', 'search'],
|
||||
subject: ['ingestion', 'archive', 'dashboard', 'users', 'roles'],
|
||||
},
|
||||
],
|
||||
'predefined_read_only_user'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({}, 'Failed to create default roles');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
SafeIngestionSource,
|
||||
} from '@open-archiver/types';
|
||||
import { logger } from '../../config/logger';
|
||||
import { config } from '../../config';
|
||||
import { UserService } from '../../services/UserService';
|
||||
import { checkDeletionEnabled } from '../../helpers/deletionGuard';
|
||||
|
||||
export class IngestionController {
|
||||
private userService = new UserService();
|
||||
/**
|
||||
* Converts an IngestionSource object to a safe version for client-side consumption
|
||||
* by removing the credentials.
|
||||
@@ -22,32 +24,45 @@ export class IngestionController {
|
||||
}
|
||||
|
||||
public create = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
}
|
||||
try {
|
||||
const dto: CreateIngestionSourceDto = req.body;
|
||||
const newSource = await IngestionService.create(dto);
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const newSource = await IngestionService.create(
|
||||
dto,
|
||||
userId,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
const safeSource = this.toSafeIngestionSource(newSource);
|
||||
return res.status(201).json(safeSource);
|
||||
} catch (error: any) {
|
||||
logger.error({ err: error }, 'Create ingestion source error');
|
||||
// Return a 400 Bad Request for connection errors
|
||||
return res.status(400).json({
|
||||
message:
|
||||
error.message || 'Failed to create ingestion source due to a connection error.',
|
||||
message: error.message || req.t('ingestion.failedToCreate'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public findAll = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const sources = await IngestionService.findAll();
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const sources = await IngestionService.findAll(userId);
|
||||
const safeSources = sources.map(this.toSafeIngestionSource);
|
||||
return res.status(200).json(safeSources);
|
||||
} catch (error) {
|
||||
console.error('Find all ingestion sources error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,97 +75,127 @@ export class IngestionController {
|
||||
} catch (error) {
|
||||
console.error(`Find ingestion source by id ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public update = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
}
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const dto: UpdateIngestionSourceDto = req.body;
|
||||
const updatedSource = await IngestionService.update(id, dto);
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const updatedSource = await IngestionService.update(
|
||||
id,
|
||||
dto,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
const safeSource = this.toSafeIngestionSource(updatedSource);
|
||||
return res.status(200).json(safeSource);
|
||||
} catch (error) {
|
||||
console.error(`Update ingestion source ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public delete = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
}
|
||||
try {
|
||||
checkDeletionEnabled();
|
||||
const { id } = req.params;
|
||||
await IngestionService.delete(id);
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
await IngestionService.delete(id, actor, req.ip || 'unknown');
|
||||
return res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error(`Delete ingestion source ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
} else if (error instanceof Error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public triggerInitialImport = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
}
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await IngestionService.triggerInitialImport(id);
|
||||
return res.status(202).json({ message: 'Initial import triggered successfully.' });
|
||||
return res.status(202).json({ message: req.t('ingestion.initialImportTriggered') });
|
||||
} catch (error) {
|
||||
console.error(`Trigger initial import for ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public pause = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
}
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updatedSource = await IngestionService.update(id, { status: 'paused' });
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const updatedSource = await IngestionService.update(
|
||||
id,
|
||||
{ status: 'paused' },
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
const safeSource = this.toSafeIngestionSource(updatedSource);
|
||||
return res.status(200).json(safeSource);
|
||||
} catch (error) {
|
||||
console.error(`Pause ingestion source ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public triggerForceSync = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
}
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await IngestionService.triggerForceSync(id);
|
||||
return res.status(202).json({ message: 'Force sync triggered successfully.' });
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
await IngestionService.triggerForceSync(id, actor, req.ip || 'unknown');
|
||||
return res.status(202).json({ message: req.t('ingestion.forceSyncTriggered') });
|
||||
} catch (error) {
|
||||
console.error(`Trigger force sync for ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
29
packages/backend/src/api/controllers/integrity.controller.ts
Normal file
29
packages/backend/src/api/controllers/integrity.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { IntegrityService } from '../../services/IntegrityService';
|
||||
import { z } from 'zod';
|
||||
|
||||
const checkIntegritySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export class IntegrityController {
|
||||
private integrityService = new IntegrityService();
|
||||
|
||||
public checkIntegrity = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = checkIntegritySchema.parse(req.params);
|
||||
const results = await this.integrityService.checkEmailIntegrity(id);
|
||||
res.status(200).json(results);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: req.t('api.requestBodyInvalid'), errors: error.message });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Archived email not found') {
|
||||
return res.status(404).json({ message: req.t('errors.notFound') });
|
||||
}
|
||||
res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
packages/backend/src/api/controllers/jobs.controller.ts
Normal file
42
packages/backend/src/api/controllers/jobs.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { JobsService } from '../../services/JobsService';
|
||||
import {
|
||||
IGetQueueJobsRequestParams,
|
||||
IGetQueueJobsRequestQuery,
|
||||
JobStatus,
|
||||
} from '@open-archiver/types';
|
||||
|
||||
export class JobsController {
|
||||
private jobsService: JobsService;
|
||||
|
||||
constructor() {
|
||||
this.jobsService = new JobsService();
|
||||
}
|
||||
|
||||
public getQueues = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const queues = await this.jobsService.getQueues();
|
||||
res.status(200).json({ queues });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Error fetching queues', error });
|
||||
}
|
||||
};
|
||||
|
||||
public getQueueJobs = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { queueName } = req.params as unknown as IGetQueueJobsRequestParams;
|
||||
const { status, page, limit } = req.query as unknown as IGetQueueJobsRequestQuery;
|
||||
const pageNumber = parseInt(page, 10) || 1;
|
||||
const limitNumber = parseInt(limit, 10) || 10;
|
||||
const queueDetails = await this.jobsService.getQueueDetails(
|
||||
queueName,
|
||||
status,
|
||||
pageNumber,
|
||||
limitNumber
|
||||
);
|
||||
res.status(200).json(queueDetails);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Error fetching queue jobs', error });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -12,22 +12,32 @@ export class SearchController {
|
||||
public search = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { keywords, page, limit, matchingStrategy } = req.query;
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!keywords) {
|
||||
res.status(400).json({ message: 'Keywords are required' });
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await this.searchService.searchEmails({
|
||||
query: keywords as string,
|
||||
page: page ? parseInt(page as string) : 1,
|
||||
limit: limit ? parseInt(limit as string) : 10,
|
||||
matchingStrategy: matchingStrategy as MatchingStrategies,
|
||||
});
|
||||
if (!keywords) {
|
||||
res.status(400).json({ message: req.t('search.keywordsRequired') });
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await this.searchService.searchEmails(
|
||||
{
|
||||
query: keywords as string,
|
||||
page: page ? parseInt(page as string) : 1,
|
||||
limit: limit ? parseInt(limit as string) : 10,
|
||||
matchingStrategy: matchingStrategy as MatchingStrategies,
|
||||
},
|
||||
userId,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
|
||||
res.status(200).json(results);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'An unknown error occurred';
|
||||
const message = error instanceof Error ? error.message : req.t('errors.unknown');
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
};
|
||||
|
||||
38
packages/backend/src/api/controllers/settings.controller.ts
Normal file
38
packages/backend/src/api/controllers/settings.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { SettingsService } from '../../services/SettingsService';
|
||||
import { UserService } from '../../services/UserService';
|
||||
|
||||
const settingsService = new SettingsService();
|
||||
const userService = new UserService();
|
||||
|
||||
export const getSystemSettings = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = await settingsService.getSystemSettings();
|
||||
res.status(200).json(settings);
|
||||
} catch (error) {
|
||||
// A more specific error could be logged here
|
||||
res.status(500).json({ message: req.t('settings.failedToRetrieve') });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSystemSettings = async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Basic validation can be performed here if necessary
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const actor = await userService.findById(req.user.sub);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const updatedSettings = await settingsService.updateSystemSettings(
|
||||
req.body,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
res.status(200).json(updatedSettings);
|
||||
} catch (error) {
|
||||
// A more specific error could be logged here
|
||||
res.status(500).json({ message: req.t('settings.failedToUpdate') });
|
||||
}
|
||||
};
|
||||
@@ -10,7 +10,7 @@ export class StorageController {
|
||||
const unsafePath = req.query.path as string;
|
||||
|
||||
if (!unsafePath) {
|
||||
res.status(400).send('File path is required');
|
||||
res.status(400).send(req.t('storage.filePathRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export class StorageController {
|
||||
const fullPath = path.join(basePath, normalizedPath);
|
||||
|
||||
if (!fullPath.startsWith(basePath)) {
|
||||
res.status(400).send('Invalid file path');
|
||||
res.status(400).send(req.t('storage.invalidFilePath'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class StorageController {
|
||||
try {
|
||||
const fileExists = await this.storageService.exists(safePath);
|
||||
if (!fileExists) {
|
||||
res.status(404).send('File not found');
|
||||
res.status(404).send(req.t('storage.fileNotFound'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export class StorageController {
|
||||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
res.status(500).send('Error downloading file');
|
||||
res.status(500).send(req.t('storage.downloadError'));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { config } from '../../config/index';
|
||||
export const uploadFile = async (req: Request, res: Response) => {
|
||||
const storage = new StorageService();
|
||||
const bb = busboy({ headers: req.headers });
|
||||
const uploads: Promise<void>[] = [];
|
||||
let filePath = '';
|
||||
let originalFilename = '';
|
||||
|
||||
@@ -14,10 +15,11 @@ export const uploadFile = async (req: Request, res: Response) => {
|
||||
originalFilename = filename.filename;
|
||||
const uuid = randomUUID();
|
||||
filePath = `${config.storage.openArchiverFolderName}/tmp/${uuid}-${originalFilename}`;
|
||||
storage.put(filePath, file);
|
||||
uploads.push(storage.put(filePath, file));
|
||||
});
|
||||
|
||||
bb.on('finish', () => {
|
||||
bb.on('finish', async () => {
|
||||
await Promise.all(uploads);
|
||||
res.json({ filePath });
|
||||
});
|
||||
|
||||
|
||||
138
packages/backend/src/api/controllers/user.controller.ts
Normal file
138
packages/backend/src/api/controllers/user.controller.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { UserService } from '../../services/UserService';
|
||||
import * as schema from '../../database/schema';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { db } from '../../database';
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
export const getUsers = async (req: Request, res: Response) => {
|
||||
const users = await userService.findAll();
|
||||
res.json(users);
|
||||
};
|
||||
|
||||
export const getUser = async (req: Request, res: Response) => {
|
||||
const user = await userService.findById(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: req.t('user.notFound') });
|
||||
}
|
||||
res.json(user);
|
||||
};
|
||||
|
||||
export const createUser = async (req: Request, res: Response) => {
|
||||
const { email, first_name, last_name, password, roleId } = req.body;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const actor = await userService.findById(req.user.sub);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const newUser = await userService.createUser(
|
||||
{ email, first_name, last_name, password },
|
||||
roleId,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
res.status(201).json(newUser);
|
||||
};
|
||||
|
||||
export const updateUser = async (req: Request, res: Response) => {
|
||||
const { email, first_name, last_name, roleId } = req.body;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const actor = await userService.findById(req.user.sub);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const updatedUser = await userService.updateUser(
|
||||
req.params.id,
|
||||
{ email, first_name, last_name },
|
||||
roleId,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
if (!updatedUser) {
|
||||
return res.status(404).json({ message: req.t('user.notFound') });
|
||||
}
|
||||
res.json(updatedUser);
|
||||
};
|
||||
|
||||
export const deleteUser = async (req: Request, res: Response) => {
|
||||
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(schema.users);
|
||||
|
||||
const isOnlyUser = Number(userCountResult[0].count) === 1;
|
||||
if (isOnlyUser) {
|
||||
return res.status(400).json({
|
||||
message: req.t('user.cannotDeleteOnlyUser'),
|
||||
});
|
||||
}
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const actor = await userService.findById(req.user.sub);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
await userService.deleteUser(req.params.id, actor, req.ip || 'unknown');
|
||||
res.status(204).send();
|
||||
};
|
||||
|
||||
export const getProfile = async (req: Request, res: Response) => {
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const user = await userService.findById(req.user.sub);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: req.t('user.notFound') });
|
||||
}
|
||||
res.json(user);
|
||||
};
|
||||
|
||||
export const updateProfile = async (req: Request, res: Response) => {
|
||||
const { email, first_name, last_name } = req.body;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const actor = await userService.findById(req.user.sub);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const updatedUser = await userService.updateUser(
|
||||
req.user.sub,
|
||||
{ email, first_name, last_name },
|
||||
undefined,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
res.json(updatedUser);
|
||||
};
|
||||
|
||||
export const updatePassword = async (req: Request, res: Response) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const actor = await userService.findById(req.user.sub);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.updatePassword(
|
||||
req.user.sub,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
res.status(200).json({ message: 'Password updated successfully' });
|
||||
} catch (e: any) {
|
||||
if (e.message === 'Invalid current password') {
|
||||
return res.status(400).json({ message: e.message });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,21 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { rateLimit, ipKeyGenerator } 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,
|
||||
keyGenerator: (req, res) => {
|
||||
// Use the real IP address of the client, even if it's behind a proxy.
|
||||
// `app.set('trust proxy', true)` in `server.ts`.
|
||||
return ipKeyGenerator(req.ip || 'unknown');
|
||||
},
|
||||
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' });
|
||||
|
||||
38
packages/backend/src/api/middleware/requirePermission.ts
Normal file
38
packages/backend/src/api/middleware/requirePermission.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { AuthorizationService } from '../../services/AuthorizationService';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { AppActions, AppSubjects } from '@open-archiver/types';
|
||||
|
||||
export const requirePermission = (
|
||||
action: AppActions,
|
||||
subjectName: AppSubjects,
|
||||
rejectMessage?: string
|
||||
) => {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
let resourceObject = undefined;
|
||||
// Logic to fetch resourceObject if needed for condition-based checks...
|
||||
const authorizationService = new AuthorizationService();
|
||||
const hasPermission = await authorizationService.can(
|
||||
userId,
|
||||
action,
|
||||
subjectName,
|
||||
resourceObject
|
||||
);
|
||||
|
||||
if (!hasPermission) {
|
||||
const message = rejectMessage
|
||||
? req.t(rejectMessage)
|
||||
: req.t('errors.noPermissionToAction');
|
||||
return res.status(403).json({
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
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): Router => {
|
||||
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,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { ArchivedEmailController } from '../controllers/archived-email.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createArchivedEmailRouter = (
|
||||
@@ -12,11 +13,23 @@ export const createArchivedEmailRouter = (
|
||||
// Secure all routes in this module
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/ingestion-source/:ingestionSourceId', archivedEmailController.getArchivedEmails);
|
||||
router.get(
|
||||
'/ingestion-source/:ingestionSourceId',
|
||||
requirePermission('read', 'archive'),
|
||||
archivedEmailController.getArchivedEmails
|
||||
);
|
||||
|
||||
router.get('/:id', archivedEmailController.getArchivedEmailById);
|
||||
router.get(
|
||||
'/:id',
|
||||
requirePermission('read', 'archive'),
|
||||
archivedEmailController.getArchivedEmailById
|
||||
);
|
||||
|
||||
router.delete('/:id', archivedEmailController.deleteArchivedEmail);
|
||||
router.delete(
|
||||
'/:id',
|
||||
requirePermission('delete', 'archive'),
|
||||
archivedEmailController.deleteArchivedEmail
|
||||
);
|
||||
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { dashboardController } from '../controllers/dashboard.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createDashboardRouter = (authService: AuthService): Router => {
|
||||
@@ -8,11 +9,31 @@ export const createDashboardRouter = (authService: AuthService): Router => {
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/stats', dashboardController.getStats);
|
||||
router.get('/ingestion-history', dashboardController.getIngestionHistory);
|
||||
router.get('/ingestion-sources', dashboardController.getIngestionSources);
|
||||
router.get('/recent-syncs', dashboardController.getRecentSyncs);
|
||||
router.get('/indexed-insights', dashboardController.getIndexedInsights);
|
||||
router.get(
|
||||
'/stats',
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getStats
|
||||
);
|
||||
router.get(
|
||||
'/ingestion-history',
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getIngestionHistory
|
||||
);
|
||||
router.get(
|
||||
'/ingestion-sources',
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getIngestionSources
|
||||
);
|
||||
router.get(
|
||||
'/recent-syncs',
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getRecentSyncs
|
||||
);
|
||||
router.get(
|
||||
'/indexed-insights',
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getIndexedInsights
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,36 +1,42 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import type { IamController } from '../controllers/iam.controller';
|
||||
import type { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createIamRouter = (iamController: IamController): Router => {
|
||||
export const createIamRouter = (iamController: IamController, authService: AuthService): Router => {
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/iam/roles
|
||||
* @description Gets all roles.
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/roles', requireAuth, iamController.getRoles);
|
||||
router.get('/roles', requirePermission('read', 'roles'), iamController.getRoles);
|
||||
|
||||
router.get('/roles/:id', requirePermission('read', 'roles'), iamController.getRoleById);
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/iam/roles/:id
|
||||
* @description Gets a role by ID.
|
||||
* @access Private
|
||||
* Only super admin has the ability to modify existing roles or create new roles.
|
||||
*/
|
||||
router.get('/roles/:id', requireAuth, iamController.getRoleById);
|
||||
router.post(
|
||||
'/roles',
|
||||
requirePermission('manage', 'all', 'iam.requiresSuperAdminRole'),
|
||||
iamController.createRole
|
||||
);
|
||||
|
||||
/**
|
||||
* @route POST /api/v1/iam/roles
|
||||
* @description Creates a new role.
|
||||
* @access Private
|
||||
*/
|
||||
router.post('/roles', requireAuth, iamController.createRole);
|
||||
router.delete(
|
||||
'/roles/:id',
|
||||
requirePermission('manage', 'all', 'iam.requiresSuperAdminRole'),
|
||||
iamController.deleteRole
|
||||
);
|
||||
|
||||
/**
|
||||
* @route DELETE /api/v1/iam/roles/:id
|
||||
* @description Deletes a role.
|
||||
* @access Private
|
||||
*/
|
||||
router.delete('/roles/:id', requireAuth, iamController.deleteRole);
|
||||
router.put(
|
||||
'/roles/:id',
|
||||
requirePermission('manage', 'all', 'iam.requiresSuperAdminRole'),
|
||||
iamController.updateRole
|
||||
);
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { IngestionController } from '../controllers/ingestion.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createIngestionRouter = (
|
||||
@@ -12,21 +13,29 @@ export const createIngestionRouter = (
|
||||
// Secure all routes in this module
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.post('/', ingestionController.create);
|
||||
router.post('/', requirePermission('create', 'ingestion'), ingestionController.create);
|
||||
|
||||
router.get('/', ingestionController.findAll);
|
||||
router.get('/', requirePermission('read', 'ingestion'), ingestionController.findAll);
|
||||
|
||||
router.get('/:id', ingestionController.findById);
|
||||
router.get('/:id', requirePermission('read', 'ingestion'), ingestionController.findById);
|
||||
|
||||
router.put('/:id', ingestionController.update);
|
||||
router.put('/:id', requirePermission('update', 'ingestion'), ingestionController.update);
|
||||
|
||||
router.delete('/:id', ingestionController.delete);
|
||||
router.delete('/:id', requirePermission('delete', 'ingestion'), ingestionController.delete);
|
||||
|
||||
router.post('/:id/import', ingestionController.triggerInitialImport);
|
||||
router.post(
|
||||
'/:id/import',
|
||||
requirePermission('create', 'ingestion'),
|
||||
ingestionController.triggerInitialImport
|
||||
);
|
||||
|
||||
router.post('/:id/pause', ingestionController.pause);
|
||||
router.post('/:id/pause', requirePermission('update', 'ingestion'), ingestionController.pause);
|
||||
|
||||
router.post('/:id/sync', ingestionController.triggerForceSync);
|
||||
router.post(
|
||||
'/:id/sync',
|
||||
requirePermission('sync', 'ingestion'),
|
||||
ingestionController.triggerForceSync
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
16
packages/backend/src/api/routes/integrity.routes.ts
Normal file
16
packages/backend/src/api/routes/integrity.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import { IntegrityController } from '../controllers/integrity.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const integrityRoutes = (authService: AuthService): Router => {
|
||||
const router = Router();
|
||||
const controller = new IntegrityController();
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/:id', requirePermission('read', 'archive'), controller.checkIntegrity);
|
||||
|
||||
return router;
|
||||
};
|
||||
25
packages/backend/src/api/routes/jobs.routes.ts
Normal file
25
packages/backend/src/api/routes/jobs.routes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Router } from 'express';
|
||||
import { JobsController } from '../controllers/jobs.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createJobsRouter = (authService: AuthService): Router => {
|
||||
const router = Router();
|
||||
const jobsController = new JobsController();
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get(
|
||||
'/queues',
|
||||
requirePermission('manage', 'all', 'user.requiresSuperAdminRole'),
|
||||
jobsController.getQueues
|
||||
);
|
||||
router.get(
|
||||
'/queues/:queueName',
|
||||
requirePermission('manage', 'all', 'user.requiresSuperAdminRole'),
|
||||
jobsController.getQueueJobs
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { SearchController } from '../controllers/search.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createSearchRouter = (
|
||||
@@ -11,7 +12,7 @@ export const createSearchRouter = (
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/', searchController.search);
|
||||
router.get('/', requirePermission('search', 'archive'), searchController.search);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
25
packages/backend/src/api/routes/settings.routes.ts
Normal file
25
packages/backend/src/api/routes/settings.routes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Router } from 'express';
|
||||
import * as settingsController from '../controllers/settings.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createSettingsRouter = (authService: AuthService): Router => {
|
||||
const router = Router();
|
||||
|
||||
// Public route to get non-sensitive settings. settings read should not be scoped with a permission because all end users need the settings data in the frontend. However, for sensitive settings data, we need to add a new permission subject to limit access. So this route should only expose non-sensitive settings data.
|
||||
/**
|
||||
* @returns SystemSettings
|
||||
*/
|
||||
router.get('/system', settingsController.getSystemSettings);
|
||||
|
||||
// Protected route to update settings
|
||||
router.put(
|
||||
'/system',
|
||||
requireAuth(authService),
|
||||
requirePermission('manage', 'settings', 'settings.noPermissionToUpdate'),
|
||||
settingsController.updateSystemSettings
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { StorageController } from '../controllers/storage.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createStorageRouter = (
|
||||
@@ -12,7 +13,7 @@ export const createStorageRouter = (
|
||||
// Secure all routes in this module
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/download', storageController.downloadFile);
|
||||
router.get('/download', requirePermission('read', 'archive'), storageController.downloadFile);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { ingestionQueue } from '../../jobs/queues';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
export default router;
|
||||
@@ -2,13 +2,14 @@ import { Router } from 'express';
|
||||
import { uploadFile } from '../controllers/upload.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
|
||||
export const createUploadRouter = (authService: AuthService): Router => {
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.post('/', uploadFile);
|
||||
router.post('/', requirePermission('create', 'ingestion'), uploadFile);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
42
packages/backend/src/api/routes/user.routes.ts
Normal file
42
packages/backend/src/api/routes/user.routes.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Router } from 'express';
|
||||
import * as userController from '../controllers/user.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createUserRouter = (authService: AuthService): Router => {
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/', requirePermission('read', 'users'), userController.getUsers);
|
||||
|
||||
router.get('/profile', userController.getProfile);
|
||||
router.patch('/profile', userController.updateProfile);
|
||||
router.post('/profile/password', userController.updatePassword);
|
||||
|
||||
router.get('/:id', requirePermission('read', 'users'), userController.getUser);
|
||||
|
||||
/**
|
||||
* Only super admin has the ability to modify existing users or create new users.
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
requirePermission('manage', 'all', 'user.requiresSuperAdminRole'),
|
||||
userController.createUser
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id',
|
||||
requirePermission('manage', 'all', 'user.requiresSuperAdminRole'),
|
||||
userController.updateUser
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
requirePermission('manage', 'all', 'user.requiresSuperAdminRole'),
|
||||
userController.deleteUser
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
170
packages/backend/src/api/server.ts
Normal file
170
packages/backend/src/api/server.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import express, { Express } from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import { AuthController } from './controllers/auth.controller';
|
||||
import { IngestionController } from './controllers/ingestion.controller';
|
||||
import { ArchivedEmailController } from './controllers/archived-email.controller';
|
||||
import { StorageController } from './controllers/storage.controller';
|
||||
import { SearchController } from './controllers/search.controller';
|
||||
import { IamController } from './controllers/iam.controller';
|
||||
import { createAuthRouter } from './routes/auth.routes';
|
||||
import { createIamRouter } from './routes/iam.routes';
|
||||
import { createIngestionRouter } from './routes/ingestion.routes';
|
||||
import { createArchivedEmailRouter } from './routes/archived-email.routes';
|
||||
import { createStorageRouter } from './routes/storage.routes';
|
||||
import { createSearchRouter } from './routes/search.routes';
|
||||
import { createDashboardRouter } from './routes/dashboard.routes';
|
||||
import { createUploadRouter } from './routes/upload.routes';
|
||||
import { createUserRouter } from './routes/user.routes';
|
||||
import { createSettingsRouter } from './routes/settings.routes';
|
||||
import { apiKeyRoutes } from './routes/api-key.routes';
|
||||
import { integrityRoutes } from './routes/integrity.routes';
|
||||
import { createJobsRouter } from './routes/jobs.routes';
|
||||
import { AuthService } from '../services/AuthService';
|
||||
import { AuditService } from '../services/AuditService';
|
||||
import { UserService } from '../services/UserService';
|
||||
import { IamService } from '../services/IamService';
|
||||
import { StorageService } from '../services/StorageService';
|
||||
import { SearchService } from '../services/SearchService';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import i18next from 'i18next';
|
||||
import FsBackend from 'i18next-fs-backend';
|
||||
import i18nextMiddleware from 'i18next-http-middleware';
|
||||
import path from 'path';
|
||||
import { logger } from '../config/logger';
|
||||
import { rateLimiter } from './middleware/rateLimiter';
|
||||
import { config } from '../config';
|
||||
import { OpenArchiverFeature } from '@open-archiver/types';
|
||||
// Define the "plugin" interface
|
||||
export interface ArchiverModule {
|
||||
initialize: (app: Express, authService: AuthService) => Promise<void>;
|
||||
name: OpenArchiverFeature;
|
||||
}
|
||||
|
||||
export let authService: AuthService;
|
||||
|
||||
export async function createServer(modules: ArchiverModule[] = []): Promise<Express> {
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// --- Environment Variable Validation ---
|
||||
const { JWT_SECRET, JWT_EXPIRES_IN } = process.env;
|
||||
|
||||
if (!JWT_SECRET || !JWT_EXPIRES_IN) {
|
||||
throw new Error(
|
||||
'Missing required environment variables for the backend: JWT_SECRET, JWT_EXPIRES_IN.'
|
||||
);
|
||||
}
|
||||
|
||||
// --- Dependency Injection Setup ---
|
||||
const auditService = new AuditService();
|
||||
const userService = new UserService();
|
||||
authService = new AuthService(userService, auditService, JWT_SECRET, JWT_EXPIRES_IN);
|
||||
const authController = new AuthController(authService, userService);
|
||||
const ingestionController = new IngestionController();
|
||||
const archivedEmailController = new ArchivedEmailController();
|
||||
const storageService = new StorageService();
|
||||
const storageController = new StorageController(storageService);
|
||||
const searchService = new SearchService();
|
||||
const searchController = new SearchController();
|
||||
const iamService = new IamService();
|
||||
const iamController = new IamController(iamService);
|
||||
const settingsService = new SettingsService();
|
||||
|
||||
// --- i18next Initialization ---
|
||||
const initializeI18next = async () => {
|
||||
const systemSettings = await settingsService.getSystemSettings();
|
||||
const defaultLanguage = systemSettings?.language || 'en';
|
||||
logger.info({ language: defaultLanguage }, 'Default language');
|
||||
await i18next.use(FsBackend).init({
|
||||
lng: defaultLanguage,
|
||||
fallbackLng: defaultLanguage,
|
||||
ns: ['translation'],
|
||||
defaultNS: 'translation',
|
||||
backend: {
|
||||
loadPath: path.resolve(__dirname, '../locales/{{lng}}/{{ns}}.json'),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize i18next
|
||||
await initializeI18next();
|
||||
logger.info({}, 'i18next initialized');
|
||||
|
||||
// Configure the Meilisearch index on startup
|
||||
logger.info({}, 'Configuring email index...');
|
||||
await searchService.configureEmailIndex();
|
||||
|
||||
const app = express();
|
||||
|
||||
// --- CORS ---
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.APP_URL || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Trust the proxy to get the real IP address of the client.
|
||||
// This is important for audit logging and security.
|
||||
app.set('trust proxy', true);
|
||||
|
||||
// --- Routes ---
|
||||
const authRouter = createAuthRouter(authController);
|
||||
const ingestionRouter = createIngestionRouter(ingestionController, authService);
|
||||
const archivedEmailRouter = createArchivedEmailRouter(archivedEmailController, authService);
|
||||
const storageRouter = createStorageRouter(storageController, authService);
|
||||
const searchRouter = createSearchRouter(searchController, authService);
|
||||
const dashboardRouter = createDashboardRouter(authService);
|
||||
const iamRouter = createIamRouter(iamController, authService);
|
||||
const uploadRouter = createUploadRouter(authService);
|
||||
const userRouter = createUserRouter(authService);
|
||||
const settingsRouter = createSettingsRouter(authService);
|
||||
const apiKeyRouter = apiKeyRoutes(authService);
|
||||
const integrityRouter = integrityRoutes(authService);
|
||||
const jobsRouter = createJobsRouter(authService);
|
||||
|
||||
// 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 }));
|
||||
|
||||
// i18n middleware
|
||||
app.use(i18nextMiddleware.handle(i18next));
|
||||
|
||||
app.use(`/${config.api.version}/auth`, authRouter);
|
||||
app.use(`/${config.api.version}/iam`, iamRouter);
|
||||
app.use(`/${config.api.version}/upload`, uploadRouter);
|
||||
app.use(`/${config.api.version}/ingestion-sources`, ingestionRouter);
|
||||
app.use(`/${config.api.version}/archived-emails`, archivedEmailRouter);
|
||||
app.use(`/${config.api.version}/storage`, storageRouter);
|
||||
app.use(`/${config.api.version}/search`, searchRouter);
|
||||
app.use(`/${config.api.version}/dashboard`, dashboardRouter);
|
||||
app.use(`/${config.api.version}/users`, userRouter);
|
||||
app.use(`/${config.api.version}/settings`, settingsRouter);
|
||||
app.use(`/${config.api.version}/api-keys`, apiKeyRouter);
|
||||
app.use(`/${config.api.version}/integrity`, integrityRouter);
|
||||
app.use(`/${config.api.version}/jobs`, jobsRouter);
|
||||
|
||||
// Load all provided extension modules
|
||||
for (const module of modules) {
|
||||
await module.initialize(app, authService);
|
||||
console.log(`🏢 Enterprise module loaded: ${module.name}`);
|
||||
}
|
||||
app.get('/', (req, res) => {
|
||||
res.send('Backend is running!!');
|
||||
});
|
||||
|
||||
console.log('✅ Core OSS modules loaded.');
|
||||
|
||||
return app;
|
||||
}
|
||||
13
packages/backend/src/config/api.ts
Normal file
13
packages/backend/src/config/api.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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
|
||||
},
|
||||
version: 'v1',
|
||||
};
|
||||
@@ -4,6 +4,7 @@ export const app = {
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
port: process.env.PORT_BACKEND ? parseInt(process.env.PORT_BACKEND, 10) : 4000,
|
||||
encryptionKey: process.env.ENCRYPTION_KEY,
|
||||
isDemo: process.env.IS_DEMO === 'true',
|
||||
syncFrequency: process.env.SYNC_FREQUENCY || '* * * * *', //default to 1 minute
|
||||
enableDeletion: process.env.ENABLE_DELETION === 'true',
|
||||
allInclusiveArchive: process.env.ALL_INCLUSIVE_ARCHIVE === 'true',
|
||||
};
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { storage } from './storage';
|
||||
import { app } from './app';
|
||||
import { searchConfig } from './search';
|
||||
import { searchConfig, meiliConfig } from './search';
|
||||
import { connection as redisConfig } from './redis';
|
||||
import { apiConfig } from './api';
|
||||
|
||||
export const config = {
|
||||
storage,
|
||||
app,
|
||||
search: searchConfig,
|
||||
meili: meiliConfig,
|
||||
redis: redisConfig,
|
||||
api: apiConfig,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
redact: ['password'],
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import 'dotenv/config';
|
||||
import { type ConnectionOptions } from 'bullmq';
|
||||
|
||||
/**
|
||||
* @see https://github.com/taskforcesh/bullmq/blob/master/docs/gitbook/guide/connections.md
|
||||
*/
|
||||
const connectionOptions: any = {
|
||||
const connectionOptions: ConnectionOptions = {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: (process.env.REDIS_PORT && parseInt(process.env.REDIS_PORT, 10)) || 6379,
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
enableReadyCheck: true,
|
||||
};
|
||||
|
||||
if (process.env.REDIS_USER) {
|
||||
connectionOptions.username = process.env.REDIS_USER;
|
||||
}
|
||||
|
||||
if (process.env.REDIS_TLS_ENABLED === 'true') {
|
||||
connectionOptions.tls = {
|
||||
rejectUnauthorized: false,
|
||||
|
||||
@@ -4,3 +4,9 @@ export const searchConfig = {
|
||||
host: process.env.MEILI_HOST || 'http://127.0.0.1:7700',
|
||||
apiKey: process.env.MEILI_MASTER_KEY || '',
|
||||
};
|
||||
|
||||
export const meiliConfig = {
|
||||
indexingBatchSize: process.env.MEILI_INDEXING_BATCH
|
||||
? parseInt(process.env.MEILI_INDEXING_BATCH)
|
||||
: 500,
|
||||
};
|
||||
|
||||
@@ -2,9 +2,14 @@ import { StorageConfig } from '@open-archiver/types';
|
||||
import 'dotenv/config';
|
||||
|
||||
const storageType = process.env.STORAGE_TYPE;
|
||||
const encryptionKey = process.env.STORAGE_ENCRYPTION_KEY;
|
||||
const openArchiverFolderName = 'open-archiver';
|
||||
let storageConfig: StorageConfig;
|
||||
|
||||
if (encryptionKey && !/^[a-fA-F0-9]{64}$/.test(encryptionKey)) {
|
||||
throw new Error('STORAGE_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)');
|
||||
}
|
||||
|
||||
if (storageType === 'local') {
|
||||
if (!process.env.STORAGE_LOCAL_ROOT_PATH) {
|
||||
throw new Error('STORAGE_LOCAL_ROOT_PATH is not defined in the environment variables');
|
||||
@@ -13,6 +18,7 @@ if (storageType === 'local') {
|
||||
type: 'local',
|
||||
rootPath: process.env.STORAGE_LOCAL_ROOT_PATH,
|
||||
openArchiverFolderName: openArchiverFolderName,
|
||||
encryptionKey: encryptionKey,
|
||||
};
|
||||
} else if (storageType === 's3') {
|
||||
if (
|
||||
@@ -32,6 +38,7 @@ if (storageType === 'local') {
|
||||
region: process.env.STORAGE_S3_REGION,
|
||||
forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true',
|
||||
openArchiverFolderName: openArchiverFolderName,
|
||||
encryptionKey: encryptionKey,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Invalid STORAGE_TYPE: ${storageType}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import 'dotenv/config';
|
||||
|
||||
@@ -12,3 +12,4 @@ if (!process.env.DATABASE_URL) {
|
||||
const connectionString = encodeDatabaseUrl(process.env.DATABASE_URL);
|
||||
const client = postgres(connectionString);
|
||||
export const db = drizzle(client, { schema });
|
||||
export type Database = PostgresJsDatabase<typeof schema>;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "ingestion_sources" ADD COLUMN "user_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "ingestion_sources" ADD CONSTRAINT "ingestion_sources_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "roles" ADD COLUMN "slug" text;--> statement-breakpoint
|
||||
ALTER TABLE "roles" ADD CONSTRAINT "roles_slug_unique" UNIQUE("slug");
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE "system_settings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"config" jsonb NOT NULL
|
||||
);
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."ingestion_provider" ADD VALUE 'mbox_import';
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TYPE "public"."audit_log_action" AS ENUM('CREATE', 'READ', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT', 'SETUP', 'IMPORT', 'PAUSE', 'SYNC', 'UPLOAD', 'SEARCH', 'DOWNLOAD', 'GENERATE');--> statement-breakpoint
|
||||
CREATE TYPE "public"."audit_log_target_type" AS ENUM('ApiKey', 'ArchivedEmail', 'Dashboard', 'IngestionSource', 'Role', 'SystemSettings', 'User', 'File');--> statement-breakpoint
|
||||
ALTER TABLE "audit_logs" ALTER COLUMN "target_type" SET DATA TYPE "public"."audit_log_target_type" USING "target_type"::"public"."audit_log_target_type";--> statement-breakpoint
|
||||
ALTER TABLE "audit_logs" ADD COLUMN "previous_hash" varchar(64);--> statement-breakpoint
|
||||
ALTER TABLE "audit_logs" ADD COLUMN "actor_ip" text;--> statement-breakpoint
|
||||
ALTER TABLE "audit_logs" ADD COLUMN "action_type" "audit_log_action" NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "audit_logs" ADD COLUMN "current_hash" varchar(64) NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "audit_logs" DROP COLUMN "action";--> statement-breakpoint
|
||||
ALTER TABLE "audit_logs" DROP COLUMN "is_tamper_evident";
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "attachments" DROP CONSTRAINT "attachments_content_hash_sha256_unique";--> statement-breakpoint
|
||||
ALTER TABLE "attachments" ADD COLUMN "ingestion_source_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_ingestion_source_id_ingestion_sources_id_fk" FOREIGN KEY ("ingestion_source_id") REFERENCES "public"."ingestion_sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "source_hash_unique" ON "attachments" USING btree ("ingestion_source_id","content_hash_sha256");
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX "source_hash_unique";--> statement-breakpoint
|
||||
CREATE INDEX "source_hash_idx" ON "attachments" USING btree ("ingestion_source_id","content_hash_sha256");
|
||||
1067
packages/backend/src/database/migrations/meta/0015_snapshot.json
Normal file
1067
packages/backend/src/database/migrations/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1078
packages/backend/src/database/migrations/meta/0016_snapshot.json
Normal file
1078
packages/backend/src/database/migrations/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1103
packages/backend/src/database/migrations/meta/0017_snapshot.json
Normal file
1103
packages/backend/src/database/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
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
1178
packages/backend/src/database/migrations/meta/0020_snapshot.json
Normal file
1178
packages/backend/src/database/migrations/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1225
packages/backend/src/database/migrations/meta/0021_snapshot.json
Normal file
1225
packages/backend/src/database/migrations/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1257
packages/backend/src/database/migrations/meta/0022_snapshot.json
Normal file
1257
packages/backend/src/database/migrations/meta/0022_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1257
packages/backend/src/database/migrations/meta/0023_snapshot.json
Normal file
1257
packages/backend/src/database/migrations/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user