mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44fc0e15de | ||
|
|
ef891b20c3 | ||
|
|
a6216c2ce6 | ||
|
|
1c58b516dd | ||
|
|
ae916574de | ||
|
|
14fb3368a3 | ||
|
|
f49929dd67 | ||
|
|
97143d55b8 | ||
|
|
e666f76d87 | ||
|
|
7fb6575f8d | ||
|
|
fb0be8c5d1 | ||
|
|
455e6b1a75 | ||
|
|
75cae51be9 | ||
|
|
62d956c7d6 | ||
|
|
c01872284e | ||
|
|
2887b5d16d | ||
|
|
558ea2f9b0 | ||
|
|
b07defa2d5 | ||
|
|
76ab16b55b | ||
|
|
06a126461b | ||
|
|
a02bb65ca0 | ||
|
|
1f57f372d3 | ||
|
|
b35493e4e1 | ||
|
|
16578fb8e2 | ||
|
|
6dd3f90ee0 | ||
|
|
6d11dcd33f | ||
|
|
e64c2467fd | ||
|
|
e8a15695d8 | ||
|
|
0f3ad83004 | ||
|
|
4af5176b65 | ||
|
|
1e2f526a07 | ||
|
|
97be76278e | ||
|
|
d4232789f9 | ||
|
|
9b83d5617e | ||
|
|
70db81dc03 | ||
|
|
6b18d7371d | ||
|
|
934e81c5f9 | ||
|
|
c05a8944ef | ||
|
|
eaff2ca70d | ||
|
|
000d144e70 | ||
|
|
20970b4fb6 | ||
|
|
57df3466e7 | ||
|
|
75d859abdf | ||
|
|
b0412b02f5 |
@@ -34,6 +34,22 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify Cargo.toml version matches git tag
|
||||
shell: bash
|
||||
run: |
|
||||
TAG_VERSION="${GITHUB_REF_NAME}"
|
||||
|
||||
CARGO_VERSION=$(grep '^version' Cargo.toml | head -n1 | cut -d '"' -f2)
|
||||
|
||||
echo "Git tag version: $TAG_VERSION"
|
||||
echo "Cargo.toml version: $CARGO_VERSION"
|
||||
|
||||
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
|
||||
echo "::error::Version mismatch! Git tag ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
|
||||
Generated
+26
-20
@@ -424,7 +424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon"
|
||||
version = "0.1.3"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"async-imap",
|
||||
@@ -1537,9 +1537,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.10.2"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e23d5986fd4364c2fb7498523540618b4b8d92eec6c36a02e565f66748e2f79"
|
||||
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dashmap",
|
||||
@@ -1686,9 +1686,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "html2text"
|
||||
version = "0.16.4"
|
||||
version = "0.16.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1418e1f5886c4a3ac23a22a5590a4907935f13edb0526593dc02a769eecefdbe"
|
||||
checksum = "89f3074c1e4a7c4b4f7aca411a610e0f2f27e16d571d0e584524b66c93204100"
|
||||
dependencies = [
|
||||
"html5ever",
|
||||
"tendril",
|
||||
@@ -2131,9 +2131,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
@@ -3498,9 +3498,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
@@ -3536,9 +3536,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.13.1"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c"
|
||||
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
@@ -3667,15 +3667,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.145"
|
||||
version = "1.0.148"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
|
||||
checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4295,9 +4295,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.23.0"
|
||||
version = "3.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
||||
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
|
||||
dependencies = [
|
||||
"fastrand 2.3.0",
|
||||
"getrandom 0.3.4",
|
||||
@@ -4633,9 +4633,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.43"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
@@ -4668,9 +4668,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
@@ -5727,6 +5727,12 @@ dependencies = [
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3280a1b827474fcd5dbef4b35a674deb52ba5c312363aef9135317df179d81b"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bichon"
|
||||
version = "0.1.4"
|
||||
version = "0.2.2"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
@@ -37,9 +37,9 @@ poem-openapi = { version = "5.1.16", features = [
|
||||
] }
|
||||
ring = { version = "0.17.14", features = ["std"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
serde_json = "1.0.148"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
tracing = "0.1.43"
|
||||
tracing = "0.1.44"
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
|
||||
base64 = "0.22.1"
|
||||
@@ -83,11 +83,11 @@ async-imap = { version = "0.11.1", default-features = false, features = [
|
||||
] }
|
||||
webpki-roots = "1.0.4"
|
||||
rustls = { version = "0.23.35", default-features = false, features = ["ring"] }
|
||||
rustls-pki-types = "1.13.1"
|
||||
rustls-pki-types = "1.13.2"
|
||||
tokio-io-timeout = "1.2.1"
|
||||
bb8 = "0.9.1"
|
||||
semver = "1.0.27"
|
||||
governor = "0.10.2"
|
||||
governor = "0.10.4"
|
||||
lru = "0.16.2"
|
||||
mime_guess = "2.0.5"
|
||||
hex = "0.4.3"
|
||||
@@ -105,10 +105,10 @@ dashmap = "6.1.0"
|
||||
openssl-sys = { version = "0.9.111", optional = true, features = ["vendored"] }
|
||||
gethostname = "1.1.0"
|
||||
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
|
||||
itoa = "1.0.15"
|
||||
html2text = "0.16.4"
|
||||
itoa = "1.0.17"
|
||||
html2text = "0.16.5"
|
||||
bytes = "1.11.0"
|
||||
[dev-dependencies]
|
||||
#bincode = "1.3.3"
|
||||
#secret-lib = "1.0.0"
|
||||
tempfile = "3.23.0"
|
||||
tempfile = "3.24.0"
|
||||
|
||||
@@ -52,49 +52,20 @@ Built in Rust, it requires no external dependencies and provides fast, efficient
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
### ⚡ Lightweight & Standalone
|
||||
- Pure Rust, single-machine application.
|
||||
- No external database required.
|
||||
- Includes **WebUI** for intuitive management.
|
||||
* **Lightweight & Standalone** — Pure Rust, no external database, with built-in WebUI
|
||||
* **Multi-Account Sync** — Download and manage emails from multiple accounts
|
||||
* **Flexible Fetching** — Sync by date range, email count, or specific mailboxes
|
||||
* **IMAP & OAuth2 Auth** — Password or OAuth2 login with automatic token refresh
|
||||
* **Proxy & Auto Config** — Supports network proxies and automatic IMAP discovery
|
||||
* **Unified Search** — Search across all accounts by sender, subject, body, date, size, attachments, and more
|
||||
* **Tags & Facets** — Organize emails using Tantivy facet-based tags
|
||||
* **Compressed Storage** — Transparent compression and deduplication for efficient storage
|
||||
* **Email Management** — Browse, view threads, bulk clean up, export EML or attachments
|
||||
* **Dashboard & Analytics** — Visual insights into email volume, trends, and top senders
|
||||
* **Internationalized WebUI** — Frontend available in 18 languages
|
||||
* **OpenAPI Access** — OpenAPI docs with access-token authentication
|
||||
* **Multi-User & Role-Based Access Control (RBAC)** — Supports multiple users with fine-grained, role-based permissions
|
||||
|
||||
### 📬 Multi-Account Management
|
||||
- Synchronize and download emails from multiple accounts.
|
||||
- Flexible selection: by **date range**, **number of emails**, or **specific mailboxes**.
|
||||
|
||||
### 🔑 IMAP & OAuth2 Authentication
|
||||
- Supports **IMAP password** or **OAuth2** login.
|
||||
- Built-in WebUI for **OAuth2 authorization**, including **automatic token refresh** (e.g., Gmail, Outlook).
|
||||
- Supports **network proxy** for IMAP and OAuth2.
|
||||
- Automatic IMAP server discovery and configuration.
|
||||
|
||||
### 🔍 Unified Multi-Account Search
|
||||
- Powerful search across all accounts:
|
||||
**account**, **mailbox**, **sender**, **attachment name**, **has attachments**, **size**, **date**, **subject**, **body**.
|
||||
|
||||
### 🏷️ Tags & Facets
|
||||
- Organize archived emails using **tags** backed by Tantivy **facets**.
|
||||
- Efficiently filter and locate emails based on these facet-based tags.
|
||||
|
||||
### 💾 Compressed & Deduplicated Storage
|
||||
- Store emails efficiently with **transparent compression** and **deduplication**—emails can be read directly without any extra steps.
|
||||
|
||||
### 📂 Email Management & Viewing
|
||||
- Bulk cleanup of local archives.
|
||||
- Download emails as **EML** or **attachments separately**.
|
||||
- View and browse emails directly.
|
||||
- View the full **conversation thread** of any email.
|
||||
|
||||
### 📊 Dashboard & Analytics
|
||||
- Visualize email statistics: **counts**, **time distribution**, **top senders**, **largest emails**, **account rankings**.
|
||||
|
||||
### 🌐 Internationalization (i18n)
|
||||
* WebUI fully supports **17 languages** for all interface elements.
|
||||
* Backend responses (e.g., system messages, API data) are **not yet internationalized**.
|
||||
* Frontend is ready to support more languages in the future with minimal effort.
|
||||
|
||||
### 🛠️ OpenAPI Support
|
||||
- Provides **OpenAPI documentation**.
|
||||
- **Access token authentication** for programmatic access.
|
||||
|
||||
## 🐾 Why Create Bichon?
|
||||
|
||||
@@ -285,79 +256,53 @@ Extract and run:
|
||||
|
||||
* If you are accessing Bichon from a proxy domain **mydomain** argument --bichon-cors-origins="https://mydomain" is required.
|
||||
|
||||
## Setting the Bichon Encryption Password
|
||||
## 🔐 Setting the Bichon Encryption Password
|
||||
|
||||
Bichon uses an encryption password to secure sensitive data. **You must set it before first use**, when no data exists.
|
||||
Please refer to the following documentation for detailed instructions on how to set the Bichon encryption password:
|
||||
|
||||
Once set, it **cannot be changed**. Changing it later will make all encrypted data unreadable. To start over, you would need to **reinitialize Bichon and clear all emails and metadata**.
|
||||
👉 [https://github.com/rustmailer/bichon/wiki/Setting-the-Bichon-Encryption-Password](https://github.com/rustmailer/bichon/wiki/Setting-the-Bichon-Encryption-Password)
|
||||
|
||||
### How to Set the Password
|
||||
All configuration methods, including command-line options, environment variables, and password file support (v0.2.0+), are documented there.
|
||||
|
||||
You can set the password **via command-line or environment variable**:
|
||||
## 🔑 User Authentication & Admin Account
|
||||
|
||||
### Command-Line
|
||||
Starting from **Bichon v0.2.0**, the authentication model has been updated.
|
||||
|
||||
```bash
|
||||
bichon --bichon-encrypt-password "your-strong-password"
|
||||
```
|
||||
### Built-in Admin User (v0.2.0+)
|
||||
|
||||
### Environment Variable
|
||||
* Bichon no longer uses the legacy single-account `root / root` login.
|
||||
* The system now ships with a built-in **admin** user by default.
|
||||
* **Default credentials:**
|
||||
|
||||
```bash
|
||||
export BICHON_ENCRYPT_PASSWORD="your-strong-password"
|
||||
bichon
|
||||
```
|
||||
* **Username:** `admin`
|
||||
* **Password:** `admin@bichon`
|
||||
|
||||
**Tip:** Use a strong, secure password and keep it safe, as it cannot be changed later.
|
||||
> The legacy `root` account and the `root / root` default credentials **no longer exist**.
|
||||
|
||||
## 🔑 Root User Login Information
|
||||
|
||||
**Bichon currently supports a single Root user login for system access and management.**
|
||||
### Mandatory Access Token Authentication
|
||||
|
||||
### First Login and Enabling Access
|
||||
* From **v0.2.0 onward**, **access-token–based authentication is always enabled**.
|
||||
* The startup flag and environment variable
|
||||
`--bichon-enable-access-token` / `BICHON_ENABLE_ACCESS_TOKEN`
|
||||
are **deprecated and no longer used**.
|
||||
* No additional configuration is required to enable authentication.
|
||||
|
||||
To enable the login feature, you must specify a command-line argument or set an environment variable when starting Bichon.
|
||||
|
||||
#### 1\. Command-Line Argument
|
||||
### Managing Account Information
|
||||
|
||||
Add the `--bichon-enable-access-token` flag to your startup command:
|
||||
After logging in, the admin user can manage their profile directly in the WebUI:
|
||||
|
||||
```bash
|
||||
# Linux/macOS Binary Deployment Example
|
||||
./bichon --bichon-root-dir /tmp/bichon-data --bichon-enable-access-token
|
||||
```
|
||||
1. Log in to the WebUI using the default admin credentials.
|
||||
2. Navigate to **Settings → Profile**.
|
||||
3. Update:
|
||||
|
||||
#### 2\. Environment Variable (Recommended for Docker)
|
||||
|
||||
Set the environment variable `BICHON_ENABLE_ACCESS_TOKEN` to `true`:
|
||||
|
||||
```bash
|
||||
# Docker Deployment Example
|
||||
docker run -d \
|
||||
--name bichon \
|
||||
-p 15630:15630 \
|
||||
-v $(pwd)/bichon-data:/data \
|
||||
-e BICHON_LOG_LEVEL=info \
|
||||
-e BICHON_ROOT_DIR=/data \
|
||||
-e BICHON_ENABLE_ACCESS_TOKEN=true \
|
||||
rustmailer/bichon:latest
|
||||
```
|
||||
|
||||
### Default Credentials
|
||||
|
||||
* **Initial Login Account:** `root`
|
||||
* **Initial Password:** `root`
|
||||
|
||||
### Changing the Password
|
||||
|
||||
**It is strongly recommended that you change the default password immediately after your first login.**
|
||||
|
||||
You can change the password via the WebUI:
|
||||
|
||||
1. Log in to the WebUI.
|
||||
2. Navigate to the **Settings** page.
|
||||
3. Use the **Reset Root Password** option to modify your password.
|
||||
* Username
|
||||
* Password
|
||||
* Avatar and other profile information
|
||||
|
||||
⚠️ **Security Notice:**
|
||||
For security reasons, you should **change the default admin password immediately after the first login**.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
@@ -371,6 +316,56 @@ please see the FAQ in the project Wiki:
|
||||
👉 [https://github.com/rustmailer/bichon/wiki/FAQ](https://github.com/rustmailer/bichon/wiki/FAQ-(Frequently-Asked-Questions))
|
||||
|
||||
|
||||
## 💡 User Case Showcase
|
||||
|
||||
We have collected a real-world case study from a user processing email data, which demonstrates Bichon's performance and storage efficiency in a live environment.
|
||||
This case involves ingesting and indexing data from **126 email accounts**. The total original data volume was **229 GB**, comprising **460,000 emails**.
|
||||
|
||||
### 📊 Performance Data Overview
|
||||
|
||||
<img width="945" height="582" alt="image" src="https://github.com/user-attachments/assets/934ed6dd-c1da-4483-84fa-6d5b1bf6ca72" />
|
||||
|
||||
A special thank you to **[@rallisf1](https://github.com/rallisf1)** for sharing this usage scenario and the detailed data.
|
||||
|
||||
#### 🤝 Open Invitation
|
||||
|
||||
This data is provided solely as a **reference** for real-world usage. We encourage more users to share their Bichon usage screenshots and metrics (e.g., ingestion volume, compression ratio, search speed, etc.) to help the community conduct a more comprehensive assessment of Bichon's suitability and performance.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- ✓ Multi-user support with account/password login
|
||||
- System-level roles (admin / user)
|
||||
- Per-mail-account permissions
|
||||
|
||||
* [ ] `bichon-cli` command-line tool
|
||||
|
||||
* Import emails from `eml`, `mbox`, `msg`, `pst`
|
||||
|
||||
* [ ] Manual sync controls
|
||||
|
||||
* Sync on demand
|
||||
* Sync a single folder
|
||||
* Verify completeness by comparing with the mail server
|
||||
|
||||
* [ ] Post-sync server cleanup
|
||||
|
||||
* Clean up server-side emails after successful sync
|
||||
* Free up mailbox space (e.g. Gmail)
|
||||
|
||||
* [ ] Email export
|
||||
|
||||
* Export by folder
|
||||
* Export by entire account
|
||||
|
||||
* [ ] Account-to-account email sync
|
||||
|
||||
* Sync emails to a specified target account
|
||||
* Support mailbox migration
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Backend**: Rust + Poem
|
||||
@@ -433,9 +428,12 @@ cargo build
|
||||
Or run directly:
|
||||
|
||||
```bash
|
||||
export BICHON_ENCRYPT_PASSWORD=dummy-password-for-testing
|
||||
cargo run -- --bichon-root-dir e:\bichon-data
|
||||
```
|
||||
|
||||
`--bichon-root-dir` specifies the directory where **all Bichon data** will be stored.
|
||||
`BICHON_ENCRYPT_PASSWORD` is the password used to encrypt the sensitive data (see `cargo run -- --help` for alternative ways to specify this).
|
||||
|
||||
### WebUI Access
|
||||
|
||||
|
||||
+4
-4
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use mimalloc::MiMalloc;
|
||||
use modules::{
|
||||
common::rustls::RustMailerTls,
|
||||
@@ -25,11 +24,12 @@ use modules::{
|
||||
logger,
|
||||
rest::start_http_server,
|
||||
tasks::PeriodicTasks,
|
||||
token::root::ensure_root_token,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
use crate::modules::{common::signal::SignalManager, settings::dir::DataDirManager};
|
||||
use crate::modules::{
|
||||
common::signal::SignalManager, settings::dir::DataDirManager, users::manager::UserManager,
|
||||
};
|
||||
|
||||
mod modules;
|
||||
|
||||
@@ -68,7 +68,7 @@ async fn initialize() -> BichonResult<()> {
|
||||
// SETTINGS.validate()?;
|
||||
SignalManager::initialize().await?;
|
||||
DataDirManager::initialize().await?;
|
||||
ensure_root_token().await?;
|
||||
UserManager::initialize().await?;
|
||||
RustMailerTls::initialize().await?;
|
||||
EmailClientExecutors::initialize().await?;
|
||||
PeriodicTasks::start_background_tasks();
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
account::migration::AccountModel,
|
||||
common::auth::ClientContext,
|
||||
database::{manager::DB_MANAGER, with_transaction},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
users::{
|
||||
permissions::Permission,
|
||||
role::{RoleType, UserRole},
|
||||
UserModel,
|
||||
},
|
||||
},
|
||||
raise_error, utc_now,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct BatchAccountRoleRequest {
|
||||
pub account_ids: Vec<u64>,
|
||||
pub user_ids: Vec<u64>,
|
||||
pub role_id: u64,
|
||||
}
|
||||
|
||||
impl BatchAccountRoleRequest {
|
||||
pub async fn validate_existence(&self) -> BichonResult<()> {
|
||||
let role = UserRole::find(self.role_id).await?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("Role ID {} not found", self.role_id),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
if !matches!(role.role_type, RoleType::Account) {
|
||||
return Err(raise_error!(
|
||||
"Only Account roles can be assigned to individual account".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
for id in &self.account_ids {
|
||||
let exists = AccountModel::find(*id).await?; // Assuming an exists helper
|
||||
if exists.is_none() {
|
||||
return Err(raise_error!(
|
||||
format!("Account ID {} not found", id),
|
||||
ErrorCode::ResourceNotFound
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for id in &self.user_ids {
|
||||
let exists = UserModel::find(*id).await?; // Assuming an exists helper
|
||||
if exists.is_none() {
|
||||
return Err(raise_error!(
|
||||
format!("User ID {} not found", id),
|
||||
ErrorCode::ResourceNotFound
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn grant_batch_account_access(
|
||||
account_ids: Vec<u64>,
|
||||
user_ids: Vec<u64>,
|
||||
role_id: u64,
|
||||
) -> BichonResult<()> {
|
||||
with_transaction(DB_MANAGER.meta_db(), move |rw| {
|
||||
for &uid in &user_ids {
|
||||
// Fetch the current user record from the database
|
||||
let user = rw
|
||||
.get()
|
||||
.primary::<UserModel>(uid)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("User with id={} not found.", uid),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut updated_user = user.clone();
|
||||
|
||||
// Apply the role to each specified account_id
|
||||
for &aid in &account_ids {
|
||||
updated_user.account_access_map.insert(aid, role_id);
|
||||
}
|
||||
|
||||
updated_user.updated_at = utc_now!();
|
||||
|
||||
// Save the updated user back to the database within the transaction
|
||||
rw.update(user, updated_user)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
|
||||
for account_id in &self.account_ids {
|
||||
// Get the user's specific access for this account
|
||||
let assigned_role_id =
|
||||
context
|
||||
.user
|
||||
.account_access_map
|
||||
.get(account_id)
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("No access to account {}", account_id),
|
||||
ErrorCode::Forbidden
|
||||
)
|
||||
})?;
|
||||
|
||||
// Fetch the role definition from the database
|
||||
let user_scoped_role = UserRole::find(*assigned_role_id).await?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Assigned account role no longer exists".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
|
||||
// Critical Check: Does this role grant management/sharing rights?
|
||||
if !user_scoped_role
|
||||
.permissions
|
||||
.contains(Permission::ACCOUNT_MANAGE)
|
||||
{
|
||||
return Err(raise_error!(
|
||||
format!("Your role on account {} does not allow sharing", account_id),
|
||||
ErrorCode::Forbidden
|
||||
));
|
||||
}
|
||||
|
||||
// Optional: Ensure manager isn't giving away perms they don't have
|
||||
// This is where you'd compare target_role.permissions vs manager's perms
|
||||
}
|
||||
|
||||
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id).await
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,16 @@ use tracing::info;
|
||||
use crate::{
|
||||
encrypt,
|
||||
modules::{
|
||||
account::{entity::ImapConfig, since::DateSince, state::AccountRunningState},
|
||||
account::{
|
||||
entity::ImapConfig,
|
||||
since::{DateSince, RelativeDate},
|
||||
state::AccountRunningState,
|
||||
},
|
||||
cache::imap::mailbox::MailBox,
|
||||
database::{insert_impl, list_all_impl},
|
||||
database::{list_all_impl, with_transaction},
|
||||
error::BichonResult,
|
||||
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
|
||||
users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel, DEFAULT_ADMIN_USER_ID},
|
||||
},
|
||||
utc_now,
|
||||
};
|
||||
@@ -52,10 +57,9 @@ use crate::modules::database::{
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::oauth2::token::OAuth2AccessToken;
|
||||
use crate::modules::rest::response::DataPage;
|
||||
use crate::modules::token::AccessToken;
|
||||
use crate::raise_error;
|
||||
|
||||
pub type AccountModel = AccountV2;
|
||||
pub type AccountModel = AccountV3;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
|
||||
pub enum AccountType {
|
||||
@@ -121,8 +125,42 @@ impl AccountV2 {
|
||||
fn pk(&self) -> String {
|
||||
format!("{}_{}", self.created_at, self.id)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(request: AccountCreateRequest) -> BichonResult<Self> {
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
#[native_model(id = 4, version = 3, from = AccountV2)]
|
||||
#[native_db(primary_key(pk -> String))]
|
||||
pub struct AccountV3 {
|
||||
#[secondary_key(unique)]
|
||||
pub id: u64,
|
||||
pub imap: Option<ImapConfig>,
|
||||
pub enabled: bool,
|
||||
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub capabilities: Option<Vec<String>>,
|
||||
pub date_since: Option<DateSince>,
|
||||
pub date_before: Option<RelativeDate>,
|
||||
pub folder_limit: Option<u32>,
|
||||
pub sync_folders: Option<Vec<String>>,
|
||||
pub account_type: AccountType,
|
||||
pub sync_interval_min: Option<i64>,
|
||||
pub sync_batch_size: Option<u32>,
|
||||
pub known_folders: Option<BTreeSet<String>>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub created_by: u64, //user id
|
||||
pub use_proxy: Option<u64>,
|
||||
pub use_dangerous: bool,
|
||||
pub pgp_key: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountV3 {
|
||||
fn pk(&self) -> String {
|
||||
format!("{}_{}", self.created_at, self.id)
|
||||
}
|
||||
|
||||
pub fn new(user_id: u64, request: AccountCreateRequest) -> BichonResult<Self> {
|
||||
Ok(Self {
|
||||
id: id!(64),
|
||||
email: request.email,
|
||||
@@ -141,12 +179,15 @@ impl AccountV2 {
|
||||
folder_limit: request.folder_limit,
|
||||
use_dangerous: request.use_dangerous,
|
||||
pgp_key: request.pgp_key,
|
||||
created_by: user_id,
|
||||
sync_batch_size: request.sync_batch_size,
|
||||
date_before: request.date_before,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
|
||||
let account =
|
||||
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
|
||||
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
@@ -154,13 +195,6 @@ impl AccountV2 {
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
// if !account.enabled {
|
||||
// return Err(raise_error!(
|
||||
// format!("Account id='{account_id}' is disabled"),
|
||||
// ErrorCode::AccountDisabled
|
||||
// ));
|
||||
// }
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
@@ -176,24 +210,48 @@ impl AccountV2 {
|
||||
}
|
||||
|
||||
pub async fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
|
||||
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
|
||||
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Saves the current `AccountEntity` by persisting it to storage.
|
||||
pub async fn save(&self) -> BichonResult<()> {
|
||||
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
|
||||
}
|
||||
pub async fn create_account(
|
||||
user_id: u64,
|
||||
request: AccountCreateRequest,
|
||||
) -> BichonResult<AccountModel> {
|
||||
let entity = request.create_entity(user_id)?;
|
||||
let cloned = entity.clone();
|
||||
with_transaction(DB_MANAGER.meta_db(), move |rw| {
|
||||
let account_id = entity.id;
|
||||
rw.insert::<AccountModel>(entity)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let user = rw
|
||||
.get()
|
||||
.primary::<UserModel>(user_id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("User with id={} not found.", user_id),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
pub async fn create_account(request: AccountCreateRequest) -> BichonResult<AccountModel> {
|
||||
let entity = request.create_entity()?;
|
||||
entity.save().await?;
|
||||
if matches!(entity.account_type, AccountType::IMAP) {
|
||||
let mut updated = user.clone();
|
||||
updated
|
||||
.account_access_map
|
||||
.insert(account_id, DEFAULT_ACCOUNT_MANAGER_ROLE_ID);
|
||||
updated.updated_at = utc_now!();
|
||||
rw.update(user, updated)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
if matches!(cloned.account_type, AccountType::IMAP) {
|
||||
SYNC_CONTROLLER
|
||||
.trigger_start(entity.id, entity.email.clone())
|
||||
.trigger_start(cloned.id, cloned.email.clone())
|
||||
.await;
|
||||
}
|
||||
Ok(entity)
|
||||
Ok(cloned)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
@@ -230,7 +288,7 @@ impl AccountV2 {
|
||||
|
||||
async fn delete_account(account_id: u64) -> BichonResult<()> {
|
||||
delete_impl(DB_MANAGER.meta_db(), move|rw|{
|
||||
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(||raise_error!(format!("The account entity with id={account_id} that you want to delete was not found."), ErrorCode::ResourceNotFound))
|
||||
}).await
|
||||
}
|
||||
@@ -242,7 +300,7 @@ impl AccountV2 {
|
||||
MAIL_CONTEXT.clean_account(account.id).await?;
|
||||
}
|
||||
OAuth2AccessToken::try_delete(account.id).await?;
|
||||
AccessToken::cleanup_account(account.id).await?;
|
||||
UserModel::cleanup_account(account.id).await?;
|
||||
MailBox::clean(account.id).await?;
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.delete_account_envelopes(account.id)
|
||||
@@ -260,7 +318,7 @@ impl AccountV2 {
|
||||
sync_folders: Vec<String>,
|
||||
) -> BichonResult<()> {
|
||||
update_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| raise_error!(format!("When trying to update account sync_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
|
||||
}, |current|{
|
||||
let mut updated = current.clone();
|
||||
@@ -275,7 +333,7 @@ impl AccountV2 {
|
||||
known_folders: BTreeSet<String>,
|
||||
) -> BichonResult<()> {
|
||||
update_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| raise_error!(format!("When trying to update account known_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
|
||||
}, |current|{
|
||||
let mut updated = current.clone();
|
||||
@@ -290,7 +348,7 @@ impl AccountV2 {
|
||||
capabilities: Vec<String>,
|
||||
) -> BichonResult<()> {
|
||||
update_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| raise_error!(format!("When trying to update account capabilities, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
|
||||
}, |current|{
|
||||
let mut updated = current.clone();
|
||||
@@ -319,7 +377,7 @@ impl AccountV2 {
|
||||
}
|
||||
|
||||
pub async fn count() -> BichonResult<usize> {
|
||||
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id)
|
||||
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -342,6 +400,19 @@ impl AccountV2 {
|
||||
|
||||
if let Some(date_since) = request.date_since {
|
||||
new.date_since = Some(date_since);
|
||||
new.date_before = None;
|
||||
}
|
||||
|
||||
if let Some(date_before) = request.date_before {
|
||||
new.date_before = Some(date_before);
|
||||
new.date_since = None;
|
||||
}
|
||||
|
||||
if let Some(clear_date_range) = request.clear_date_range {
|
||||
if clear_date_range {
|
||||
new.date_since = None;
|
||||
new.date_before = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(folder_limit) = request.folder_limit {
|
||||
@@ -377,6 +448,11 @@ impl AccountV2 {
|
||||
if let Some(sync_interval_min) = &request.sync_interval_min {
|
||||
new.sync_interval_min = Some(*sync_interval_min);
|
||||
}
|
||||
|
||||
if let Some(sync_batch_size) = &request.sync_batch_size {
|
||||
new.sync_batch_size = Some(*sync_batch_size);
|
||||
}
|
||||
|
||||
if let Some(use_proxy) = request.use_proxy {
|
||||
new.use_proxy = Some(use_proxy);
|
||||
}
|
||||
@@ -450,3 +526,54 @@ impl From<AccountV2> for AccountV1 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AccountV3> for AccountV2 {
|
||||
fn from(value: AccountV3) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
imap: value.imap,
|
||||
enabled: value.enabled,
|
||||
email: value.email,
|
||||
name: value.name,
|
||||
capabilities: value.capabilities,
|
||||
date_since: value.date_since,
|
||||
folder_limit: value.folder_limit,
|
||||
sync_folders: value.sync_folders,
|
||||
account_type: value.account_type,
|
||||
sync_interval_min: value.sync_interval_min,
|
||||
known_folders: value.known_folders,
|
||||
created_at: value.created_at,
|
||||
updated_at: value.updated_at,
|
||||
use_proxy: value.use_proxy,
|
||||
use_dangerous: value.use_dangerous,
|
||||
pgp_key: value.pgp_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AccountV2> for AccountV3 {
|
||||
fn from(value: AccountV2) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
imap: value.imap,
|
||||
enabled: value.enabled,
|
||||
email: value.email,
|
||||
name: value.name,
|
||||
capabilities: value.capabilities,
|
||||
date_since: value.date_since,
|
||||
folder_limit: value.folder_limit,
|
||||
sync_folders: value.sync_folders,
|
||||
account_type: value.account_type,
|
||||
sync_interval_min: value.sync_interval_min,
|
||||
known_folders: value.known_folders,
|
||||
created_at: value.created_at,
|
||||
updated_at: value.updated_at,
|
||||
created_by: DEFAULT_ADMIN_USER_ID,
|
||||
use_proxy: value.use_proxy,
|
||||
use_dangerous: value.use_dangerous,
|
||||
pgp_key: value.pgp_key,
|
||||
sync_batch_size: None,
|
||||
date_before: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
pub mod dispatcher;
|
||||
pub mod entity;
|
||||
pub mod grant;
|
||||
pub mod migration;
|
||||
pub mod payload;
|
||||
pub mod since;
|
||||
pub mod state;
|
||||
pub mod migration;
|
||||
pub mod view;
|
||||
|
||||
@@ -16,14 +16,11 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::modules::account::entity::ImapConfig;
|
||||
use crate::modules::account::migration::{AccountModel, AccountType};
|
||||
use crate::modules::account::since::DateSince;
|
||||
use crate::modules::account::since::{DateSince, RelativeDate};
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::modules::token::AccountInfo;
|
||||
use crate::{raise_error, validate_email};
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -36,21 +33,37 @@ pub struct AccountCreateRequest {
|
||||
pub imap: Option<ImapConfig>,
|
||||
pub enabled: bool,
|
||||
pub date_since: Option<DateSince>,
|
||||
pub date_before: Option<RelativeDate>,
|
||||
pub account_type: AccountType,
|
||||
#[oai(validator(minimum(value = "100")))]
|
||||
pub folder_limit: Option<u32>,
|
||||
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
|
||||
pub sync_interval_min: Option<i64>,
|
||||
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
|
||||
pub sync_batch_size: Option<u32>,
|
||||
pub use_proxy: Option<u64>,
|
||||
pub use_dangerous: bool,
|
||||
pub pgp_key: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountCreateRequest {
|
||||
pub fn create_entity(self) -> BichonResult<AccountModel> {
|
||||
pub fn create_entity(self, user_id: u64) -> BichonResult<AccountModel> {
|
||||
if self.date_before.is_some() && self.date_since.is_some() {
|
||||
return Err(raise_error!(
|
||||
"date_before and date_since are mutually exclusive; specify only one time boundary"
|
||||
.into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(date_since) = self.date_since.as_ref() {
|
||||
date_since.validate()?;
|
||||
}
|
||||
|
||||
if let Some(date_before) = self.date_before.as_ref() {
|
||||
date_before.validate_date()?;
|
||||
}
|
||||
|
||||
match self.account_type {
|
||||
AccountType::IMAP => {
|
||||
match &self.imap {
|
||||
@@ -71,7 +84,7 @@ impl AccountCreateRequest {
|
||||
}
|
||||
AccountType::NoSync => {}
|
||||
}
|
||||
Ok(AccountModel::new(self)?)
|
||||
Ok(AccountModel::new(user_id, self)?)
|
||||
}
|
||||
|
||||
fn validate_request(imap: &ImapConfig, email: &str) -> BichonResult<()> {
|
||||
@@ -107,6 +120,8 @@ pub struct AccountUpdateRequest {
|
||||
/// - First-time sync optimization for large accounts
|
||||
/// - Reducing server load during resyncs
|
||||
pub date_since: Option<DateSince>,
|
||||
pub date_before: Option<RelativeDate>,
|
||||
pub clear_date_range: Option<bool>,
|
||||
/// Max emails to sync for this folder.
|
||||
/// If not set, sync all emails.
|
||||
/// otherwise sync up to `n` most recent emails (min 10).
|
||||
@@ -129,6 +144,8 @@ pub struct AccountUpdateRequest {
|
||||
/// Incremental sync interval (seconds)
|
||||
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
|
||||
pub sync_interval_min: Option<i64>,
|
||||
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
|
||||
pub sync_batch_size: Option<u32>,
|
||||
/// Optional proxy ID for establishing the connection to external APIs (e.g., Gmail, Outlook).
|
||||
/// - If `None` or not provided, the client will connect directly to the API server.
|
||||
/// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID for API requests.
|
||||
@@ -141,9 +158,31 @@ pub struct AccountUpdateRequest {
|
||||
|
||||
impl AccountUpdateRequest {
|
||||
pub fn validate_update_request(&self, account: &AccountModel) -> BichonResult<()> {
|
||||
if self.date_before.is_some() && self.date_since.is_some() {
|
||||
return Err(raise_error!(
|
||||
"date_before and date_since are mutually exclusive; specify only one time boundary"
|
||||
.into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
if self.clear_date_range == Some(true)
|
||||
&& (self.date_since.is_some() || self.date_before.is_some())
|
||||
{
|
||||
return Err(raise_error!(
|
||||
"clear_date_range cannot be combined with date_since or date_before".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(date_since) = self.date_since.as_ref() {
|
||||
date_since.validate()?;
|
||||
}
|
||||
|
||||
if let Some(date_before) = self.date_before.as_ref() {
|
||||
date_before.validate_date()?;
|
||||
}
|
||||
|
||||
if matches!(account.account_type, AccountType::IMAP) {
|
||||
if let Some(mailboxes) = self.sync_folders.as_ref() {
|
||||
if mailboxes.is_empty() {
|
||||
@@ -167,11 +206,11 @@ pub struct MinimalAccount {
|
||||
|
||||
pub fn filter_accessible_accounts<'a>(
|
||||
all_accounts: &'a [MinimalAccount],
|
||||
allowed: &BTreeSet<AccountInfo>,
|
||||
allowed: &Vec<u64>,
|
||||
) -> Vec<MinimalAccount> {
|
||||
all_accounts
|
||||
.iter()
|
||||
.filter(|acct| allowed.iter().any(|a| a.id == acct.id))
|
||||
.filter(|acct| allowed.contains(&acct.id))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::{
|
||||
modules::error::{code::ErrorCode, BichonResult},
|
||||
raise_error,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::modules::{
|
||||
account::{
|
||||
entity::ImapConfig,
|
||||
migration::{AccountModel, AccountType},
|
||||
since::{DateSince, RelativeDate},
|
||||
},
|
||||
users::UserModel,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct AccountResp {
|
||||
pub id: u64,
|
||||
pub imap: Option<ImapConfig>,
|
||||
pub enabled: bool,
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub capabilities: Option<Vec<String>>,
|
||||
pub date_since: Option<DateSince>,
|
||||
pub date_before: Option<RelativeDate>,
|
||||
pub folder_limit: Option<u32>,
|
||||
pub sync_folders: Option<Vec<String>>,
|
||||
pub account_type: AccountType,
|
||||
pub sync_interval_min: Option<i64>,
|
||||
pub sync_batch_size: Option<u32>,
|
||||
pub known_folders: Option<BTreeSet<String>>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub created_by: u64, //user id
|
||||
pub created_user_name: String,
|
||||
pub created_user_email: String,
|
||||
pub use_proxy: Option<u64>,
|
||||
pub use_dangerous: bool,
|
||||
pub pgp_key: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountResp {
|
||||
pub fn from_model(account: AccountModel, user_map: &HashMap<u64, UserModel>) -> AccountResp {
|
||||
let user = user_map.get(&account.created_by);
|
||||
AccountResp {
|
||||
id: account.id,
|
||||
imap: account.imap,
|
||||
enabled: account.enabled,
|
||||
email: account.email,
|
||||
name: account.name,
|
||||
capabilities: account.capabilities,
|
||||
date_since: account.date_since,
|
||||
date_before: account.date_before,
|
||||
folder_limit: account.folder_limit,
|
||||
sync_folders: account.sync_folders,
|
||||
account_type: account.account_type,
|
||||
sync_interval_min: account.sync_interval_min,
|
||||
sync_batch_size: account.sync_batch_size,
|
||||
known_folders: account.known_folders,
|
||||
created_at: account.created_at,
|
||||
updated_at: account.updated_at,
|
||||
created_by: account.created_by,
|
||||
created_user_name: user
|
||||
.map(|u| u.username.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
created_user_email: user
|
||||
.map(|u| u.email.clone())
|
||||
.unwrap_or_else(|| "N/A".to_string()),
|
||||
use_proxy: account.use_proxy,
|
||||
use_dangerous: account.use_dangerous,
|
||||
pgp_key: account.pgp_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+82
-21
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
account::{migration::AccountModel, state::AccountRunningState},
|
||||
@@ -24,7 +23,7 @@ use crate::{
|
||||
imap::{
|
||||
find_intersecting_mailboxes, find_missing_mailboxes,
|
||||
mailbox::MailBox,
|
||||
sync::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_since_date},
|
||||
sync::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_by_date},
|
||||
},
|
||||
SEMAPHORE,
|
||||
},
|
||||
@@ -37,17 +36,30 @@ use crate::{
|
||||
use std::time::Instant;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
pub const BATCH_SIZE: u32 = 50;
|
||||
pub const DEFAULT_BATCH_SIZE: u32 = 50;
|
||||
|
||||
pub async fn fetch_and_save_since_date(
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum FetchDirection {
|
||||
Since,
|
||||
Before,
|
||||
}
|
||||
|
||||
pub async fn fetch_and_save_by_date(
|
||||
account: &AccountModel,
|
||||
date: &str,
|
||||
mailbox: &MailBox,
|
||||
direction: FetchDirection,
|
||||
) -> BichonResult<usize> {
|
||||
let account_id = account.id;
|
||||
let executor = MAIL_CONTEXT.imap(account_id).await?;
|
||||
|
||||
let search_criteria = match direction {
|
||||
FetchDirection::Since => format!("SINCE {date}"),
|
||||
FetchDirection::Before => format!("BEFORE {date}"),
|
||||
};
|
||||
|
||||
let uid_list = executor
|
||||
.uid_search(&mailbox.encoded_name(), format!("SINCE {date}").as_str())
|
||||
.uid_search(&mailbox.encoded_name(), &search_criteria)
|
||||
.await?;
|
||||
|
||||
let len = uid_list.len();
|
||||
@@ -63,13 +75,23 @@ pub async fn fetch_and_save_since_date(
|
||||
if let Some(limit) = folder_limit {
|
||||
let limit = limit.max(100) as usize;
|
||||
if len > limit {
|
||||
uid_vec = uid_vec.split_off(len - limit as usize);
|
||||
uid_vec = match direction {
|
||||
FetchDirection::Since => uid_vec.split_off(len - limit),
|
||||
FetchDirection::Before => {
|
||||
uid_vec.truncate(limit);
|
||||
uid_vec
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// let semaphore = Arc::new(Semaphore::new(5));
|
||||
|
||||
let uid_batches = generate_uid_sequence_hashset(uid_vec, BATCH_SIZE as usize, false);
|
||||
let uid_batches = generate_uid_sequence_hashset(
|
||||
uid_vec,
|
||||
account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
|
||||
false,
|
||||
);
|
||||
AccountRunningState::set_initial_current_syncing_folder(
|
||||
account_id,
|
||||
mailbox.name.clone(),
|
||||
@@ -105,9 +127,11 @@ pub async fn fetch_and_save_full_mailbox(
|
||||
_ => total,
|
||||
};
|
||||
let page_size = if let Some(limit) = folder_limit {
|
||||
limit.max(100).min(BATCH_SIZE as u32)
|
||||
limit
|
||||
.max(100)
|
||||
.min(account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE))
|
||||
} else {
|
||||
BATCH_SIZE as u32
|
||||
account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE)
|
||||
};
|
||||
|
||||
let total_batches = total_to_fetch.div_ceil(page_size);
|
||||
@@ -251,17 +275,30 @@ pub async fn reconcile_mailboxes(
|
||||
|
||||
match &account.date_since {
|
||||
Some(date_since) => {
|
||||
rebuild_mailbox_cache_since_date(
|
||||
rebuild_mailbox_cache_by_date(
|
||||
account,
|
||||
local_mailbox.id,
|
||||
date_since,
|
||||
&date_since.since_date()?,
|
||||
remote_mailbox,
|
||||
FetchDirection::Since,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
rebuild_mailbox_cache(account, local_mailbox, remote_mailbox).await?;
|
||||
}
|
||||
None => match &account.date_before {
|
||||
Some(r) => {
|
||||
rebuild_mailbox_cache_by_date(
|
||||
account,
|
||||
local_mailbox.id,
|
||||
&r.calculate_date()?,
|
||||
remote_mailbox,
|
||||
FetchDirection::Before,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
rebuild_mailbox_cache(account, local_mailbox, remote_mailbox).await?
|
||||
}
|
||||
},
|
||||
}
|
||||
} else {
|
||||
perform_incremental_sync(account, local_mailbox, remote_mailbox).await?;
|
||||
@@ -305,14 +342,31 @@ pub async fn reconcile_mailboxes(
|
||||
let _permit = permit;
|
||||
match &account.date_since {
|
||||
Some(date_since) => {
|
||||
rebuild_mailbox_cache_since_date(
|
||||
&account, mailbox.id, date_since, &mailbox,
|
||||
rebuild_mailbox_cache_by_date(
|
||||
&account,
|
||||
mailbox.id,
|
||||
&date_since.since_date()?,
|
||||
&mailbox,
|
||||
FetchDirection::Since,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
rebuild_mailbox_cache(&account, &mailbox, &mailbox).await
|
||||
}
|
||||
None => match &account.date_before {
|
||||
Some(r) => {
|
||||
rebuild_mailbox_cache_by_date(
|
||||
&account,
|
||||
mailbox.id,
|
||||
&r.calculate_date()?,
|
||||
&mailbox,
|
||||
FetchDirection::Before,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
rebuild_mailbox_cache(&account, &mailbox, &mailbox)
|
||||
.await
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
handles.push(handle);
|
||||
@@ -348,8 +402,14 @@ async fn perform_incremental_sync(
|
||||
match local_max_uid {
|
||||
Some(max_uid) => {
|
||||
let executor = MAIL_CONTEXT.imap(account.id).await?;
|
||||
let before_date = account
|
||||
.date_before
|
||||
.as_ref()
|
||||
.map(|r| r.calculate_date())
|
||||
.transpose()?;
|
||||
|
||||
executor
|
||||
.fetch_new_mail(account.id, local_mailbox, max_uid + 1)
|
||||
.fetch_new_mail(account, local_mailbox, max_uid + 1, before_date.as_deref())
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
@@ -359,10 +419,11 @@ async fn perform_incremental_sync(
|
||||
|
||||
match &account.date_since {
|
||||
Some(date_since) => {
|
||||
fetch_and_save_since_date(
|
||||
fetch_and_save_by_date(
|
||||
account,
|
||||
date_since.since_date()?.as_str(),
|
||||
remote_mailbox,
|
||||
FetchDirection::Since,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Vendored
+21
-4
@@ -23,13 +23,13 @@ use crate::{
|
||||
migration::{AccountModel, AccountType},
|
||||
state::AccountRunningState,
|
||||
},
|
||||
cache::imap::mailbox::MailBox,
|
||||
cache::imap::{mailbox::MailBox, sync::flow::FetchDirection},
|
||||
error::BichonResult,
|
||||
},
|
||||
utc_now,
|
||||
};
|
||||
use flow::reconcile_mailboxes;
|
||||
use rebuild::{rebuild_cache, rebuild_cache_since_date};
|
||||
use rebuild::{rebuild_cache, rebuild_cache_by_date};
|
||||
use std::time::Instant;
|
||||
use sync_folders::get_sync_folders;
|
||||
use sync_type::{determine_sync_type, SyncType};
|
||||
@@ -54,9 +54,26 @@ pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> {
|
||||
// AccountRunningState::set_initial_sync_start(account_id).await?;
|
||||
let result = match &account.date_since {
|
||||
Some(date_since) => {
|
||||
rebuild_cache_since_date(account, &remote_mailboxes, date_since).await
|
||||
rebuild_cache_by_date(
|
||||
account,
|
||||
&remote_mailboxes,
|
||||
&date_since.since_date()?,
|
||||
FetchDirection::Since,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => rebuild_cache(account, &remote_mailboxes).await,
|
||||
None => match &account.date_before {
|
||||
Some(r) => {
|
||||
rebuild_cache_by_date(
|
||||
account,
|
||||
&remote_mailboxes,
|
||||
&r.calculate_date()?,
|
||||
FetchDirection::Before,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => rebuild_cache(account, &remote_mailboxes).await,
|
||||
},
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {
|
||||
|
||||
+18
-14
@@ -16,14 +16,13 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
account::{migration::AccountModel, since::DateSince},
|
||||
account::migration::AccountModel,
|
||||
cache::{
|
||||
imap::{
|
||||
mailbox::MailBox,
|
||||
sync::flow::{fetch_and_save_full_mailbox, fetch_and_save_since_date},
|
||||
sync::flow::{fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection},
|
||||
},
|
||||
SEMAPHORE,
|
||||
},
|
||||
@@ -86,14 +85,14 @@ pub async fn rebuild_cache(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rebuild_cache_since_date(
|
||||
pub async fn rebuild_cache_by_date(
|
||||
account: &AccountModel,
|
||||
remote_mailboxes: &[MailBox],
|
||||
date_since: &DateSince,
|
||||
date: &str,
|
||||
direction: FetchDirection,
|
||||
) -> BichonResult<()> {
|
||||
let start_time = Instant::now();
|
||||
let mut total_inserted = 0;
|
||||
let date = date_since.since_date()?;
|
||||
MailBox::batch_insert(remote_mailboxes).await?;
|
||||
|
||||
let mut handles = Vec::new();
|
||||
@@ -107,13 +106,14 @@ pub async fn rebuild_cache_since_date(
|
||||
}
|
||||
let account = account.clone();
|
||||
let mailbox = mailbox.clone();
|
||||
let date = date.clone();
|
||||
let date = date.to_string();
|
||||
let direction = direction.clone();
|
||||
match SEMAPHORE.clone().acquire_owned().await {
|
||||
Ok(permit) => {
|
||||
let handle: tokio::task::JoinHandle<Result<usize, BichonError>> =
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit; // Ensure permit is released when task finishes
|
||||
fetch_and_save_since_date(&account, date.as_str(), &mailbox).await
|
||||
fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction).await
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
@@ -132,10 +132,14 @@ pub async fn rebuild_cache_since_date(
|
||||
}
|
||||
}
|
||||
let elapsed_time = start_time.elapsed().as_secs();
|
||||
let direction_desc = match direction {
|
||||
FetchDirection::Since => "starting from the specified date",
|
||||
FetchDirection::Before => "ending before the specified date",
|
||||
};
|
||||
info!(
|
||||
"Rebuild account cache completed: {} envelopes inserted. {} secs elapsed. \
|
||||
Data fetched from server starting from the specified date: {}.",
|
||||
total_inserted, elapsed_time, date
|
||||
Data fetched from server {}: {}.",
|
||||
total_inserted, elapsed_time, direction_desc, date
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -169,11 +173,12 @@ pub async fn rebuild_mailbox_cache(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rebuild_mailbox_cache_since_date(
|
||||
pub async fn rebuild_mailbox_cache_by_date(
|
||||
account: &AccountModel,
|
||||
local_mailbox_id: u64,
|
||||
date_since: &DateSince,
|
||||
date: &str,
|
||||
remote: &MailBox,
|
||||
direction: FetchDirection,
|
||||
) -> BichonResult<()> {
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
|
||||
@@ -190,8 +195,7 @@ pub async fn rebuild_mailbox_cache_since_date(
|
||||
return Ok(()); // Skip if the mailbox has no emails
|
||||
}
|
||||
|
||||
let count =
|
||||
fetch_and_save_since_date(account, date_since.since_date()?.as_str(), remote).await?;
|
||||
let count = fetch_and_save_by_date(account, date, remote, direction).await?;
|
||||
info!(
|
||||
"Account {}: Successfully rebuild mailbox cache, inserted {} envelopes for mailbox '{}'.",
|
||||
account.id, count, &remote.name
|
||||
|
||||
+153
-133
@@ -16,12 +16,11 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
settings::{cli::SETTINGS, system::SystemSetting},
|
||||
token::{root::ROOT_TOKEN, AccessToken, AccountInfo},
|
||||
token::AccessTokenModel,
|
||||
users::{permissions::Permission, role::UserRole, UserModel},
|
||||
utils::rate_limit::RATE_LIMITER_MANAGER,
|
||||
},
|
||||
raise_error,
|
||||
@@ -35,7 +34,11 @@ use poem::{
|
||||
Endpoint, FromRequest, Middleware, Request, RequestBody, Result,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{collections::BTreeSet, net::IpAddr, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeSet, HashSet},
|
||||
net::IpAddr,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use super::create_api_error_response;
|
||||
|
||||
@@ -68,62 +71,101 @@ impl<E: Endpoint> Endpoint for ApiGuardEndpoint<E> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClientContext {
|
||||
pub ip_addr: Option<IpAddr>,
|
||||
pub access_token: Option<AccessToken>,
|
||||
pub is_root: bool,
|
||||
pub user: UserModel,
|
||||
}
|
||||
|
||||
impl ClientContext {
|
||||
pub fn require_root(&self) -> BichonResult<()> {
|
||||
if !SETTINGS.bichon_enable_access_token || self.is_root {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(raise_error!(
|
||||
"Root access required".into(),
|
||||
ErrorCode::PermissionDenied
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn require_authorized(&self) -> BichonResult<()> {
|
||||
if !SETTINGS.bichon_enable_access_token || self.is_root || self.access_token.is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(raise_error!(
|
||||
"Authorization required".into(),
|
||||
ErrorCode::PermissionDenied
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn require_account_access(&self, account_id: u64) -> BichonResult<()> {
|
||||
if !SETTINGS.bichon_enable_access_token || self.is_root {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match &self.access_token {
|
||||
Some(token) if token.can_access_account(account_id) => Ok(()),
|
||||
_ => Err(raise_error!(format!(
|
||||
"You do not have permission to access the requested email account (ID: {}). Please check your access rights or contact the administrator.",
|
||||
account_id
|
||||
), ErrorCode::PermissionDenied)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn accessible_accounts(&self) -> BichonResult<Option<&BTreeSet<AccountInfo>>> {
|
||||
if !SETTINGS.bichon_enable_access_token || self.is_root {
|
||||
Ok(None) // All accounts are accessible
|
||||
} else {
|
||||
match &self.access_token {
|
||||
Some(token) => Ok(Some(&token.accounts)),
|
||||
None => Err(raise_error!(
|
||||
"Missing access token".into(),
|
||||
ErrorCode::PermissionDenied
|
||||
)),
|
||||
pub async fn require_any_permission(
|
||||
&self,
|
||||
requirements: Vec<(Option<u64>, &str)>,
|
||||
) -> BichonResult<()> {
|
||||
for (account_id, permission) in requirements {
|
||||
if self.has_permission(account_id, permission).await {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(raise_error!(
|
||||
"Access denied: Insufficient permissions to perform this action.".into(),
|
||||
ErrorCode::Forbidden
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn has_permission(&self, account_id: Option<u64>, permission: &str) -> bool {
|
||||
if self.user.is_admin().await {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut global_perms = HashSet::new();
|
||||
for rid in &self.user.global_roles {
|
||||
if let Some(role) = UserRole::find(*rid).await.ok().flatten() {
|
||||
global_perms.extend(role.permissions);
|
||||
}
|
||||
}
|
||||
|
||||
if self.check_global_logic(&global_perms, permission) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(aid) = account_id {
|
||||
if let Some(role_id) = self.user.account_access_map.get(&aid) {
|
||||
if let Some(role) = UserRole::find(*role_id).await.ok().flatten() {
|
||||
if role.permissions.contains(&permission.to_string())
|
||||
|| self.check_account_logic(&role.permissions, permission)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn check_global_logic(&self, global: &HashSet<String>, perm: &str) -> bool {
|
||||
if global.contains(perm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
match perm {
|
||||
Permission::DATA_READ => global.contains(Permission::DATA_READ_ALL),
|
||||
Permission::DATA_DELETE => global.contains(Permission::DATA_DELETE_ALL),
|
||||
Permission::DATA_RAW_DOWNLOAD => global.contains(Permission::DATA_RAW_DOWNLOAD_ALL),
|
||||
Permission::DATA_EXPORT_BATCH => global.contains(Permission::DATA_EXPORT_BATCH_ALL),
|
||||
Permission::ACCOUNT_MANAGE | Permission::ACCOUNT_READ_DETAILS => {
|
||||
global.contains(Permission::ACCOUNT_MANAGE_ALL)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_account_logic(&self, scoped_perms: &BTreeSet<String>, perm: &str) -> bool {
|
||||
if scoped_perms.contains(perm) {
|
||||
return true;
|
||||
}
|
||||
match perm {
|
||||
Permission::DATA_READ | Permission::ACCOUNT_READ_DETAILS => {
|
||||
scoped_perms.contains(Permission::ACCOUNT_MANAGE)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_permission(
|
||||
&self,
|
||||
account_id: Option<u64>,
|
||||
permission: &str,
|
||||
) -> BichonResult<()> {
|
||||
if self.has_permission(account_id, permission).await {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(raise_error!(
|
||||
format!("Access Denied: Missing permission '{}'", permission),
|
||||
ErrorCode::Forbidden
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,98 +176,76 @@ impl<'a> FromRequest<'a> for ClientContext {
|
||||
}
|
||||
|
||||
pub async fn extract_client_context(req: &Request) -> Result<ClientContext> {
|
||||
if SETTINGS.bichon_enable_access_token {
|
||||
let ip_addr = RealIp::from_request_without_body(req)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
create_api_error_response(
|
||||
"Failed to parse client IP address",
|
||||
ErrorCode::InvalidParameter,
|
||||
)
|
||||
})?
|
||||
.0
|
||||
.ok_or_else(|| {
|
||||
create_api_error_response(
|
||||
"Failed to parse client IP address",
|
||||
ErrorCode::InvalidParameter,
|
||||
)
|
||||
})?;
|
||||
// Extract access token from Bearer header or query params
|
||||
let bearer = req
|
||||
.headers()
|
||||
.typed_get::<Authorization<Bearer>>()
|
||||
.map(|auth| auth.0.token().to_string())
|
||||
.or_else(|| req.params::<Param>().ok().map(|param| param.access_token));
|
||||
let ip_addr = RealIp::from_request_without_body(req)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
create_api_error_response(
|
||||
"Failed to parse client IP address",
|
||||
ErrorCode::InvalidParameter,
|
||||
)
|
||||
})?
|
||||
.0
|
||||
.ok_or_else(|| {
|
||||
create_api_error_response(
|
||||
"Failed to parse client IP address",
|
||||
ErrorCode::InvalidParameter,
|
||||
)
|
||||
})?;
|
||||
// Extract access token from Bearer header or query params
|
||||
let bearer = req
|
||||
.headers()
|
||||
.typed_get::<Authorization<Bearer>>()
|
||||
.map(|auth| auth.0.token().to_string())
|
||||
.or_else(|| req.params::<Param>().ok().map(|param| param.access_token));
|
||||
|
||||
let token = bearer.ok_or_else(|| {
|
||||
create_api_error_response("Valid access token not found", ErrorCode::PermissionDenied)
|
||||
let token = bearer.ok_or_else(|| {
|
||||
create_api_error_response("Valid access token not found", ErrorCode::PermissionDenied)
|
||||
})?;
|
||||
|
||||
// Validate and update access token
|
||||
let user = AccessTokenModel::resolve_user_from_token(&token)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
create_api_error_response(&format!("{:#?}", e), ErrorCode::PermissionDenied)
|
||||
})?;
|
||||
|
||||
// Check for root token
|
||||
if let Ok(Some(root)) = SystemSetting::get(ROOT_TOKEN) {
|
||||
if root.value == token {
|
||||
return Ok(ClientContext {
|
||||
ip_addr: Some(ip_addr),
|
||||
access_token: None,
|
||||
is_root: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and update access token
|
||||
let validated_token = AccessToken::try_update_access_timestamp(&token)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
create_api_error_response("Invalid access token", ErrorCode::PermissionDenied)
|
||||
})?;
|
||||
|
||||
return Ok(ClientContext {
|
||||
ip_addr: Some(ip_addr),
|
||||
access_token: Some(validated_token),
|
||||
is_root: false,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Default::default())
|
||||
return Ok(ClientContext {
|
||||
ip_addr: Some(ip_addr),
|
||||
user,
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn authorize_access(req: &Request) -> Result<ClientContext, poem::Error> {
|
||||
let context = extract_client_context(&req).await?;
|
||||
context.require_authorized().map_err(|error| {
|
||||
create_api_error_response(&error.to_string(), ErrorCode::PermissionDenied)
|
||||
})?;
|
||||
|
||||
if let Some(access_token) = &context.access_token {
|
||||
if let Some(access_control) = &access_token.acl {
|
||||
if let Some(ip_addr) = context.ip_addr {
|
||||
if let Some(whitelist) = &access_control.ip_whitelist {
|
||||
if !whitelist.contains(&ip_addr.to_string()) {
|
||||
return Err(create_api_error_response(
|
||||
&format!("IP {} not in whitelist", ip_addr),
|
||||
ErrorCode::PermissionDenied,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rate_limit) = &access_control.rate_limit {
|
||||
if let Err(not_until) = RATE_LIMITER_MANAGER
|
||||
.check(&access_token.token, rate_limit.clone())
|
||||
.await
|
||||
{
|
||||
let wait_duration = not_until.wait_time_from(QuantaClock::default().now());
|
||||
if let Some(access_control) = &context.user.acl {
|
||||
if let Some(ip_addr) = context.ip_addr {
|
||||
if let Some(whitelist) = &access_control.ip_whitelist {
|
||||
if !whitelist.contains(&ip_addr.to_string()) {
|
||||
return Err(create_api_error_response(
|
||||
&format!(
|
||||
"Rate limit: {}/{}s. Retry after {}s",
|
||||
rate_limit.quota,
|
||||
rate_limit.interval,
|
||||
wait_duration.as_secs()
|
||||
),
|
||||
ErrorCode::TooManyRequest,
|
||||
&format!("IP {} not in whitelist", ip_addr),
|
||||
ErrorCode::Forbidden,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rate_limit) = &access_control.rate_limit {
|
||||
if let Err(not_until) = RATE_LIMITER_MANAGER
|
||||
.check(context.user.id, rate_limit.clone())
|
||||
.await
|
||||
{
|
||||
let wait_duration = not_until.wait_time_from(QuantaClock::default().now());
|
||||
return Err(create_api_error_response(
|
||||
&format!(
|
||||
"Rate limit: {}/{}s. Retry after {}s",
|
||||
rate_limit.quota,
|
||||
rate_limit.interval,
|
||||
wait_duration.as_secs()
|
||||
),
|
||||
ErrorCode::TooManyRequest,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(context)
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::modules::error::code::ErrorCode;
|
||||
|
||||
use super::create_api_error_response;
|
||||
|
||||
pub const TIMEOUT_HEADER: &str = "X-RustMailer-Timeout-Seconds";
|
||||
pub const TIMEOUT_HEADER: &str = "X-Bichon-Timeout-Seconds";
|
||||
|
||||
pub struct Timeout;
|
||||
|
||||
@@ -63,7 +63,7 @@ impl<E: Endpoint> Endpoint for TimeoutEndpoint<E> {
|
||||
error!("Request timed out after {} seconds", seconds);
|
||||
Err(create_api_error_response(
|
||||
&format!(
|
||||
"Request timed out after {} seconds (timeout set via X-RustMailer-Timeout-Seconds header, max allowed: 600 seconds)",
|
||||
"Request timed out after {} seconds (timeout set via X-Bichon-Timeout-Seconds header, max allowed: 600 seconds)",
|
||||
seconds
|
||||
),
|
||||
ErrorCode::RequestTimeout,
|
||||
|
||||
@@ -63,7 +63,7 @@ impl EmailClientExecutors {
|
||||
}
|
||||
|
||||
let pool = build_imap_pool(account_id).await?;
|
||||
let new_executor = Arc::new(ImapExecutor::new(pool));
|
||||
let new_executor = Arc::new(ImapExecutor::new(account_id, pool));
|
||||
|
||||
match self.imap.try_entry(account_id) {
|
||||
Some(dashmap::mapref::entry::Entry::Occupied(entry)) => Ok(entry.get().clone()),
|
||||
|
||||
@@ -16,14 +16,17 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use tantivy::{schema::Value, TantivyDocument};
|
||||
|
||||
use crate::{
|
||||
bichon_version,
|
||||
modules::{
|
||||
account::migration::AccountModel,
|
||||
common::auth::ClientContext,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
indexer::{manager::ENVELOPE_INDEX_MANAGER, schema::SchemaTools},
|
||||
settings::dir::DATA_DIR_MANAGER,
|
||||
@@ -50,17 +53,47 @@ pub struct DashboardStats {
|
||||
}
|
||||
|
||||
impl DashboardStats {
|
||||
pub async fn get() -> BichonResult<Self> {
|
||||
let mut stat = ENVELOPE_INDEX_MANAGER.get_dashboard_stats().await?;
|
||||
stat.top_largest_emails = ENVELOPE_INDEX_MANAGER.top_10_largest_emails().await?;
|
||||
stat.email_count = ENVELOPE_INDEX_MANAGER.total_emails()?;
|
||||
stat.account_count = AccountModel::count().await?;
|
||||
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
stat.index_usage_bytes = get_total_size(&DATA_DIR_MANAGER.envelope_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
pub async fn get(context: ClientContext) -> BichonResult<Self> {
|
||||
let has_all_accounts = context
|
||||
.has_permission(None, Permission::ACCOUNT_MANAGE_ALL)
|
||||
.await;
|
||||
|
||||
let authorized_ids: Option<HashSet<u64>> = if has_all_accounts {
|
||||
None
|
||||
} else {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
|
||||
let mut stat = ENVELOPE_INDEX_MANAGER
|
||||
.get_dashboard_stats(&authorized_ids)
|
||||
.await?;
|
||||
|
||||
stat.top_largest_emails = ENVELOPE_INDEX_MANAGER
|
||||
.top_10_largest_emails(&authorized_ids)
|
||||
.await?;
|
||||
|
||||
stat.account_count = if has_all_accounts {
|
||||
AccountModel::count().await?
|
||||
} else {
|
||||
authorized_ids.as_ref().map(|ids| ids.len()).unwrap_or(0)
|
||||
};
|
||||
|
||||
stat.email_count = ENVELOPE_INDEX_MANAGER.total_emails(&authorized_ids)?;
|
||||
|
||||
if has_all_accounts {
|
||||
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
stat.index_usage_bytes = get_total_size(&DATA_DIR_MANAGER.envelope_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
} else {
|
||||
stat.storage_usage_bytes = 0;
|
||||
stat.index_usage_bytes = 0;
|
||||
}
|
||||
|
||||
stat.system_version = bichon_version!().to_string();
|
||||
stat.commit_hash = env!("GIT_HASH").to_string();
|
||||
|
||||
Ok(stat)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::modules::cache::imap::MAILBOX_MODELS;
|
||||
use crate::modules::error::{code::ErrorCode, BichonError};
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::modules::settings::dir::DATA_DIR_MANAGER;
|
||||
use crate::modules::users::UserModel;
|
||||
use crate::modules::{database::META_MODELS, error::BichonResult};
|
||||
use crate::raise_error;
|
||||
use native_db::{Builder, Database};
|
||||
@@ -73,6 +74,8 @@ impl DatabaseManager {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
rw.migrate::<AccountModel>()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
rw.migrate::<UserModel>()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
rw.commit()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
|
||||
+46
-19
@@ -16,7 +16,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::modules::account::migration::{AccountV1, AccountV2};
|
||||
use crate::modules::account::migration::{AccountV1, AccountV2, AccountV3};
|
||||
use crate::modules::autoconfig::CachedMailSettings;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
@@ -25,7 +25,9 @@ use crate::modules::oauth2::pending::OAuth2PendingEntity;
|
||||
use crate::modules::oauth2::token::OAuth2AccessToken;
|
||||
use crate::modules::settings::proxy::Proxy;
|
||||
use crate::modules::settings::system::SystemSetting;
|
||||
use crate::modules::token::AccessToken;
|
||||
use crate::modules::token::AccessTokenModel;
|
||||
use crate::modules::users::role::UserRole;
|
||||
use crate::modules::users::{BichonUser, BichonUserV2};
|
||||
use crate::raise_error;
|
||||
use db_type::{KeyOptions, ToKeyDefinition};
|
||||
use itertools::Itertools;
|
||||
@@ -58,15 +60,21 @@ impl ModelsAdapter {
|
||||
}
|
||||
|
||||
pub fn register_metadata_models(&mut self) {
|
||||
self.register_model::<AccessToken>();
|
||||
//Starting from version 0.2.0, `AccessToken` is deprecated/no longer used, but its ID must not be reused, otherwise it may cause model errors.
|
||||
//self.register_model::<AccessToken>();
|
||||
self.register_model::<SystemSetting>();
|
||||
self.register_model::<CachedMailSettings>();
|
||||
self.register_model::<AccountV1>();
|
||||
self.register_model::<AccountV2>();
|
||||
self.register_model::<AccountV3>();
|
||||
self.register_model::<OAuth2>();
|
||||
self.register_model::<OAuth2PendingEntity>();
|
||||
self.register_model::<OAuth2AccessToken>();
|
||||
self.register_model::<Proxy>();
|
||||
self.register_model::<UserRole>();
|
||||
self.register_model::<BichonUser>();
|
||||
self.register_model::<BichonUserV2>();
|
||||
self.register_model::<AccessTokenModel>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,11 +178,11 @@ pub async fn update_impl<T: ToInput + Clone + std::fmt::Debug + Send + 'static>(
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let current_item = current(&rw)?;
|
||||
let updated_item = updated(¤t_item)?;
|
||||
rw.update(current_item.clone(), updated_item)
|
||||
rw.update(current_item, updated_item.clone())
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
rw.commit()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
Ok(current_item)
|
||||
Ok(updated_item)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
@@ -223,20 +231,20 @@ pub async fn async_find_impl<T: ToInput + Clone + Send + 'static>(
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
}
|
||||
|
||||
pub fn find_impl<T: ToInput + Clone + Send + 'static>(
|
||||
database: &Arc<Database<'static>>,
|
||||
key: &str,
|
||||
) -> BichonResult<Option<T>> {
|
||||
let db = database.clone();
|
||||
let r_transaction = db
|
||||
.r_transaction()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let entity: Option<T> = r_transaction
|
||||
.get()
|
||||
.primary(key)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
Ok(entity)
|
||||
}
|
||||
// pub fn find_impl<T: ToInput + Clone + Send + 'static>(
|
||||
// database: &Arc<Database<'static>>,
|
||||
// key: &str,
|
||||
// ) -> BichonResult<Option<T>> {
|
||||
// let db = database.clone();
|
||||
// let r_transaction = db
|
||||
// .r_transaction()
|
||||
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
// let entity: Option<T> = r_transaction
|
||||
// .get()
|
||||
// .primary(key)
|
||||
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
// Ok(entity)
|
||||
// }
|
||||
|
||||
pub async fn delete_impl<T: ToInput + Clone + Send + 'static>(
|
||||
database: &Arc<Database<'static>>,
|
||||
@@ -307,6 +315,25 @@ pub async fn list_all_impl<T: ToInput + Clone + Send + 'static>(
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
}
|
||||
|
||||
pub async fn with_transaction(
|
||||
database: &Arc<Database<'static>>,
|
||||
f: impl FnOnce(&RwTransaction) -> BichonResult<()> + Send + 'static,
|
||||
) -> BichonResult<()> {
|
||||
let db: Arc<Database<'_>> = database.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let rw_transaction = db
|
||||
.rw_transaction()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
f(&rw_transaction)?;
|
||||
rw_transaction
|
||||
.commit()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
}
|
||||
|
||||
// For tables with a creation timestamp, place the creation time at the front of the primary key.
|
||||
// This allows sorting by time, as the data is stored in dictionary order based on the primary key.
|
||||
// If reverse sorting by time is needed, the iterator can be reversed.
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::modules::common::AddrVec;
|
||||
use crate::modules::envelope::utils::normalize_subject;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::modules::utils::create_hash;
|
||||
use crate::{calculate_hash, raise_error, utc_now};
|
||||
use crate::{id, modules::indexer::envelope::Envelope};
|
||||
use async_imap::types::Fetch;
|
||||
use html2text::from_read;
|
||||
use mail_parser::{Message, MessageParser, MimeHeaders};
|
||||
use mail_parser::{HeaderName, Message, MessageParser, MimeHeaders};
|
||||
|
||||
pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> BichonResult<Envelope> {
|
||||
let internal_date = fetch
|
||||
@@ -48,7 +48,9 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
|
||||
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
|
||||
text
|
||||
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
|
||||
from_read(html.as_bytes(), 0)
|
||||
html2text::config::plain()
|
||||
.allow_width_overflow()
|
||||
.string_from_read(html.as_bytes(), 100)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
} else {
|
||||
String::new()
|
||||
@@ -61,7 +63,13 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
|
||||
let in_reply_to = message.in_reply_to().as_text().map(String::from);
|
||||
let references = extract_references(&message);
|
||||
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
|
||||
let subject = message.subject().map(String::from).unwrap_or("".into());
|
||||
|
||||
let mut subject = message.subject().map(String::from).unwrap_or_default();
|
||||
|
||||
if subject.contains('\u{FFFD}') {
|
||||
subject = normalize_subject(message.header_raw(HeaderName::Subject));
|
||||
}
|
||||
|
||||
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
|
||||
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
|
||||
AddrVec::from(addr)
|
||||
@@ -113,6 +121,8 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
|
||||
thread_id,
|
||||
attachments,
|
||||
tags: None,
|
||||
account_email: None,
|
||||
mailbox_name: None,
|
||||
};
|
||||
Ok(envelope)
|
||||
}
|
||||
@@ -134,7 +144,9 @@ pub fn extract_envelope_from_eml(
|
||||
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
|
||||
text
|
||||
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
|
||||
from_read(html.as_bytes(), 0)
|
||||
html2text::config::plain()
|
||||
.allow_width_overflow()
|
||||
.string_from_read(html.as_bytes(), 100)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
} else {
|
||||
String::new()
|
||||
@@ -147,7 +159,12 @@ pub fn extract_envelope_from_eml(
|
||||
let in_reply_to = message.in_reply_to().as_text().map(String::from);
|
||||
let references = extract_references(&message);
|
||||
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
|
||||
let subject = message.subject().map(String::from).unwrap_or("".into());
|
||||
|
||||
let mut subject = message.subject().map(String::from).unwrap_or_default();
|
||||
if subject.contains('\u{FFFD}') {
|
||||
subject = normalize_subject(message.header_raw(HeaderName::Subject));
|
||||
}
|
||||
|
||||
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
|
||||
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
|
||||
AddrVec::from(addr)
|
||||
@@ -199,6 +216,8 @@ pub fn extract_envelope_from_eml(
|
||||
thread_id,
|
||||
attachments,
|
||||
tags: None,
|
||||
account_email: None,
|
||||
mailbox_name: None,
|
||||
};
|
||||
Ok(envelope)
|
||||
}
|
||||
@@ -229,3 +248,52 @@ fn extract_references(message: &Message<'_>) -> Option<Vec<String>> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use html2text::config;
|
||||
|
||||
#[test]
|
||||
fn test_various_html_with_overflow_enabled() {
|
||||
let cases = [
|
||||
("<p>Hello World</p>", "Simple paragraph"),
|
||||
("<h1>Title</h1><p>Content</p>", "Heading + paragraph"),
|
||||
("<ul><li>Item1</li><li>Item2</li></ul>", "Unordered list"),
|
||||
(
|
||||
"<strong>Bold</strong> and <em>italic</em>",
|
||||
"Inline formatting",
|
||||
),
|
||||
(
|
||||
"<div><span>Nested</span> elements</div>",
|
||||
"Nested inline elements inside block",
|
||||
),
|
||||
(
|
||||
"<table><tr><td>A</td><td>B</td></tr></table>",
|
||||
"Simple table",
|
||||
),
|
||||
(
|
||||
"<pre> preformatted text\n line2</pre>",
|
||||
"Preformatted block",
|
||||
),
|
||||
("😃 emoji test", "Wide emoji"),
|
||||
("<a href=\"#\">link</a>", "Anchor tag"),
|
||||
(
|
||||
"<blockquote><p>Quoted text</p></blockquote>",
|
||||
"Blockquote with paragraph",
|
||||
),
|
||||
];
|
||||
|
||||
for (html, desc) in cases {
|
||||
let result = config::plain()
|
||||
.allow_width_overflow()
|
||||
.string_from_read(html.as_bytes(), 100);
|
||||
|
||||
match result {
|
||||
Ok(output) => {
|
||||
println!("✓ Rendered ({}) =>\n{}", desc, output);
|
||||
}
|
||||
Err(e) => panic!("Unexpected error for {}: {:?}", desc, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
pub mod extractor;
|
||||
pub mod utils;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
use mail_parser::parsers::MessageStream;
|
||||
use regex::{Captures, Regex};
|
||||
|
||||
fn merge_contiguous_encoded_words(input: &str) -> String {
|
||||
let block_re =
|
||||
Regex::new(r"(?:=\?[^?]+\?[bBqQ]\?[^?]+\?=)(?:\s+(?:=\?[^?]+\?[bBqQ]\?[^?]+\?=))+")
|
||||
.unwrap();
|
||||
|
||||
let word_re = Regex::new(r"=\?([^?]+)\?([bBqQ])\?([^?]+)\?=").unwrap();
|
||||
|
||||
block_re
|
||||
.replace_all(input, |caps: &Captures| {
|
||||
let whole = caps.get(0).unwrap().as_str();
|
||||
|
||||
let mut charset: Option<String> = None;
|
||||
let mut encoding: Option<String> = None;
|
||||
let mut combined = String::new();
|
||||
let mut ok = true;
|
||||
|
||||
for cap in word_re.captures_iter(whole) {
|
||||
let cs = &cap[1];
|
||||
let enc = cap[2].to_ascii_uppercase();
|
||||
let text = &cap[3];
|
||||
|
||||
if let Some(ref c) = charset {
|
||||
if c != cs {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
charset = Some(cs.to_string());
|
||||
}
|
||||
|
||||
if let Some(ref e) = encoding {
|
||||
if e != &enc {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
encoding = Some(enc);
|
||||
}
|
||||
|
||||
combined.push_str(text);
|
||||
}
|
||||
|
||||
if ok {
|
||||
format!(
|
||||
"=?{}?{}?{}?=",
|
||||
charset.unwrap(),
|
||||
encoding.unwrap(),
|
||||
combined
|
||||
)
|
||||
} else {
|
||||
whole.to_string()
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn normalize_subject(raw_subject: Option<&str>) -> String {
|
||||
let subject = match raw_subject {
|
||||
Some(subject) => merge_contiguous_encoded_words(subject),
|
||||
None => return String::new(),
|
||||
};
|
||||
|
||||
MessageStream::new(subject.as_bytes())
|
||||
.parse_unstructured()
|
||||
.as_text()
|
||||
.map(String::from)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::modules::envelope::utils::merge_contiguous_encoded_words;
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn test3() {
|
||||
let s = "Hello =?UTF-8?B?SGVsbG8=?= =?UTF-8?B?V29ybGQ=?= !!!";
|
||||
assert_eq!(
|
||||
merge_contiguous_encoded_words(s),
|
||||
"Hello =?UTF-8?B?SGVsbG8=V29ybGQ=?= !!!"
|
||||
);
|
||||
|
||||
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= =?UTF-8?B?Qw==?=";
|
||||
assert_eq!(
|
||||
merge_contiguous_encoded_words(s),
|
||||
"=?UTF-8?B?QQ==Qg==Qw==?="
|
||||
);
|
||||
|
||||
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= test =?UTF-8?B?Qw==?= =?UTF-8?B?RA==?=";
|
||||
assert_eq!(
|
||||
merge_contiguous_encoded_words(s),
|
||||
"=?UTF-8?B?QQ==Qg==?= test =?UTF-8?B?Qw==RA==?="
|
||||
);
|
||||
|
||||
let s = "=?UTF-8?B?QQ==?= =?GBK?B?Qg==?=";
|
||||
assert_eq!(merge_contiguous_encoded_words(s), s);
|
||||
let s = "=?UTF-8?B?QQ==?= =?UTF-8?Q?Qg?=";
|
||||
assert_eq!(merge_contiguous_encoded_words(s), s);
|
||||
|
||||
let s = "=?UTF-8?b?QQ==?= =?UTF-8?B?Qg==?=";
|
||||
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
|
||||
let s = "Hello =?UTF-8?B?SGVsbG8=?= !!!";
|
||||
assert_eq!(merge_contiguous_encoded_words(s), s);
|
||||
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?=";
|
||||
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
|
||||
let s = "Just a normal subject line";
|
||||
assert_eq!(merge_contiguous_encoded_words(s), s);
|
||||
let s = "=?UTF-8?Q?Hello_?= =?UTF-8?Q?World?=";
|
||||
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?Q?Hello_World?=");
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use poem::http::StatusCode;
|
||||
use poem_openapi::Enum;
|
||||
|
||||
@@ -34,12 +33,14 @@ pub enum ErrorCode {
|
||||
// Authentication and authorization errors (20000–20999)
|
||||
PermissionDenied = 20000,
|
||||
AccountDisabled = 20010,
|
||||
Forbidden = 20020,
|
||||
OAuth2ItemDisabled = 20050,
|
||||
MissingRefreshToken = 20060,
|
||||
|
||||
// Resource errors (30000–30999)
|
||||
ResourceNotFound = 30000,
|
||||
TooManyRequest = 30020,
|
||||
AlreadyExists = 30030,
|
||||
|
||||
// Network connection errors (40000–40999)
|
||||
NetworkError = 40000,
|
||||
@@ -64,11 +65,14 @@ impl ErrorCode {
|
||||
| ErrorCode::MissingConfiguration
|
||||
| ErrorCode::Incompatible => StatusCode::BAD_REQUEST,
|
||||
ErrorCode::PermissionDenied => StatusCode::UNAUTHORIZED,
|
||||
ErrorCode::AccountDisabled | ErrorCode::OAuth2ItemDisabled => StatusCode::FORBIDDEN,
|
||||
ErrorCode::AccountDisabled | ErrorCode::OAuth2ItemDisabled | ErrorCode::Forbidden => {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
ErrorCode::ResourceNotFound => StatusCode::NOT_FOUND,
|
||||
ErrorCode::RequestTimeout => StatusCode::REQUEST_TIMEOUT,
|
||||
ErrorCode::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
|
||||
ErrorCode::TooManyRequest => StatusCode::TOO_MANY_REQUESTS,
|
||||
ErrorCode::AlreadyExists => StatusCode::CONFLICT,
|
||||
ErrorCode::InternalError
|
||||
| ErrorCode::AutoconfigFetchFailed
|
||||
| ErrorCode::ImapCommandFailed
|
||||
|
||||
@@ -16,15 +16,11 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use std::{fmt::Formatter, u32};
|
||||
|
||||
use crate::raise_error;
|
||||
use bb8::RunError;
|
||||
use code::ErrorCode;
|
||||
use poem::http::StatusCode;
|
||||
use poem_openapi::{payload::Json, ApiResponse, Object};
|
||||
use snafu::{Location, Snafu};
|
||||
use std::{fmt::Formatter, u32};
|
||||
|
||||
pub mod code;
|
||||
pub mod handler;
|
||||
@@ -43,17 +39,6 @@ pub enum BichonError {
|
||||
|
||||
pub type BichonResult<T, E = BichonError> = std::result::Result<T, E>;
|
||||
|
||||
impl From<RunError<BichonError>> for BichonError {
|
||||
fn from(e: RunError<BichonError>) -> Self {
|
||||
match e {
|
||||
RunError::User(e) => e,
|
||||
RunError::TimedOut => raise_error!(
|
||||
"Timed out while attempting to acquire a connection from the pool".into(),
|
||||
ErrorCode::ConnectionPoolTimeout
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Object)]
|
||||
pub struct ApiError {
|
||||
pub message: String,
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::account::migration::AccountModel;
|
||||
use crate::modules::account::state::AccountRunningState;
|
||||
use crate::modules::cache::imap::mailbox::MailBox;
|
||||
use crate::modules::cache::imap::sync::flow::{generate_uid_sequence_hashset, BATCH_SIZE};
|
||||
use crate::modules::cache::imap::sync::flow::{generate_uid_sequence_hashset, DEFAULT_BATCH_SIZE};
|
||||
use crate::modules::envelope::extractor::extract_envelope;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER};
|
||||
@@ -27,7 +27,7 @@ use crate::modules::indexer::schema::SchemaTools;
|
||||
use crate::modules::{error::BichonResult, imap::manager::ImapConnectionManager};
|
||||
use crate::raise_error;
|
||||
use async_imap::types::{Mailbox, Name};
|
||||
use bb8::Pool;
|
||||
use bb8::{Pool, RunError};
|
||||
use futures::TryStreamExt;
|
||||
use std::collections::HashSet;
|
||||
use tantivy::doc;
|
||||
@@ -36,16 +36,17 @@ use tracing::info;
|
||||
const BODY_FETCH_COMMAND: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[])";
|
||||
|
||||
pub struct ImapExecutor {
|
||||
account_id: u64,
|
||||
pool: Pool<ImapConnectionManager>,
|
||||
}
|
||||
|
||||
impl ImapExecutor {
|
||||
pub fn new(pool: Pool<ImapConnectionManager>) -> Self {
|
||||
Self { pool }
|
||||
pub fn new(account_id: u64, pool: Pool<ImapConnectionManager>) -> Self {
|
||||
Self { account_id, pool }
|
||||
}
|
||||
|
||||
pub async fn list_all_mailboxes(&self) -> BichonResult<Vec<Name>> {
|
||||
let mut session = self.pool.get().await?;
|
||||
let mut session = self.get_connection().await?;
|
||||
let list = session
|
||||
.list(Some(""), Some("*"))
|
||||
.await
|
||||
@@ -58,7 +59,7 @@ impl ImapExecutor {
|
||||
}
|
||||
|
||||
pub async fn examine_mailbox(&self, mailbox_name: &str) -> BichonResult<Mailbox> {
|
||||
let mut session = self.pool.get().await?;
|
||||
let mut session = self.get_connection().await?;
|
||||
session
|
||||
.examine(mailbox_name)
|
||||
.await
|
||||
@@ -66,7 +67,7 @@ impl ImapExecutor {
|
||||
}
|
||||
|
||||
pub async fn uid_search(&self, mailbox_name: &str, query: &str) -> BichonResult<HashSet<u32>> {
|
||||
let mut session = self.pool.get().await?;
|
||||
let mut session = self.get_connection().await?;
|
||||
session
|
||||
.examine(mailbox_name)
|
||||
.await
|
||||
@@ -78,19 +79,35 @@ impl ImapExecutor {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn append(
|
||||
&self,
|
||||
mailbox_name: impl AsRef<str>,
|
||||
flags: Option<&str>,
|
||||
internaldate: Option<&str>,
|
||||
content: impl AsRef<[u8]>,
|
||||
) -> BichonResult<()> {
|
||||
let mut session = self.get_connection().await?;
|
||||
session
|
||||
.append(mailbox_name, flags, internaldate, content)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
|
||||
}
|
||||
|
||||
pub async fn fetch_new_mail(
|
||||
&self,
|
||||
account_id: u64,
|
||||
account: &AccountModel,
|
||||
mailbox: &MailBox,
|
||||
start_uid: u64,
|
||||
before: Option<&str>,
|
||||
) -> BichonResult<()> {
|
||||
assert!(start_uid > 0, "start_uid must be greater than 0");
|
||||
let uid_list = self
|
||||
.uid_search(
|
||||
&mailbox.encoded_name(),
|
||||
format!("UID {start_uid}:*").as_str(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let query = match before {
|
||||
Some(date) => format!("UID {start_uid}:* BEFORE {date}"),
|
||||
None => format!("UID {start_uid}:*"),
|
||||
};
|
||||
|
||||
let uid_list = self.uid_search(&mailbox.encoded_name(), &query).await?;
|
||||
|
||||
let len = uid_list.len();
|
||||
if len == 0 {
|
||||
@@ -98,17 +115,21 @@ impl ImapExecutor {
|
||||
}
|
||||
info!(
|
||||
"[account {}][mailbox {}] {} envelopes need to be fetched",
|
||||
account_id, mailbox.name, len
|
||||
account.id, mailbox.name, len
|
||||
);
|
||||
|
||||
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
|
||||
uid_vec.sort();
|
||||
let uid_batches = generate_uid_sequence_hashset(uid_vec, BATCH_SIZE as usize, false);
|
||||
let uid_batches = generate_uid_sequence_hashset(
|
||||
uid_vec,
|
||||
account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
|
||||
false,
|
||||
);
|
||||
|
||||
let too_many = len as u32 > 10 * BATCH_SIZE;
|
||||
let too_many = len as u32 > 5 * account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
|
||||
if too_many {
|
||||
AccountRunningState::set_initial_current_syncing_folder(
|
||||
account_id,
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
uid_batches.len() as u32,
|
||||
)
|
||||
@@ -118,13 +139,13 @@ impl ImapExecutor {
|
||||
for (index, batch) in uid_batches.into_iter().enumerate() {
|
||||
if too_many {
|
||||
AccountRunningState::set_current_sync_batch_number(
|
||||
account_id,
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
(index + 1) as u32,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
self.uid_batch_retrieve_emails(account_id, mailbox.id, &batch, &mailbox.encoded_name())
|
||||
self.uid_batch_retrieve_emails(account.id, mailbox.id, &batch, &mailbox.encoded_name())
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -142,7 +163,7 @@ impl ImapExecutor {
|
||||
assert!(page > 0, "Page number must be greater than 0");
|
||||
assert!(page_size > 0, "Page size must be greater than 0");
|
||||
|
||||
let mut session = self.pool.get().await?;
|
||||
let mut session = self.get_connection().await?;
|
||||
let total = session
|
||||
.examine(encoded_mailbox_name)
|
||||
.await
|
||||
@@ -211,7 +232,7 @@ impl ImapExecutor {
|
||||
uid_set: &str,
|
||||
encoded_mailbox_name: &str,
|
||||
) -> BichonResult<()> {
|
||||
let mut session = self.pool.get().await?;
|
||||
let mut session = self.get_connection().await?;
|
||||
session
|
||||
.examine(encoded_mailbox_name)
|
||||
.await
|
||||
@@ -238,4 +259,41 @@ impl ImapExecutor {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_connection(
|
||||
&self,
|
||||
) -> BichonResult<bb8::PooledConnection<'_, ImapConnectionManager>> {
|
||||
match self.pool.get().await {
|
||||
Ok(connection) => Ok(connection),
|
||||
Err(e) => match e {
|
||||
RunError::User(e) => Err(e),
|
||||
RunError::TimedOut => {
|
||||
let state = self.pool.state();
|
||||
tracing::warn!(
|
||||
"{}: connections={}, idle={}, \
|
||||
get_started={}, get_direct={}, get_waited={}, get_timed_out={}, \
|
||||
wait_time_ms={}, created={}, closed_broken={}, closed_invalid={}, \
|
||||
closed_lifetime={}, closed_idle={}",
|
||||
self.account_id,
|
||||
state.connections,
|
||||
state.idle_connections,
|
||||
state.statistics.get_started,
|
||||
state.statistics.get_direct,
|
||||
state.statistics.get_waited,
|
||||
state.statistics.get_timed_out,
|
||||
state.statistics.get_wait_time.as_millis(),
|
||||
state.statistics.connections_created,
|
||||
state.statistics.connections_closed_broken,
|
||||
state.statistics.connections_closed_invalid,
|
||||
state.statistics.connections_closed_max_lifetime,
|
||||
state.statistics.connections_closed_idle_timeout,
|
||||
);
|
||||
return Err(raise_error!(
|
||||
"Timed out while attempting to acquire a connection from the pool".into(),
|
||||
ErrorCode::ConnectionPoolTimeout
|
||||
));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,13 @@ impl ImapConnectionManager {
|
||||
})?;
|
||||
|
||||
let password = decrypt!(&password)?;
|
||||
client.login(&username, &password).await
|
||||
client.login(&username, &password).await.map_err(|e| {
|
||||
error!(
|
||||
"IMAP password auth failed for username '{}': {}",
|
||||
username, e
|
||||
);
|
||||
e
|
||||
})
|
||||
}
|
||||
AuthType::OAuth2 => {
|
||||
let record = OAuth2AccessToken::get(self.account_id).await?;
|
||||
@@ -90,8 +96,12 @@ impl ImapConnectionManager {
|
||||
)
|
||||
})?;
|
||||
client
|
||||
.authenticate(OAuth2::new(username, access_token))
|
||||
.authenticate(OAuth2::new(username.clone(), access_token))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("IMAP OAuth2 auth failed for username '{}': {}", username, e);
|
||||
e
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::{BichonError, BichonResult};
|
||||
use crate::modules::imap::{manager::ImapConnectionManager, session::SessionStream};
|
||||
@@ -49,7 +48,7 @@ pub async fn build_imap_pool(account_id: u64) -> BichonResult<Pool<ImapConnectio
|
||||
let manager = ImapConnectionManager::new(account_id);
|
||||
let pool = Pool::builder()
|
||||
.connection_timeout(Duration::from_secs(30))
|
||||
.idle_timeout(Duration::from_secs(120))
|
||||
//.idle_timeout(Duration::from_secs(120))
|
||||
.retry_connection(true)
|
||||
.max_size(10)
|
||||
.test_on_check_out(true)
|
||||
|
||||
@@ -16,11 +16,13 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use mail_parser::MessageParser;
|
||||
use mail_parser::{parsers::MessageStream, HeaderName, MessageParser};
|
||||
|
||||
use crate::{
|
||||
base64_encode_url_safe,
|
||||
modules::{account::entity::Encryption, imap::client::Client},
|
||||
modules::{
|
||||
account::entity::Encryption, envelope::utils::normalize_subject, imap::client::Client,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -48,3 +50,72 @@ async fn test1() {
|
||||
println!("{}", part.is_multipart());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test2() {
|
||||
const MESSAGE: &str = r#"From: Art Vandelay <art@vandelay.com> (Vandelay Industries)
|
||||
To: "Colleagues": "James Smythe" <james@vandelay.com>; Friends:
|
||||
jane@example.com, =?UTF-8?Q?John_Sm=C3=AEth?= <john@example.com>;
|
||||
Date: Sat, 20 Nov 2021 14:22:01 -0800
|
||||
Subject: =?utf-8?B?SnVzdCAxNSBkYXlzIGxlZnQgdG8gdmlzaXQgTkFSTklBISDinYTvuI/wn462?=
|
||||
Content-Type: multipart/mixed; boundary="festivus";
|
||||
|
||||
--festivus
|
||||
Content-Type: text/html; charset="us-ascii"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
PGh0bWw+PHA+SSB3YXMgdGhpbmtpbmcgYWJvdXQgcXVpdHRpbmcgdGhlICZsZHF1bztle
|
||||
HBvcnRpbmcmcmRxdW87IHRvIGZvY3VzIGp1c3Qgb24gdGhlICZsZHF1bztpbXBvcnRpbm
|
||||
cmcmRxdW87LDwvcD48cD5idXQgdGhlbiBJIHRob3VnaHQsIHdoeSBub3QgZG8gYm90aD8
|
||||
gJiN4MjYzQTs8L3A+PC9odG1sPg==
|
||||
--festivus
|
||||
Content-Type: message/rfc822
|
||||
|
||||
From: "Cosmo Kramer" <kramer@kramerica.com>
|
||||
Subject: Exporting my book about coffee tables
|
||||
Content-Type: multipart/mixed; boundary="giddyup";
|
||||
|
||||
--giddyup
|
||||
Content-Type: text/plain; charset="utf-16"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
=FF=FE=0C!5=D8"=DD5=D8)=DD5=D8-=DD =005=D8*=DD5=D8"=DD =005=D8"=
|
||||
=DD5=D85=DD5=D8-=DD5=D8,=DD5=D8/=DD5=D81=DD =005=D8*=DD5=D86=DD =
|
||||
=005=D8=1F=DD5=D8,=DD5=D8,=DD5=D8(=DD =005=D8-=DD5=D8)=DD5=D8"=
|
||||
=DD5=D8=1E=DD5=D80=DD5=D8"=DD!=00
|
||||
--giddyup
|
||||
Content-Type: image/gif; name*1="about "; name*0="Book ";
|
||||
name*2*=utf-8''%e2%98%95 tables.gif
|
||||
Content-Transfer-Encoding: Base64
|
||||
Content-Disposition: attachment
|
||||
|
||||
R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
|
||||
--giddyup--
|
||||
--festivus--
|
||||
"#;
|
||||
|
||||
let message = MessageParser::default().parse(MESSAGE).unwrap();
|
||||
let raw_subject = message.header_raw("Subject").unwrap().as_bytes();
|
||||
|
||||
let data = MessageStream::new(raw_subject)
|
||||
.parse_unstructured()
|
||||
.unwrap_text()
|
||||
.to_string();
|
||||
|
||||
println!("{}", data);
|
||||
// RFC2047 support for encoded text in message readers
|
||||
println!("{}", message.subject().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test44() {
|
||||
let path = r"C:\Users\polly\Downloads\test222.eml";
|
||||
let input = std::fs::read(path).unwrap();
|
||||
let message = MessageParser::default().parse(&input).unwrap();
|
||||
let subject = message.subject().unwrap();
|
||||
println!("Subject: {}", subject);
|
||||
if subject.contains('\u{FFFD}') {
|
||||
let subject = normalize_subject(message.header_raw(HeaderName::Subject));
|
||||
println!("Subject: {}", subject);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::account::migration::AccountModel;
|
||||
use crate::modules::cache::imap::mailbox::MailBox;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::utils::create_hash;
|
||||
use crate::modules::{error::BichonResult, indexer::schema::SchemaTools};
|
||||
@@ -31,7 +32,9 @@ pub struct Envelope {
|
||||
pub id: u64,
|
||||
pub message_id: String,
|
||||
pub account_id: u64,
|
||||
pub account_email: Option<String>,
|
||||
pub mailbox_id: u64,
|
||||
pub mailbox_name: Option<String>,
|
||||
pub uid: u32,
|
||||
pub subject: String,
|
||||
pub text: String,
|
||||
@@ -169,11 +172,20 @@ impl Envelope {
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
let account_email = AccountModel::find(account_id).await?.map(|a| a.email);
|
||||
|
||||
let mailboxes = MailBox::list_all(account_id).await?;
|
||||
let mailbox_name = mailboxes
|
||||
.iter()
|
||||
.find(|m| m.id == mailbox_id)
|
||||
.map(|m| m.name.clone());
|
||||
|
||||
let envelope = Envelope {
|
||||
id,
|
||||
account_id,
|
||||
account_email,
|
||||
mailbox_id,
|
||||
mailbox_name,
|
||||
message_id: extract_string_field(doc, fields.f_message_id)?,
|
||||
uid: extract_u64_field(doc, fields.f_uid)? as u32,
|
||||
subject: extract_string_field(doc, fields.f_subject)?,
|
||||
|
||||
+169
-21
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
ops::Bound,
|
||||
@@ -35,8 +34,8 @@ use crate::{
|
||||
indexer::{
|
||||
envelope::Envelope,
|
||||
fields::{
|
||||
F_ACCOUNT_ID, F_FROM, F_HAS_ATTACHMENT, F_INTERNAL_DATE, F_MAILBOX_ID, F_SIZE,
|
||||
F_TAGS, F_THREAD_ID, F_UID,
|
||||
F_ACCOUNT_ID, F_DATE, F_FROM, F_HAS_ATTACHMENT, F_MAILBOX_ID, F_SIZE, F_TAGS,
|
||||
F_THREAD_ID, F_UID,
|
||||
},
|
||||
schema::SchemaTools,
|
||||
},
|
||||
@@ -58,7 +57,7 @@ use tantivy::{
|
||||
AggregationCollector, Key,
|
||||
},
|
||||
collector::{Count, FacetCollector, TopDocs},
|
||||
query::{AllQuery, BooleanQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
|
||||
query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
|
||||
schema::{Facet, IndexRecordOption, Value},
|
||||
store::{Compressor, ZstdCompressor},
|
||||
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
|
||||
@@ -194,9 +193,29 @@ impl EnvelopeIndexManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn total_emails(&self) -> BichonResult<u64> {
|
||||
pub fn total_emails(&self, accounts: &Option<HashSet<u64>>) -> BichonResult<u64> {
|
||||
let searcher = self.create_searcher()?;
|
||||
Ok(searcher.num_docs())
|
||||
|
||||
match accounts {
|
||||
Some(ref ids) if !ids.is_empty() => {
|
||||
let mut subqueries = Vec::new();
|
||||
for &id in ids {
|
||||
let term =
|
||||
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
|
||||
subqueries.push((
|
||||
Occur::Should,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
|
||||
));
|
||||
}
|
||||
let query = Box::new(BooleanQuery::new(subqueries)) as Box<dyn Query>;
|
||||
let count = searcher
|
||||
.search(&query, &Count)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
Some(_) => Ok(0),
|
||||
None => Ok(searcher.num_docs()),
|
||||
}
|
||||
}
|
||||
|
||||
fn account_query(&self, account_id: u64) -> Box<TermQuery> {
|
||||
@@ -223,12 +242,36 @@ impl EnvelopeIndexManager {
|
||||
|
||||
fn filter_query(
|
||||
&self,
|
||||
accounts: Option<HashSet<u64>>,
|
||||
filter: SearchFilter,
|
||||
parser: QueryParser,
|
||||
) -> BichonResult<Box<dyn Query>> {
|
||||
let f = SchemaTools::envelope_fields();
|
||||
let mut subqueries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
|
||||
|
||||
if let Some(authorized_ids) = accounts {
|
||||
if authorized_ids.is_empty() {
|
||||
let term = Term::from_field_u64(f.f_account_id, u64::MAX);
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
} else {
|
||||
let mut account_must_queries = Vec::new();
|
||||
for id in authorized_ids {
|
||||
let term = Term::from_field_u64(f.f_account_id, id);
|
||||
account_must_queries.push((
|
||||
Occur::Should,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
|
||||
));
|
||||
}
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(BooleanQuery::new(account_must_queries)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref text) = filter.text {
|
||||
let query = parser
|
||||
.parse_query(text)
|
||||
@@ -292,13 +335,13 @@ impl EnvelopeIndexManager {
|
||||
}
|
||||
|
||||
let start_bound = if let Some(from) = filter.since {
|
||||
Bound::Included(Term::from_field_i64(f.f_internal_date, from))
|
||||
Bound::Included(Term::from_field_i64(f.f_date, from))
|
||||
} else {
|
||||
Bound::Unbounded
|
||||
};
|
||||
|
||||
let end_bound = if let Some(to) = filter.before {
|
||||
Bound::Included(Term::from_field_i64(f.f_internal_date, to))
|
||||
Bound::Included(Term::from_field_i64(f.f_date, to))
|
||||
} else {
|
||||
Bound::Unbounded
|
||||
};
|
||||
@@ -426,14 +469,16 @@ impl EnvelopeIndexManager {
|
||||
}
|
||||
|
||||
fn collect_facets_recursive(
|
||||
query: &dyn Query,
|
||||
searcher: &Searcher,
|
||||
parent_facet: &str,
|
||||
all_facets: &mut Vec<TagCount>,
|
||||
) -> BichonResult<()> {
|
||||
let mut facet_collector = FacetCollector::for_field(F_TAGS);
|
||||
facet_collector.add_facet(parent_facet);
|
||||
|
||||
let facet_counts = searcher
|
||||
.search(&AllQuery, &facet_collector)
|
||||
.search(query, &facet_collector)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
for (facet, count) in facet_counts.get(parent_facet) {
|
||||
@@ -441,16 +486,37 @@ impl EnvelopeIndexManager {
|
||||
tag: facet.to_string(),
|
||||
count,
|
||||
});
|
||||
Self::collect_facets_recursive(searcher, &facet.to_string(), all_facets)?;
|
||||
Self::collect_facets_recursive(query, searcher, &facet.to_string(), all_facets)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_all_tags(&self) -> BichonResult<Vec<TagCount>> {
|
||||
pub async fn get_all_tags(
|
||||
&self,
|
||||
accounts: Option<HashSet<u64>>,
|
||||
) -> BichonResult<Vec<TagCount>> {
|
||||
let searcher = self.reader.searcher();
|
||||
|
||||
let query: Box<dyn Query> = match accounts {
|
||||
Some(ref ids) if !ids.is_empty() => {
|
||||
let mut subqueries = Vec::new();
|
||||
for &id in ids {
|
||||
let term =
|
||||
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
|
||||
subqueries.push((
|
||||
Occur::Should,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
|
||||
));
|
||||
}
|
||||
Box::new(BooleanQuery::new(subqueries))
|
||||
}
|
||||
Some(_) => Box::new(EmptyQuery),
|
||||
None => Box::new(AllQuery),
|
||||
};
|
||||
|
||||
let mut all_facets = Vec::new();
|
||||
Self::collect_facets_recursive(&searcher, "/", &mut all_facets)?;
|
||||
Self::collect_facets_recursive(&query, &searcher, "/", &mut all_facets)?;
|
||||
Ok(all_facets)
|
||||
}
|
||||
|
||||
@@ -550,6 +616,7 @@ impl EnvelopeIndexManager {
|
||||
|
||||
pub async fn search(
|
||||
&self,
|
||||
accounts: Option<HashSet<u64>>,
|
||||
filter: SearchFilter,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
@@ -557,7 +624,7 @@ impl EnvelopeIndexManager {
|
||||
) -> BichonResult<DataPage<Envelope>> {
|
||||
assert!(page > 0, "Page number must be greater than 0");
|
||||
assert!(page_size > 0, "Page size must be greater than 0");
|
||||
let query = self.filter_query(filter, self.query_parser.clone())?;
|
||||
let query = self.filter_query(accounts, filter, self.query_parser.clone())?;
|
||||
let searcher = self.create_searcher()?;
|
||||
let total = searcher
|
||||
.search(&query, &Count)
|
||||
@@ -591,7 +658,7 @@ impl EnvelopeIndexManager {
|
||||
&query,
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_INTERNAL_DATE, order),
|
||||
.order_by_fast_field(F_DATE, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let mut result = Vec::new();
|
||||
@@ -654,7 +721,7 @@ impl EnvelopeIndexManager {
|
||||
query.as_ref(),
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_INTERNAL_DATE, order),
|
||||
.order_by_fast_field(F_DATE, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let mut result = Vec::new();
|
||||
@@ -719,7 +786,7 @@ impl EnvelopeIndexManager {
|
||||
query.as_ref(),
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_INTERNAL_DATE, order),
|
||||
.order_by_fast_field(F_DATE, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let mut result = Vec::new();
|
||||
@@ -741,15 +808,77 @@ impl EnvelopeIndexManager {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn top_10_largest_emails(&self) -> BichonResult<Vec<LargestEmail>> {
|
||||
pub async fn get_envelope_by_id(
|
||||
&self,
|
||||
account_id: u64,
|
||||
message_id: u64,
|
||||
) -> BichonResult<Option<Envelope>> {
|
||||
let searcher = self.create_searcher()?;
|
||||
let f = SchemaTools::envelope_fields();
|
||||
|
||||
let query = BooleanQuery::new(vec![
|
||||
(
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(
|
||||
Term::from_field_u64(f.f_account_id, account_id),
|
||||
IndexRecordOption::Basic,
|
||||
)),
|
||||
),
|
||||
(
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(
|
||||
Term::from_field_u64(f.f_id, message_id),
|
||||
IndexRecordOption::Basic,
|
||||
)),
|
||||
),
|
||||
]);
|
||||
|
||||
let docs: Vec<(f32, DocAddress)> = searcher
|
||||
.search(&query, &TopDocs::with_limit(1))
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
if let Some((_, doc_address)) = docs.first() {
|
||||
let doc: TantivyDocument = searcher
|
||||
.doc_async(*doc_address)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let envelope = Envelope::from_tantivy_doc(&doc).await?;
|
||||
Ok(Some(envelope))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn top_10_largest_emails(
|
||||
&self,
|
||||
accounts: &Option<HashSet<u64>>,
|
||||
) -> BichonResult<Vec<LargestEmail>> {
|
||||
self.reader
|
||||
.reload()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let searcher = self.reader.searcher();
|
||||
|
||||
let query: Box<dyn Query> = match accounts {
|
||||
Some(ref ids) if !ids.is_empty() => {
|
||||
let mut subqueries = Vec::new();
|
||||
for &id in ids {
|
||||
let term =
|
||||
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
|
||||
subqueries.push((
|
||||
Occur::Should,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
|
||||
));
|
||||
}
|
||||
Box::new(BooleanQuery::new(subqueries))
|
||||
}
|
||||
Some(_) => Box::new(EmptyQuery),
|
||||
None => Box::new(AllQuery),
|
||||
};
|
||||
|
||||
let mailbox_docs: Vec<(u64, DocAddress)> = searcher
|
||||
.search(
|
||||
&AllQuery,
|
||||
&query,
|
||||
&TopDocs::with_limit(10).order_by_fast_field(F_SIZE, Order::Desc),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
@@ -905,7 +1034,10 @@ impl EnvelopeIndexManager {
|
||||
Ok(self.reader.searcher())
|
||||
}
|
||||
|
||||
pub async fn get_dashboard_stats(&self) -> BichonResult<DashboardStats> {
|
||||
pub async fn get_dashboard_stats(
|
||||
&self,
|
||||
accounts: &Option<HashSet<u64>>,
|
||||
) -> BichonResult<DashboardStats> {
|
||||
let searcher = self.create_searcher()?;
|
||||
let now_ms = utc_now!();
|
||||
let week_ago_ms = (Utc::now() - Duration::from_secs(60 * 60 * 24 * 30)).timestamp_millis();
|
||||
@@ -916,7 +1048,7 @@ impl EnvelopeIndexManager {
|
||||
},
|
||||
"recent_30d_histogram": {
|
||||
"histogram": {
|
||||
"field": F_INTERNAL_DATE,
|
||||
"field": F_DATE,
|
||||
"interval": 86400000,
|
||||
"hard_bounds": {
|
||||
"min": week_ago_ms,
|
||||
@@ -944,7 +1076,23 @@ impl EnvelopeIndexManager {
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let query = AllQuery;
|
||||
let query: Box<dyn Query> = match accounts {
|
||||
Some(ref ids) if !ids.is_empty() => {
|
||||
let mut subqueries = Vec::new();
|
||||
for &id in ids {
|
||||
let term =
|
||||
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
|
||||
subqueries.push((
|
||||
Occur::Should,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
|
||||
));
|
||||
}
|
||||
Box::new(BooleanQuery::new(subqueries))
|
||||
}
|
||||
Some(_) => Box::new(EmptyQuery),
|
||||
None => Box::new(AllQuery),
|
||||
};
|
||||
|
||||
let agg_collector = AggregationCollector::from_aggs(aggregations, Default::default());
|
||||
let agg_results = searcher
|
||||
.search(&query, &agg_collector)
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::account::migration::{AccountModel, AccountType};
|
||||
use crate::modules::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox};
|
||||
use crate::modules::context::executors::MAIL_CONTEXT;
|
||||
@@ -64,8 +63,15 @@ pub async fn convert_names_to_mailboxes(
|
||||
for name in names.into_iter() {
|
||||
// Convert the name into a MailBox structure
|
||||
let mailbox_name = name.name().to_string();
|
||||
|
||||
let mut mailbox: MailBox = name.into();
|
||||
|
||||
tracing::debug!(
|
||||
raw = &mailbox_name,
|
||||
decoded = &mailbox.name,
|
||||
"mailbox name comparison"
|
||||
);
|
||||
|
||||
if contains_no_select(&mailbox.attributes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
use crate::{
|
||||
encode_mailbox_name,
|
||||
modules::{
|
||||
account::migration::{AccountModel, AccountType},
|
||||
context::executors::MAIL_CONTEXT,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const MAX_RESTORE_COUNT: usize = 100;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct RestoreMessagesRequest {
|
||||
/// Message IDs to restore (max 100)
|
||||
pub message_ids: Vec<u64>,
|
||||
}
|
||||
|
||||
pub async fn restore_emails(account_id: u64, message_ids: Vec<u64>) -> BichonResult<()> {
|
||||
if message_ids.len() > MAX_RESTORE_COUNT {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Too many messages to restore: {} (max {})",
|
||||
message_ids.len(),
|
||||
MAX_RESTORE_COUNT
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
let account = AccountModel::check_account_exists(account_id).await?;
|
||||
if !matches!(account.account_type, AccountType::IMAP) {
|
||||
return Err(raise_error!(
|
||||
"Account type is not IMAP".into(),
|
||||
ErrorCode::Incompatible
|
||||
));
|
||||
}
|
||||
let executor = MAIL_CONTEXT.imap(account.id).await?;
|
||||
|
||||
let mut failed = Vec::new();
|
||||
|
||||
for message_id in message_ids {
|
||||
let result: BichonResult<()> = async {
|
||||
let envelope = ENVELOPE_INDEX_MANAGER
|
||||
.get_envelope_by_id(account_id, message_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"Envelope not found: account_id={} message_id={}",
|
||||
account_id, message_id
|
||||
),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
let eml = EML_INDEX_MANAGER
|
||||
.get(account_id, message_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"Email record not found: account_id={} id={}",
|
||||
account_id, message_id
|
||||
),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(mailbox_name) = envelope.mailbox_name {
|
||||
executor
|
||||
.append(encode_mailbox_name!(&mailbox_name), None, None, &eml)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
failed.push(message_id);
|
||||
tracing::warn!(
|
||||
account_id = account_id,
|
||||
message_id = message_id,
|
||||
error = ?err,
|
||||
"Failed to restore email"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !failed.is_empty() {
|
||||
tracing::info!(
|
||||
account_id = account_id,
|
||||
failed_count = failed.len(),
|
||||
failed_message_ids = ?failed,
|
||||
"Restore emails finished with partial failures"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
pub mod append;
|
||||
pub mod content;
|
||||
pub mod delete;
|
||||
pub mod list;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -72,9 +73,18 @@ impl SearchRequest {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_messages_impl(request: SearchRequest) -> BichonResult<DataPage<Envelope>> {
|
||||
pub async fn search_messages_impl(
|
||||
accounts: Option<HashSet<u64>>,
|
||||
request: SearchRequest,
|
||||
) -> BichonResult<DataPage<Envelope>> {
|
||||
request.validate()?;
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.search(request.filter, request.page, request.page_size, true)
|
||||
.search(
|
||||
accounts,
|
||||
request.filter,
|
||||
request.page,
|
||||
request.page_size,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -36,5 +36,6 @@ pub mod rest;
|
||||
pub mod settings;
|
||||
pub mod tasks;
|
||||
pub mod token;
|
||||
pub mod users;
|
||||
pub mod utils;
|
||||
pub mod version;
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::{
|
||||
encrypt, id,
|
||||
modules::{
|
||||
@@ -97,6 +96,27 @@ impl OAuth2 {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn scrub_sensitive_fields(&mut self) {
|
||||
let mask = "********";
|
||||
let notice =
|
||||
" [REDACTED: You do not have permission to view sensitive configuration details]";
|
||||
|
||||
let original_desc = self
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| "OAuth2 Config".to_string());
|
||||
self.description = Some(format!("{}{}", original_desc, notice));
|
||||
|
||||
self.client_id = mask.to_string();
|
||||
self.client_secret = mask.to_string();
|
||||
self.auth_url = mask.to_string();
|
||||
self.token_url = mask.to_string();
|
||||
self.redirect_uri = mask.to_string();
|
||||
|
||||
self.scopes = None;
|
||||
self.extra_params = None;
|
||||
}
|
||||
|
||||
pub async fn save(&self) -> BichonResult<()> {
|
||||
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await?;
|
||||
Ok(())
|
||||
|
||||
@@ -16,16 +16,12 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::ApiResult;
|
||||
use crate::modules::token::payload::AccessTokenUpdateRequest;
|
||||
use crate::modules::token::root::set_root_password;
|
||||
use crate::modules::{
|
||||
token::payload::AccessTokenCreateRequest,
|
||||
token::{root::reset_root_token, AccessToken},
|
||||
};
|
||||
use crate::modules::token::view::AccessTokenResp;
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use crate::modules::{token::payload::AccessTokenCreateRequest, token::AccessTokenModel};
|
||||
use poem_openapi::payload::PlainText;
|
||||
use poem_openapi::{param::Path, payload::Json, OpenApi};
|
||||
|
||||
@@ -33,9 +29,6 @@ pub struct AccessTokenApi;
|
||||
|
||||
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::AccessToken")]
|
||||
impl AccessTokenApi {
|
||||
/// Lists all access tokens in the system.
|
||||
///
|
||||
/// Requires root privileges.
|
||||
#[oai(
|
||||
path = "/access-token-list",
|
||||
method = "get",
|
||||
@@ -44,31 +37,15 @@ impl AccessTokenApi {
|
||||
async fn list_access_tokens(
|
||||
&self,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<Vec<AccessToken>>> {
|
||||
context.require_root()?;
|
||||
Ok(Json(AccessToken::list_all().await?))
|
||||
) -> ApiResult<Json<Vec<AccessTokenResp>>> {
|
||||
context
|
||||
.require_permission(None, Permission::TOKEN_MANAGE)
|
||||
.await?;
|
||||
|
||||
Ok(Json(AccessTokenModel::list_all_api_tokens().await?))
|
||||
}
|
||||
|
||||
/// Lists access tokens for a specific account.
|
||||
///
|
||||
/// Requires root privileges.
|
||||
#[oai(
|
||||
path = "/access-token-list/:account_id",
|
||||
method = "get",
|
||||
operation_id = "list_account_access_tokens"
|
||||
)]
|
||||
async fn list_account_access_tokens(
|
||||
&self,
|
||||
/// The ID of the account whose tokens are to be retrieved.
|
||||
account_id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<Vec<AccessToken>>> {
|
||||
context.require_root()?;
|
||||
Ok(Json(AccessToken::list_account_tokens(account_id.0).await?))
|
||||
}
|
||||
/// Deletes a specific access token.
|
||||
///
|
||||
/// Requires root privileges.
|
||||
#[oai(
|
||||
path = "/access-token/:token",
|
||||
method = "delete",
|
||||
@@ -80,13 +57,18 @@ impl AccessTokenApi {
|
||||
token: Path<String>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
context.require_root()?;
|
||||
Ok(AccessToken::delete(token.0.trim()).await?)
|
||||
let token = token.0.trim();
|
||||
let token = AccessTokenModel::get_token(token).await?;
|
||||
if context.user.id != token.user_id {
|
||||
context
|
||||
.require_permission(None, Permission::TOKEN_MANAGE)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(AccessTokenModel::delete(&token.token).await?)
|
||||
}
|
||||
|
||||
/// Creates a new access token.
|
||||
///
|
||||
/// Requires root privileges.
|
||||
/// Creates a new api token.
|
||||
#[oai(
|
||||
path = "/access-token",
|
||||
method = "post",
|
||||
@@ -98,59 +80,15 @@ impl AccessTokenApi {
|
||||
/// The request payload
|
||||
payload: Json<AccessTokenCreateRequest>,
|
||||
) -> ApiResult<PlainText<String>> {
|
||||
context.require_root()?;
|
||||
Ok(PlainText(AccessToken::create(payload.0).await?))
|
||||
}
|
||||
let current_user_id = context.user.id;
|
||||
let target_user_id = payload.0.user_id.unwrap_or(current_user_id);
|
||||
if target_user_id != current_user_id {
|
||||
context
|
||||
.require_permission(None, Permission::USER_MANAGE)
|
||||
.await?;
|
||||
}
|
||||
|
||||
/// Updates an existing access token.
|
||||
///
|
||||
/// Requires root privileges.
|
||||
#[oai(
|
||||
path = "/access-token/:token",
|
||||
method = "post",
|
||||
operation_id = "update_access_token"
|
||||
)]
|
||||
async fn update_access_token(
|
||||
&self,
|
||||
context: ClientContext,
|
||||
/// The access token to be updated.
|
||||
token: Path<String>,
|
||||
/// The request payload.
|
||||
payload: Json<AccessTokenUpdateRequest>,
|
||||
) -> ApiResult<()> {
|
||||
context.require_root()?;
|
||||
Ok(AccessToken::update(token.0.trim(), payload.0).await?)
|
||||
}
|
||||
|
||||
/// Regenerates the root access token.
|
||||
///
|
||||
/// Requires root privileges.
|
||||
#[oai(
|
||||
path = "/reset-root-token",
|
||||
method = "post",
|
||||
operation_id = "regenerate_root_token"
|
||||
)]
|
||||
async fn regenerate_root_token(&self, context: ClientContext) -> ApiResult<PlainText<String>> {
|
||||
context.require_root()?;
|
||||
Ok(PlainText(reset_root_token().await?))
|
||||
}
|
||||
|
||||
/// Reset the Root user's password.
|
||||
///
|
||||
/// Only callable by an already authenticated Root user.
|
||||
/// This endpoint updates the Root password to `password_str`
|
||||
/// and regenerates the `root_token`, invalidating any previous token.
|
||||
#[oai(
|
||||
path = "/reset-root-password",
|
||||
method = "post",
|
||||
operation_id = "reset_root_password"
|
||||
)]
|
||||
async fn reset_root_password(
|
||||
&self,
|
||||
password_str: PlainText<String>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
context.require_root()?;
|
||||
Ok(set_root_password(password_str.0.trim()).await?)
|
||||
let token_string = AccessTokenModel::create_api_token(target_user_id, payload.0).await?;
|
||||
Ok(PlainText(token_string))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,23 +16,25 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::modules::account::grant::BatchAccountRoleRequest;
|
||||
use crate::modules::account::migration::AccountModel;
|
||||
use crate::modules::account::payload::{
|
||||
filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount,
|
||||
};
|
||||
use crate::modules::account::state::AccountRunningState;
|
||||
use crate::modules::account::view::AccountResp;
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::common::paginated::paginate_vec;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::response::DataPage;
|
||||
use crate::modules::rest::ApiResult;
|
||||
use crate::modules::token::{AccessToken, AccountInfo};
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use crate::modules::users::UserModel;
|
||||
use crate::raise_error;
|
||||
use poem::web::Path;
|
||||
use poem_openapi::param::Query;
|
||||
use poem_openapi::param::{Path, Query};
|
||||
use poem_openapi::payload::Json;
|
||||
use poem_openapi::OpenApi;
|
||||
|
||||
@@ -53,7 +55,9 @@ impl AccountApi {
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<AccountModel>> {
|
||||
let account_id = account_id.0;
|
||||
context.require_account_access(account_id)?;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
|
||||
.await?;
|
||||
Ok(Json(AccountModel::get(account_id).await?))
|
||||
}
|
||||
|
||||
@@ -70,7 +74,9 @@ impl AccountApi {
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let account_id = account_id.0;
|
||||
context.require_account_access(account_id)?;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
|
||||
.await?;
|
||||
Ok(AccountModel::delete(account_id).await?)
|
||||
}
|
||||
|
||||
@@ -82,14 +88,10 @@ impl AccountApi {
|
||||
payload: Json<AccountCreateRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<AccountModel>> {
|
||||
let account = AccountModel::create_account(payload.0).await?;
|
||||
if let Some(access_token) = &context.access_token {
|
||||
let account_info = AccountInfo {
|
||||
id: account.id,
|
||||
email: account.email.clone(),
|
||||
};
|
||||
AccessToken::grant_account_access(&access_token.token, account_info).await?;
|
||||
}
|
||||
context
|
||||
.require_permission(None, Permission::ACCOUNT_CREATE)
|
||||
.await?;
|
||||
let account = AccountModel::create_account(context.user.id, payload.0).await?;
|
||||
Ok(Json(account))
|
||||
}
|
||||
|
||||
@@ -108,7 +110,9 @@ impl AccountApi {
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let account_id = account_id.0;
|
||||
context.require_account_access(account_id)?;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
|
||||
.await?;
|
||||
Ok(AccountModel::update(account_id, payload.0, true).await?)
|
||||
}
|
||||
|
||||
@@ -123,35 +127,61 @@ impl AccountApi {
|
||||
/// Optional. Whether to sort the list in descending order.
|
||||
desc: Query<Option<bool>>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<DataPage<AccountModel>>> {
|
||||
let accessible_accounts = context.accessible_accounts()?;
|
||||
) -> ApiResult<Json<DataPage<AccountResp>>> {
|
||||
let is_admin = context.user.is_admin().await;
|
||||
let sort_desc = desc.0.unwrap_or(true);
|
||||
|
||||
if accessible_accounts.is_none() {
|
||||
return Ok(Json(
|
||||
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?,
|
||||
));
|
||||
}
|
||||
|
||||
let all_accounts = AccountModel::list_all().await?;
|
||||
let allowed_ids: BTreeSet<u64> =
|
||||
accessible_accounts.unwrap().iter().map(|a| a.id).collect();
|
||||
|
||||
let mut filtered_accounts: Vec<AccountModel> = all_accounts
|
||||
let user_map: HashMap<u64, UserModel> = UserModel::list_all()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|acct| allowed_ids.contains(&acct.id))
|
||||
.map(|u| (u.id, u))
|
||||
.collect();
|
||||
let page_data: DataPage<AccountModel> = if is_admin {
|
||||
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?
|
||||
} else {
|
||||
let authorized_ids: HashSet<u64> =
|
||||
context.user.account_access_map.keys().cloned().collect();
|
||||
|
||||
if authorized_ids.is_empty() {
|
||||
return Ok(Json(DataPage {
|
||||
current_page: page.0,
|
||||
page_size: page_size.0,
|
||||
total_items: 0,
|
||||
items: vec![],
|
||||
total_pages: Some(0),
|
||||
}));
|
||||
}
|
||||
|
||||
let mut accounts: Vec<AccountModel> = AccountModel::list_all()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|acct| authorized_ids.contains(&acct.id))
|
||||
.collect();
|
||||
|
||||
accounts.sort_by(|a, b| {
|
||||
if sort_desc {
|
||||
b.created_at.cmp(&a.created_at)
|
||||
} else {
|
||||
a.created_at.cmp(&b.created_at)
|
||||
}
|
||||
});
|
||||
|
||||
paginate_vec(&accounts, page.0, page_size.0).map(DataPage::from)?
|
||||
};
|
||||
|
||||
let items = page_data
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|account| AccountResp::from_model(account, &user_map))
|
||||
.collect();
|
||||
|
||||
let sort_desc = desc.0.unwrap_or(true);
|
||||
filtered_accounts.sort_by(|a, b| {
|
||||
if sort_desc {
|
||||
b.created_at.cmp(&a.created_at)
|
||||
} else {
|
||||
a.created_at.cmp(&b.created_at)
|
||||
}
|
||||
});
|
||||
let page_data =
|
||||
paginate_vec(&filtered_accounts, page.0, page_size.0).map(DataPage::from)?;
|
||||
Ok(Json(page_data))
|
||||
Ok(Json(DataPage {
|
||||
current_page: page_data.current_page,
|
||||
page_size: page_data.page_size,
|
||||
total_items: page_data.total_items,
|
||||
total_pages: page_data.total_pages,
|
||||
items,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get the running state of an account
|
||||
@@ -168,7 +198,9 @@ impl AccountApi {
|
||||
) -> ApiResult<Json<AccountRunningState>> {
|
||||
let account_id = account_id.0;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
context.require_account_access(account_id)?;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
|
||||
.await?;
|
||||
let state = AccountRunningState::get(account_id).await?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"account running state is not found".into(),
|
||||
@@ -191,13 +223,25 @@ impl AccountApi {
|
||||
&self,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<Vec<MinimalAccount>>> {
|
||||
let accessible_accounts = context.accessible_accounts()?;
|
||||
|
||||
let is_admin = context.user.is_admin().await;
|
||||
let minimal_list = AccountModel::minimal_list().await?;
|
||||
let result = match accessible_accounts {
|
||||
Some(set) => filter_accessible_accounts(&minimal_list, set),
|
||||
None => minimal_list,
|
||||
};
|
||||
if is_admin {
|
||||
return Ok(Json(minimal_list));
|
||||
}
|
||||
|
||||
let authorized_ids: Vec<u64> = context.user.account_access_map.keys().cloned().collect();
|
||||
let result = filter_accessible_accounts(&minimal_list, &authorized_ids);
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[oai(path = "/accounts/access/assignments", method = "post")]
|
||||
async fn batch_assign_account_role(
|
||||
&self,
|
||||
req: Json<BatchAccountRoleRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
req.validate_existence().await?;
|
||||
req.0.do_assign(&context).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,15 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::autoconfig::entity::MailServerConfig;
|
||||
use crate::modules::autoconfig::load::resolve_autoconfig;
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::ApiResult;
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use crate::raise_error;
|
||||
use poem::web::Path;
|
||||
use poem_openapi::param::Path;
|
||||
use poem_openapi::payload::Json;
|
||||
use poem_openapi::OpenApi;
|
||||
|
||||
@@ -40,8 +41,13 @@ impl AutoConfigApi {
|
||||
async fn autoconfig(
|
||||
&self,
|
||||
/// The email address to lookup configuration for
|
||||
email_address: Path<String>
|
||||
email_address: Path<String>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<MailServerConfig>> {
|
||||
context
|
||||
.require_permission(None, Permission::ACCOUNT_CREATE)
|
||||
.await?;
|
||||
|
||||
let result = resolve_autoconfig(email_address.0.trim())
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::modules::import::BatchEmlResult;
|
||||
use crate::modules::import::{BatchEmlRequest, ImportEmls};
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::ApiResult;
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use poem_openapi::payload::Json;
|
||||
use poem_openapi::OpenApi;
|
||||
|
||||
@@ -43,7 +44,9 @@ impl ImportApi {
|
||||
payload: Json<BatchEmlRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<BatchEmlResult>> {
|
||||
context.require_root()?;
|
||||
context
|
||||
.require_permission(Some(payload.0.account_id), Permission::DATA_IMPORT_BATCH)
|
||||
.await?;
|
||||
Ok(Json(ImportEmls::do_import(payload.0).await?))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,13 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::cache::imap::mailbox::MailBox;
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::mailbox::list::get_account_mailboxes;
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::ApiResult;
|
||||
use poem::web::Path;
|
||||
use poem_openapi::param::Query;
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use poem_openapi::param::{Path, Query};
|
||||
use poem_openapi::payload::Json;
|
||||
use poem_openapi::OpenApi;
|
||||
|
||||
@@ -53,7 +52,9 @@ impl MailBoxApi {
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<Vec<MailBox>>> {
|
||||
let account_id = account_id.0;
|
||||
context.require_account_access(account_id)?;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
|
||||
.await?;
|
||||
let remote = remote.0.unwrap_or(false);
|
||||
Ok(Json(get_account_mailboxes(account_id, remote).await?))
|
||||
}
|
||||
|
||||
+137
-31
@@ -21,6 +21,8 @@ use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::indexer::envelope::Envelope;
|
||||
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
|
||||
use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER;
|
||||
use crate::modules::message::append::restore_emails;
|
||||
use crate::modules::message::append::RestoreMessagesRequest;
|
||||
use crate::modules::message::content::{retrieve_email_content, FullMessageContent};
|
||||
use crate::modules::message::delete::delete_messages_impl;
|
||||
use crate::modules::message::list::{get_thread_messages, list_messages_impl};
|
||||
@@ -31,13 +33,14 @@ use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::response::DataPage;
|
||||
use crate::modules::rest::ApiResult;
|
||||
use crate::modules::rest::ErrorCode;
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use crate::raise_error;
|
||||
use poem::web::Path;
|
||||
use poem::Body;
|
||||
use poem_openapi::param::Query;
|
||||
use poem_openapi::param::{Path, Query};
|
||||
use poem_openapi::payload::{Attachment, AttachmentType, Json};
|
||||
use poem_openapi::OpenApi;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use tantivy::schema::Facet;
|
||||
|
||||
pub struct MessageApi;
|
||||
@@ -58,12 +61,14 @@ impl MessageApi {
|
||||
) -> ApiResult<()> {
|
||||
let request = payload.0;
|
||||
for account_id in request.keys() {
|
||||
context.require_account_access(*account_id)?;
|
||||
context
|
||||
.require_permission(Some(*account_id), Permission::DATA_DELETE)
|
||||
.await?;
|
||||
}
|
||||
Ok(delete_messages_impl(request).await?)
|
||||
}
|
||||
|
||||
/// Lists messages in a specified mailbox for the given account.
|
||||
/// Lists messages in a mailbox. Requires `mailbox_id`, `page`, and `page_size` query parameters.
|
||||
#[oai(
|
||||
path = "/list-messages/:account_id",
|
||||
method = "get",
|
||||
@@ -71,7 +76,9 @@ impl MessageApi {
|
||||
)]
|
||||
async fn list_messages(
|
||||
&self,
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
/// The ID of the mailbox to list messages from.
|
||||
mailbox_id: Query<u64>,
|
||||
page: Query<u64>,
|
||||
page_size: Query<u64>,
|
||||
@@ -79,13 +86,16 @@ impl MessageApi {
|
||||
) -> ApiResult<Json<DataPage<Envelope>>> {
|
||||
let account_id = account_id.0;
|
||||
let mailbox_id = mailbox_id.0;
|
||||
context.require_account_access(account_id)?;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_READ)
|
||||
.await?;
|
||||
Ok(Json(
|
||||
list_messages_impl(account_id, mailbox_id, page.0, page_size.0).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Lists messages in a specified mailbox for the given account.
|
||||
/// Searches messages across all mailboxes using various filter criteria.
|
||||
/// The search filters are provided in the request body.
|
||||
#[oai(
|
||||
path = "/search-messages",
|
||||
method = "post",
|
||||
@@ -96,11 +106,18 @@ impl MessageApi {
|
||||
payload: Json<SearchRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<DataPage<Envelope>>> {
|
||||
context.require_root()?;
|
||||
Ok(Json(search_messages_impl(payload.0).await?))
|
||||
let authorized_ids: Option<HashSet<u64>> = if context
|
||||
.has_permission(None, Permission::DATA_READ_ALL)
|
||||
.await
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
Ok(Json(search_messages_impl(authorized_ids, payload.0).await?))
|
||||
}
|
||||
|
||||
/// Get thread's envelopes in a specified mailbox for the given account.
|
||||
/// Retrieves all messages belonging to a specific thread. Requires `thread_id`, `page`, and `page_size` query parameters.
|
||||
#[oai(
|
||||
path = "/get-thread-messages/:account_id",
|
||||
method = "get",
|
||||
@@ -120,73 +137,141 @@ impl MessageApi {
|
||||
) -> ApiResult<Json<DataPage<Envelope>>> {
|
||||
let account_id = account_id.0;
|
||||
let thread_id = thread_id.0;
|
||||
context.require_account_access(account_id)?;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_READ)
|
||||
.await?;
|
||||
Ok(Json(
|
||||
get_thread_messages(account_id, thread_id, page.0, page_size.0).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Fetches the content of a specific email for the given account.
|
||||
/// Fetches the content of a specific email.
|
||||
#[oai(
|
||||
path = "/message-content/:account_id",
|
||||
path = "/message-content/:account_id/:message_id",
|
||||
method = "get",
|
||||
operation_id = "fetch_message_content"
|
||||
)]
|
||||
async fn fetch_message_content(
|
||||
&self,
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
id: Query<u64>,
|
||||
/// The ID of the message to fetch.
|
||||
message_id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<FullMessageContent>> {
|
||||
let account_id = account_id.0;
|
||||
context.require_account_access(account_id)?;
|
||||
Ok(Json(retrieve_email_content(account_id, id.0).await?))
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_READ)
|
||||
.await?;
|
||||
Ok(Json(
|
||||
retrieve_email_content(account_id, message_id.0).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Fetches the full content of a specific email for the given account.
|
||||
/// Retrieves the envelope (metadata) of a specific message.
|
||||
#[oai(
|
||||
path = "/download-message/:account_id",
|
||||
path = "/envelope/:account_id/:message_id",
|
||||
method = "get",
|
||||
operation_id = "get_envelope"
|
||||
)]
|
||||
async fn get_envelope(
|
||||
&self,
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
/// The ID of the message.
|
||||
message_id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<Envelope>> {
|
||||
let account_id = account_id.0;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_READ)
|
||||
.await?;
|
||||
let envelope = ENVELOPE_INDEX_MANAGER
|
||||
.get_envelope_by_id(account_id, message_id.0)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"Envelope not found: account_id={} message_id={}",
|
||||
account_id, message_id.0
|
||||
),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
Ok(Json(envelope))
|
||||
}
|
||||
|
||||
/// Downloads the raw EML file of a specific email.
|
||||
#[oai(
|
||||
path = "/download-message/:account_id/:message_id",
|
||||
method = "get",
|
||||
operation_id = "download_message"
|
||||
)]
|
||||
async fn download_message(
|
||||
&self,
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
id: Query<u64>,
|
||||
/// The ID of the message to download.
|
||||
message_id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Attachment<Body>> {
|
||||
let account_id = account_id.0;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
context.require_account_access(account_id)?;
|
||||
let id = id.0;
|
||||
let reader = EML_INDEX_MANAGER.get_reader(account_id, id).await?;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)
|
||||
.await?;
|
||||
let message_id = message_id.0;
|
||||
let reader = EML_INDEX_MANAGER.get_reader(account_id, message_id).await?;
|
||||
let body = Body::from_async_read(reader);
|
||||
let attachment = Attachment::new(body)
|
||||
.attachment_type(AttachmentType::Attachment)
|
||||
.filename(format!("{id}.eml"));
|
||||
.filename(format!("{message_id}.eml"));
|
||||
Ok(attachment)
|
||||
}
|
||||
|
||||
/// Downloads a specific attachment by filename.
|
||||
#[oai(
|
||||
path = "/download-attachment/:account_id",
|
||||
path = "/restore-messages/:account_id",
|
||||
method = "post",
|
||||
operation_id = "restore_messages"
|
||||
)]
|
||||
async fn restore_messages(
|
||||
&self,
|
||||
account_id: Path<u64>,
|
||||
/// Message IDs to restore.
|
||||
payload: Json<RestoreMessagesRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let account_id = account_id.0;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_EXPORT_BATCH)
|
||||
.await?;
|
||||
Ok(restore_emails(account_id, payload.0.message_ids).await?)
|
||||
}
|
||||
|
||||
/// Downloads a specific attachment from an email. Requires `name` query parameter.
|
||||
#[oai(
|
||||
path = "/download-attachment/:account_id/:message_id",
|
||||
method = "get",
|
||||
operation_id = "download_attachment"
|
||||
)]
|
||||
async fn download_attachment(
|
||||
&self,
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
id: Query<u64>,
|
||||
/// The ID of the message containing the attachment.
|
||||
message_id: Path<u64>,
|
||||
/// The filename of the attachment to download.
|
||||
name: Query<String>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Attachment<Body>> {
|
||||
let account_id = account_id.0;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
context.require_account_access(account_id)?;
|
||||
let email_id = id.0;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_READ)
|
||||
.await?;
|
||||
let name = name.0.trim();
|
||||
let reader = EML_INDEX_MANAGER
|
||||
.get_attachment(account_id, email_id, name)
|
||||
.get_attachment(account_id, message_id.0, name)
|
||||
.await?;
|
||||
let body = Body::from_async_read(reader);
|
||||
let attachment = Attachment::new(body)
|
||||
@@ -196,8 +281,18 @@ impl MessageApi {
|
||||
}
|
||||
/// Returns all facets in the index along with their document counts.
|
||||
#[oai(path = "/all-tags", method = "get", operation_id = "get_all_tags")]
|
||||
async fn get_all_tags(&self) -> ApiResult<Json<Vec<TagCount>>> {
|
||||
Ok(Json(ENVELOPE_INDEX_MANAGER.get_all_tags().await?))
|
||||
async fn get_all_tags(&self, context: ClientContext) -> ApiResult<Json<Vec<TagCount>>> {
|
||||
let authorized_ids: Option<HashSet<u64>> = if context
|
||||
.has_permission(None, Permission::DATA_READ_ALL)
|
||||
.await
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
Ok(Json(
|
||||
ENVELOPE_INDEX_MANAGER.get_all_tags(authorized_ids).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Adds or removes facet tags for multiple emails across accounts.
|
||||
@@ -206,12 +301,23 @@ impl MessageApi {
|
||||
method = "post",
|
||||
operation_id = "update_envelope_tags"
|
||||
)]
|
||||
async fn update_envelope_tags(&self, req: Json<UpdateTagsRequest>) -> ApiResult<()> {
|
||||
async fn update_envelope_tags(
|
||||
&self,
|
||||
req: Json<UpdateTagsRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let req = req.0;
|
||||
for tag in &req.tags {
|
||||
Facet::from_text(tag)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
|
||||
}
|
||||
|
||||
for account_id in req.updates.keys() {
|
||||
context
|
||||
.require_permission(Some(*account_id), Permission::DATA_MANAGE)
|
||||
.await?;
|
||||
}
|
||||
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.update_envelope_tags(req.updates, req.tags)
|
||||
.await?;
|
||||
|
||||
@@ -25,7 +25,10 @@ use oauth2::OAuth2Api;
|
||||
use poem_openapi::{OpenApiService, Tags};
|
||||
use system::SystemApi;
|
||||
|
||||
use crate::{bichon_version, modules::rest::api::import::ImportApi};
|
||||
use crate::{
|
||||
bichon_version,
|
||||
modules::rest::api::{import::ImportApi, users::UsersApi},
|
||||
};
|
||||
|
||||
pub mod access_token;
|
||||
pub mod account;
|
||||
@@ -35,6 +38,7 @@ pub mod mailbox;
|
||||
pub mod message;
|
||||
pub mod oauth2;
|
||||
pub mod system;
|
||||
pub mod users;
|
||||
|
||||
#[derive(Tags)]
|
||||
pub enum ApiTags {
|
||||
@@ -46,6 +50,7 @@ pub enum ApiTags {
|
||||
Message,
|
||||
System,
|
||||
Import,
|
||||
Users,
|
||||
}
|
||||
|
||||
type RustMailOpenApi = (
|
||||
@@ -57,6 +62,7 @@ type RustMailOpenApi = (
|
||||
OAuth2Api,
|
||||
MessageApi,
|
||||
ImportApi,
|
||||
UsersApi,
|
||||
);
|
||||
|
||||
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
|
||||
@@ -70,6 +76,7 @@ pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
|
||||
OAuth2Api,
|
||||
MessageApi,
|
||||
ImportApi,
|
||||
UsersApi,
|
||||
),
|
||||
"BichonApi",
|
||||
bichon_version!(),
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::account::migration::AccountModel;
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest};
|
||||
@@ -25,9 +25,9 @@ use crate::modules::oauth2::token::{ExternalOAuth2Request, OAuth2AccessToken};
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::response::DataPage;
|
||||
use crate::modules::rest::ApiResult;
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use crate::raise_error;
|
||||
use poem::web::Path;
|
||||
use poem_openapi::param::Query;
|
||||
use poem_openapi::param::{Path, Query};
|
||||
use poem_openapi::payload::{Json, PlainText};
|
||||
use poem_openapi::OpenApi;
|
||||
|
||||
@@ -50,14 +50,26 @@ impl OAuth2Api {
|
||||
id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<OAuth2>> {
|
||||
context.require_root()?;
|
||||
let id = id.0;
|
||||
Ok(Json(OAuth2::get(id).await?.ok_or_else(|| {
|
||||
let mut oauth2 = OAuth2::get(id).await?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("OAuth2 configuration id='{id}' not found"),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?))
|
||||
})?;
|
||||
if context
|
||||
.has_permission(None, Permission::ROOT)
|
||||
.await
|
||||
{
|
||||
return Ok(Json(oauth2));
|
||||
}
|
||||
|
||||
context
|
||||
.require_permission(None, Permission::ACCOUNT_CREATE)
|
||||
.await?;
|
||||
|
||||
oauth2.scrub_sensitive_fields();
|
||||
Ok(Json(oauth2))
|
||||
}
|
||||
|
||||
/// Deletes an OAuth2 configuration by name.
|
||||
@@ -75,7 +87,9 @@ impl OAuth2Api {
|
||||
id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
context.require_root()?;
|
||||
context
|
||||
.require_permission(None, Permission::ROOT)
|
||||
.await?;
|
||||
Ok(OAuth2::delete(id.0).await?)
|
||||
}
|
||||
|
||||
@@ -94,7 +108,9 @@ impl OAuth2Api {
|
||||
request: Json<OAuth2CreateRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
context.require_root()?;
|
||||
context
|
||||
.require_permission(None, Permission::ROOT)
|
||||
.await?;
|
||||
let entity = OAuth2::new(request.0)?;
|
||||
Ok(entity.save().await?)
|
||||
}
|
||||
@@ -116,7 +132,9 @@ impl OAuth2Api {
|
||||
payload: Json<OAuth2UpdateRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
context.require_root()?;
|
||||
context
|
||||
.require_permission(None, Permission::ROOT)
|
||||
.await?;
|
||||
Ok(OAuth2::update(id.0, payload.0).await?)
|
||||
}
|
||||
|
||||
@@ -139,10 +157,23 @@ impl OAuth2Api {
|
||||
desc: Query<Option<bool>>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<DataPage<OAuth2>>> {
|
||||
context.require_root()?;
|
||||
Ok(Json(
|
||||
OAuth2::paginate_list(page.0, page_size.0, desc.0).await?,
|
||||
))
|
||||
let mut list = OAuth2::paginate_list(page.0, page_size.0, desc.0).await?;
|
||||
if context
|
||||
.has_permission(None, Permission::ROOT)
|
||||
.await
|
||||
{
|
||||
return Ok(Json(list));
|
||||
}
|
||||
|
||||
context
|
||||
.require_permission(None, Permission::ACCOUNT_CREATE)
|
||||
.await?;
|
||||
|
||||
for item in &mut list.items {
|
||||
item.scrub_sensitive_fields();
|
||||
}
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Generates an OAuth2 authorization URL for a specific account.
|
||||
@@ -160,8 +191,14 @@ impl OAuth2Api {
|
||||
request: Json<AuthorizeUrlRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<PlainText<String>> {
|
||||
context.require_root()?;
|
||||
let request = request.0;
|
||||
context
|
||||
.require_any_permission(vec![
|
||||
(None, Permission::ACCOUNT_CREATE),
|
||||
(Some(request.account_id), Permission::ACCOUNT_MANAGE),
|
||||
])
|
||||
.await?;
|
||||
|
||||
let flow = OAuth2Flow::new(request.oauth2_id);
|
||||
Ok(PlainText(flow.authorize_url(request.account_id).await?))
|
||||
}
|
||||
@@ -181,7 +218,9 @@ impl OAuth2Api {
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<OAuth2AccessToken>> {
|
||||
let account = account_id.0;
|
||||
context.require_account_access(account)?;
|
||||
context
|
||||
.require_permission(Some(account), Permission::ACCOUNT_MANAGE)
|
||||
.await?;
|
||||
Ok(Json(OAuth2AccessToken::get(account).await?.ok_or_else(
|
||||
|| {
|
||||
raise_error!(
|
||||
@@ -219,10 +258,13 @@ impl OAuth2Api {
|
||||
request: Json<ExternalOAuth2Request>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let account = account_id.0;
|
||||
let account_id = account_id.0;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
// Check account access permissions
|
||||
context.require_account_access(account)?;
|
||||
OAuth2AccessToken::upsert_external_oauth_token(account, request.0).await?;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
|
||||
.await?;
|
||||
OAuth2AccessToken::upsert_external_oauth_token(account_id, request.0).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,15 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::dashboard::DashboardStats;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::ApiResult;
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::modules::settings::proxy::Proxy;
|
||||
use crate::modules::settings::SystemConfigurations;
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use crate::modules::version::{fetch_notifications, Notifications};
|
||||
use crate::raise_error;
|
||||
use poem_openapi::param::Path;
|
||||
@@ -60,14 +62,20 @@ impl SystemApi {
|
||||
path = "/dashboard-stats",
|
||||
operation_id = "get_dashboard_stats"
|
||||
)]
|
||||
async fn get_dashboard_stats(&self) -> ApiResult<Json<DashboardStats>> {
|
||||
let stats = DashboardStats::get().await?;
|
||||
async fn get_dashboard_stats(&self, context: ClientContext) -> ApiResult<Json<DashboardStats>> {
|
||||
let stats = DashboardStats::get(context).await?;
|
||||
Ok(Json(stats))
|
||||
}
|
||||
|
||||
/// Get the full list of SOCKS5 proxy configurations.
|
||||
#[oai(method = "get", path = "/list-proxy", operation_id = "list_proxy")]
|
||||
async fn list_proxy(&self) -> ApiResult<Json<Vec<Proxy>>> {
|
||||
async fn list_proxy(&self, context: ClientContext) -> ApiResult<Json<Vec<Proxy>>> {
|
||||
context
|
||||
.require_any_permission(vec![
|
||||
(None, Permission::ACCOUNT_CREATE),
|
||||
(None, Permission::ROOT),
|
||||
])
|
||||
.await?;
|
||||
let proxies = Proxy::list_all()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
@@ -78,30 +86,36 @@ impl SystemApi {
|
||||
#[oai(path = "/proxy/:id", method = "delete", operation_id = "remove_proxy")]
|
||||
async fn remove_proxy(
|
||||
&self,
|
||||
/// The name of the OAuth2 configuration to retrieve
|
||||
/// The ID of the proxy configuration to delete.
|
||||
id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
context.require_root()?;
|
||||
context
|
||||
.require_permission(None, Permission::ROOT)
|
||||
.await?;
|
||||
Ok(Proxy::delete(id.0).await?)
|
||||
}
|
||||
|
||||
/// Retrieve a specific proxy configuration by ID
|
||||
/// Retrieve a specific proxy configuration by ID. Requires root permission.
|
||||
#[oai(path = "/proxy/:id", method = "get", operation_id = "get_proxy")]
|
||||
async fn get_proxy(
|
||||
&self,
|
||||
/// The name of the OAuth2 configuration to retrieve
|
||||
/// The ID of the proxy configuration to retrieve.
|
||||
id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<Proxy>> {
|
||||
context.require_root()?;
|
||||
context
|
||||
.require_permission(None, Permission::ROOT)
|
||||
.await?;
|
||||
Ok(Json(Proxy::get(id.0).await?))
|
||||
}
|
||||
|
||||
/// Create a new proxy configuration. Requires root permission.
|
||||
#[oai(path = "/proxy", method = "post", operation_id = "create_proxy")]
|
||||
async fn create_proxy(&self, url: PlainText<String>, context: ClientContext) -> ApiResult<()> {
|
||||
context.require_root()?;
|
||||
context
|
||||
.require_permission(None, Permission::ROOT)
|
||||
.await?;
|
||||
let entity = Proxy::new(url.0);
|
||||
Ok(entity.save().await?)
|
||||
}
|
||||
@@ -114,7 +128,28 @@ impl SystemApi {
|
||||
url: PlainText<String>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
context.require_root()?;
|
||||
context
|
||||
.require_permission(None, Permission::ROOT)
|
||||
.await?;
|
||||
Ok(Proxy::update(id.0, url.0).await?)
|
||||
}
|
||||
/// Get system configurations.
|
||||
///
|
||||
/// Returns a read-only snapshot of the server configuration
|
||||
/// resolved at startup. Sensitive values are not exposed.
|
||||
#[oai(
|
||||
method = "get",
|
||||
path = "/system-configurations",
|
||||
operation_id = "get_system_configurations"
|
||||
)]
|
||||
async fn get_system_configurations(
|
||||
&self,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<SystemConfigurations>> {
|
||||
context
|
||||
.require_permission(None, Permission::ROOT)
|
||||
.await?;
|
||||
let config: SystemConfigurations = SystemConfigurations::from(&*SETTINGS);
|
||||
Ok(Json(config))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::ApiResult;
|
||||
use crate::modules::token::AccessTokenModel;
|
||||
use crate::modules::users::minimal::MinimalUser;
|
||||
use crate::modules::users::payload::{
|
||||
RoleCreateRequest, RoleUpdateRequest, UserCreateRequest, UserUpdateRequest,
|
||||
};
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use crate::modules::users::role::UserRole;
|
||||
use crate::modules::users::view::UserView;
|
||||
use crate::modules::users::UserModel;
|
||||
use poem::web::Path;
|
||||
use poem_openapi::payload::Json;
|
||||
use poem_openapi::OpenApi;
|
||||
|
||||
pub struct UsersApi;
|
||||
|
||||
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Users")]
|
||||
impl UsersApi {
|
||||
#[oai(path = "/list-roles", method = "get", operation_id = "list_roles")]
|
||||
async fn list_roles(&self, context: ClientContext) -> ApiResult<Json<Vec<UserRole>>> {
|
||||
context
|
||||
.require_permission(None, Permission::USER_MANAGE)
|
||||
.await?;
|
||||
|
||||
Ok(Json(UserRole::list_all().await?))
|
||||
}
|
||||
|
||||
#[oai(path = "/roles/:id", method = "delete", operation_id = "remove_role")]
|
||||
async fn remove_role(
|
||||
&self,
|
||||
/// The Role ID to delete
|
||||
id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let id = id.0;
|
||||
context
|
||||
.require_permission(None, Permission::USER_MANAGE)
|
||||
.await?;
|
||||
Ok(UserRole::delete(id).await?)
|
||||
}
|
||||
|
||||
/// Create a new account
|
||||
#[oai(path = "/roles", method = "post", operation_id = "create_role")]
|
||||
async fn create_role(
|
||||
&self,
|
||||
/// Role creation request payload
|
||||
payload: Json<RoleCreateRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<UserRole>> {
|
||||
context
|
||||
.require_permission(None, Permission::USER_MANAGE)
|
||||
.await?;
|
||||
let role = UserRole::create(payload.0).await?;
|
||||
Ok(Json(role))
|
||||
}
|
||||
|
||||
/// Update an existing account
|
||||
#[oai(path = "/roles/:id", method = "post", operation_id = "update_role")]
|
||||
async fn update_role(
|
||||
&self,
|
||||
/// The Role ID to update
|
||||
id: Path<u64>,
|
||||
/// Role update request payload
|
||||
payload: Json<RoleUpdateRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let id = id.0;
|
||||
context
|
||||
.require_permission(None, Permission::USER_MANAGE)
|
||||
.await?;
|
||||
Ok(UserRole::update(id, payload.0).await?)
|
||||
}
|
||||
|
||||
#[oai(path = "/list-users", method = "get", operation_id = "list_users")]
|
||||
async fn list_users(&self, context: ClientContext) -> ApiResult<Json<Vec<UserView>>> {
|
||||
context
|
||||
.require_permission(None, Permission::USER_MANAGE)
|
||||
.await?;
|
||||
let roles = UserRole::list_all().await?;
|
||||
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
|
||||
let users = UserModel::list_all().await?;
|
||||
let users = users
|
||||
.into_iter()
|
||||
.map(|u| u.to_view(&role_lookup))
|
||||
.collect();
|
||||
Ok(Json(users))
|
||||
}
|
||||
|
||||
#[oai(
|
||||
path = "/user-tokens/:id",
|
||||
method = "get",
|
||||
operation_id = "get_user_tokens"
|
||||
)]
|
||||
async fn get_user_tokens(
|
||||
&self,
|
||||
id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<Vec<AccessTokenModel>>> {
|
||||
let target_user_id = id.0;
|
||||
let tokens = AccessTokenModel::get_user_api_tokens(target_user_id).await?;
|
||||
if context.user.id == target_user_id {
|
||||
return Ok(Json(tokens));
|
||||
}
|
||||
context
|
||||
.require_permission(None, Permission::USER_MANAGE)
|
||||
.await?;
|
||||
Ok(Json(tokens))
|
||||
}
|
||||
|
||||
#[oai(path = "/users/:id", method = "delete", operation_id = "remove_user")]
|
||||
async fn remove_user(
|
||||
&self,
|
||||
/// The User ID to delete
|
||||
id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let id = id.0;
|
||||
context
|
||||
.require_permission(None, Permission::USER_MANAGE)
|
||||
.await?;
|
||||
Ok(UserModel::remove(id).await?)
|
||||
}
|
||||
|
||||
#[oai(path = "/users", method = "post", operation_id = "create_user")]
|
||||
async fn create_user(
|
||||
&self,
|
||||
payload: Json<UserCreateRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<UserView>> {
|
||||
context
|
||||
.require_permission(None, Permission::USER_MANAGE)
|
||||
.await?;
|
||||
let user = UserModel::create(payload.0).await?;
|
||||
let roles = UserRole::list_all().await?;
|
||||
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
|
||||
Ok(Json(user.to_view(&role_lookup)))
|
||||
}
|
||||
|
||||
#[oai(path = "/users/:id", method = "post", operation_id = "update_user")]
|
||||
async fn update_user(
|
||||
&self,
|
||||
id: Path<u64>,
|
||||
payload: Json<UserUpdateRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let target_id = id.0;
|
||||
let current_user_id = context.user.id;
|
||||
if current_user_id != target_id {
|
||||
context
|
||||
.require_permission(None, Permission::USER_MANAGE)
|
||||
.await?;
|
||||
}
|
||||
let mut update_data = payload.0;
|
||||
if current_user_id == target_id
|
||||
&& !context.has_permission(None, Permission::USER_MANAGE).await
|
||||
{
|
||||
update_data.global_roles = None;
|
||||
update_data.account_access_map = None;
|
||||
update_data.acl = None;
|
||||
}
|
||||
Ok(UserModel::update(target_id, update_data).await?)
|
||||
}
|
||||
|
||||
#[oai(
|
||||
path = "/current-user",
|
||||
method = "get",
|
||||
operation_id = "get_current_user"
|
||||
)]
|
||||
async fn get_current_user(&self, context: ClientContext) -> ApiResult<Json<UserView>> {
|
||||
let roles = UserRole::list_all().await?;
|
||||
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
|
||||
Ok(Json(context.user.to_view(&role_lookup)))
|
||||
}
|
||||
|
||||
#[oai(
|
||||
path = "/minimal-user-list",
|
||||
method = "get",
|
||||
operation_id = "get_minimal_user_list"
|
||||
)]
|
||||
async fn get_minimal_user_list(
|
||||
&self,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<Vec<MinimalUser>>> {
|
||||
let is_admin = context.user.is_admin().await;
|
||||
let minimal_list = MinimalUser::list_all().await?;
|
||||
if is_admin {
|
||||
return Ok(Json(minimal_list));
|
||||
}
|
||||
context
|
||||
.require_permission(None, Permission::USER_VIEW)
|
||||
.await?;
|
||||
|
||||
Ok(Json(minimal_list))
|
||||
}
|
||||
}
|
||||
@@ -78,8 +78,7 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
.with(Timeout)
|
||||
.with(Tracing);
|
||||
|
||||
let cors_origins: Option<HashSet<String>> =
|
||||
SETTINGS.bichon_cors_origins.clone();
|
||||
let cors_origins: Option<HashSet<String>> = SETTINGS.bichon_cors_origins.clone();
|
||||
|
||||
let cors_origins: Vec<String> = cors_origins.unwrap_or_default().into_iter().collect();
|
||||
|
||||
|
||||
@@ -16,29 +16,41 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::token::root::check_root_password;
|
||||
use poem::{handler, IntoResponse, Response};
|
||||
use crate::modules::users::UserModel;
|
||||
use poem::{handler, web::Json, IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
use tracing::error;
|
||||
|
||||
/// Login endpoint for Root user
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginPayload {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Login endpoint
|
||||
///
|
||||
/// Accepts a plain text password and returns the `root_token`
|
||||
/// on successful authentication.
|
||||
#[handler]
|
||||
pub async fn login(password: String) -> Response {
|
||||
match check_root_password(&password) {
|
||||
Ok(root_token) => Response::builder()
|
||||
.status(http::StatusCode::OK)
|
||||
.content_type("text/plain")
|
||||
.body(root_token)
|
||||
.into_response(),
|
||||
pub async fn login(payload: Json<LoginPayload>) -> Response {
|
||||
let payload = payload.0;
|
||||
match UserModel::authenticate_user(payload.username, payload.password).await {
|
||||
Ok(result) => match serde_json::to_string(&result) {
|
||||
Ok(json_string) => Response::builder()
|
||||
.status(http::StatusCode::OK)
|
||||
.content_type("application/json")
|
||||
.body(json_string)
|
||||
.into_response(),
|
||||
Err(_) => Response::builder()
|
||||
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body("Internal server error during response serialization.")
|
||||
.into_response(),
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Root login failed: {:?}", e);
|
||||
error!("Authentication failed with system error: {:?}", e);
|
||||
Response::builder()
|
||||
.status(http::StatusCode::UNAUTHORIZED)
|
||||
.content_type("text/plain")
|
||||
.body(e.to_string())
|
||||
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body("Authentication system failed.".to_string())
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
+32
-17
@@ -19,7 +19,7 @@
|
||||
use clap::{builder::ValueParser, Parser, ValueEnum};
|
||||
use std::{collections::HashSet, env, fmt, path::PathBuf, sync::LazyLock};
|
||||
|
||||
pub static SETTINGS: LazyLock<Settings> = LazyLock::new(Settings::parse);
|
||||
pub static SETTINGS: LazyLock<Settings> = LazyLock::new(Settings::init);
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[clap(
|
||||
@@ -132,11 +132,27 @@ pub struct Settings {
|
||||
/// bichon encryption password
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "change-this-default-password-now",
|
||||
env,
|
||||
help = "Set the encryption password for bichon. ⚠️ Change this default in production!"
|
||||
default_value = "change-this-default-password-now",
|
||||
help = "Set the encryption password for bichon. Alternatively, you can use --bichon-encrypt-password-file. If both are set, this parameter takes precedence over the file."
|
||||
)]
|
||||
pub bichon_encrypt_password: String,
|
||||
pub bichon_encrypt_password: Option<String>,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
help = "The file containing the encryption password. An alternative to --bichon-encrypt-password."
|
||||
)]
|
||||
pub bichon_encrypt_password_file: Option<String>,
|
||||
|
||||
/// WebUI token expiration time in seconds (default: 7 days)
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "168",
|
||||
env,
|
||||
help = "Set the WebUI token expiration time in hours"
|
||||
)]
|
||||
pub bichon_webui_token_expiration_hours: u32,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
@@ -174,19 +190,6 @@ pub struct Settings {
|
||||
)]
|
||||
pub bichon_envelope_cache_size: Option<usize>,
|
||||
|
||||
/// Enables or disables the access token mechanism for HTTP endpoints.
|
||||
///
|
||||
/// When set to `true`, HTTP requests will be subject to access token validation.
|
||||
/// If the `Authorization` header is missing or the token is invalid, the service will return a 401 Unauthorized response.
|
||||
/// When set to `false`, access token validation will be skipped.
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "false",
|
||||
env,
|
||||
help = "Enables or disables the access token mechanism for HTTP endpoints."
|
||||
)]
|
||||
pub bichon_enable_access_token: bool,
|
||||
|
||||
/// Enables or disables HTTPS for REST API endpoints.
|
||||
///
|
||||
/// When set to `true`, the REST API will use HTTPS with a valid SSL/TLS certificate for secure communication.
|
||||
@@ -217,6 +220,18 @@ pub struct Settings {
|
||||
pub bichon_sync_concurrency: Option<u16>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn init() -> Self {
|
||||
let s = Self::parse();
|
||||
if s.bichon_encrypt_password.is_none() && s.bichon_encrypt_password_file.is_none() {
|
||||
panic!(
|
||||
"One of --bichon_encrypt_password or --bichon_encrypt_password_file has to be set"
|
||||
);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
|
||||
pub enum CompressionAlgorithm {
|
||||
#[clap(name = "none")]
|
||||
|
||||
@@ -16,8 +16,68 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::modules::settings::cli::Settings;
|
||||
|
||||
pub mod cli;
|
||||
pub mod dir;
|
||||
pub mod proxy;
|
||||
pub mod system;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct SystemConfigurations {
|
||||
pub bichon_log_level: String,
|
||||
pub bichon_http_port: i32,
|
||||
pub bichon_bind_ip: Option<String>,
|
||||
pub bichon_public_url: String,
|
||||
|
||||
pub bichon_cors_origins: Option<Vec<String>>,
|
||||
pub bichon_cors_max_age: i32,
|
||||
|
||||
pub bichon_ansi_logs: bool,
|
||||
pub bichon_log_to_file: bool,
|
||||
pub bichon_json_logs: bool,
|
||||
pub bichon_max_server_log_files: usize,
|
||||
|
||||
pub bichon_encrypt_password_set: bool,
|
||||
pub bichon_webui_token_expiration_hours: u32,
|
||||
|
||||
pub bichon_root_dir: String,
|
||||
pub bichon_metadata_cache_size: Option<usize>,
|
||||
pub bichon_envelope_cache_size: Option<usize>,
|
||||
|
||||
pub bichon_enable_rest_https: bool,
|
||||
pub bichon_http_compression_enabled: bool,
|
||||
pub bichon_sync_concurrency: Option<u16>,
|
||||
}
|
||||
|
||||
impl From<&Settings> for SystemConfigurations {
|
||||
fn from(s: &Settings) -> Self {
|
||||
Self {
|
||||
bichon_log_level: s.bichon_log_level.clone(),
|
||||
bichon_http_port: s.bichon_http_port,
|
||||
bichon_bind_ip: s.bichon_bind_ip.clone(),
|
||||
bichon_public_url: s.bichon_public_url.clone(),
|
||||
bichon_cors_origins: s
|
||||
.bichon_cors_origins
|
||||
.as_ref()
|
||||
.map(|set| set.iter().cloned().collect()),
|
||||
bichon_cors_max_age: s.bichon_cors_max_age,
|
||||
bichon_ansi_logs: s.bichon_ansi_logs,
|
||||
bichon_log_to_file: s.bichon_log_to_file,
|
||||
bichon_json_logs: s.bichon_json_logs,
|
||||
bichon_max_server_log_files: s.bichon_max_server_log_files,
|
||||
bichon_encrypt_password_set: s.bichon_encrypt_password.is_some()
|
||||
|| s.bichon_encrypt_password_file.is_some(),
|
||||
bichon_webui_token_expiration_hours: s.bichon_webui_token_expiration_hours,
|
||||
bichon_root_dir: s.bichon_root_dir.clone(),
|
||||
bichon_metadata_cache_size: s.bichon_metadata_cache_size,
|
||||
bichon_envelope_cache_size: s.bichon_envelope_cache_size,
|
||||
bichon_enable_rest_https: s.bichon_enable_rest_https,
|
||||
bichon_http_compression_enabled: s.bichon_http_compression_enabled,
|
||||
bichon_sync_concurrency: s.bichon_sync_concurrency,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use native_db::*;
|
||||
use native_model::{native_model, Model};
|
||||
use poem_openapi::Object;
|
||||
@@ -132,10 +131,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_valid_proxy_urls() {
|
||||
let urls = vec![
|
||||
"socks5://127.0.0.1:1080",
|
||||
"http://127.0.0.1:8080",
|
||||
];
|
||||
let urls = vec!["socks5://127.0.0.1:1080", "http://127.0.0.1:8080"];
|
||||
|
||||
for url in urls {
|
||||
let proxy = Proxy::new(url.to_string());
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::database::manager::DB_MANAGER;
|
||||
use crate::modules::database::{find_impl, upsert_impl};
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::utc_now;
|
||||
// use crate::modules::database::manager::DB_MANAGER;
|
||||
// use crate::modules::database::{find_impl, upsert_impl};
|
||||
// use crate::modules::error::BichonResult;
|
||||
// use crate::utc_now;
|
||||
use native_db::*;
|
||||
use native_model::{native_model, Model};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -37,34 +37,34 @@ pub struct SystemSetting {
|
||||
}
|
||||
|
||||
impl SystemSetting {
|
||||
pub fn new(key: String, value: String) -> Self {
|
||||
Self {
|
||||
key,
|
||||
value,
|
||||
created_at: utc_now!(),
|
||||
updated_at: utc_now!(),
|
||||
}
|
||||
}
|
||||
// pub fn new(key: String, value: String) -> Self {
|
||||
// Self {
|
||||
// key,
|
||||
// value,
|
||||
// created_at: utc_now!(),
|
||||
// updated_at: utc_now!(),
|
||||
// }
|
||||
// }
|
||||
//overwrite
|
||||
pub async fn set(&self) -> BichonResult<()> {
|
||||
upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
|
||||
}
|
||||
// pub async fn set(&self) -> BichonResult<()> {
|
||||
// upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
|
||||
// }
|
||||
|
||||
pub fn get(key: &str) -> BichonResult<Option<SystemSetting>> {
|
||||
find_impl(DB_MANAGER.meta_db(), key)
|
||||
}
|
||||
// pub fn get(key: &str) -> BichonResult<Option<SystemSetting>> {
|
||||
// find_impl(DB_MANAGER.meta_db(), key)
|
||||
// }
|
||||
|
||||
// pub async fn list() -> RustMailerResult<Vec<SystemSetting>> {
|
||||
// list_all_impl(DB_MANAGER.metadata_db()).await
|
||||
// }
|
||||
|
||||
pub fn get_existing_value(key: &str) -> BichonResult<Option<String>> {
|
||||
let setting = Self::get(key)?;
|
||||
Ok(setting.map(|s| s.value))
|
||||
}
|
||||
// pub fn get_existing_value(key: &str) -> BichonResult<Option<String>> {
|
||||
// let setting = Self::get(key)?;
|
||||
// Ok(setting.map(|s| s.value))
|
||||
// }
|
||||
|
||||
pub async fn set_value(key: &str, value: String) -> BichonResult<()> {
|
||||
let setting = Self::new(key.to_string(), value);
|
||||
setting.set().await
|
||||
}
|
||||
// pub async fn set_value(key: &str, value: String) -> BichonResult<()> {
|
||||
// let setting = Self::new(key.to_string(), value);
|
||||
// setting.set().await
|
||||
// }
|
||||
}
|
||||
|
||||
+223
-259
@@ -16,12 +16,17 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::modules::account::migration::AccountModel;
|
||||
use crate::modules::database::delete_impl;
|
||||
use super::error::code::ErrorCode;
|
||||
use crate::modules::database::manager::DB_MANAGER;
|
||||
use crate::modules::database::{
|
||||
async_find_impl, delete_impl, filter_by_secondary_key_impl, with_transaction,
|
||||
};
|
||||
use crate::modules::database::{insert_impl, list_all_impl, update_impl};
|
||||
use crate::modules::token::payload::AccessTokenUpdateRequest;
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::modules::token::view::AccessTokenResp;
|
||||
use crate::modules::users::UserModel;
|
||||
use crate::raise_error;
|
||||
use crate::{
|
||||
generate_token, modules::error::BichonResult,
|
||||
@@ -29,259 +34,227 @@ use crate::{
|
||||
};
|
||||
use native_db::*;
|
||||
use native_model::{native_model, Model};
|
||||
use poem_openapi::Object;
|
||||
use poem_openapi::{Enum, Object};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use super::error::code::ErrorCode;
|
||||
|
||||
pub mod payload;
|
||||
pub mod root;
|
||||
pub mod view;
|
||||
|
||||
// Starting from version 0.2.0, this model is deprecated/no longer used
|
||||
// #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
|
||||
// #[native_model(id = 1, version = 1)]
|
||||
// #[native_db]
|
||||
// pub struct AccessToken {
|
||||
// /// The unique token string used for authentication
|
||||
// #[primary_key]
|
||||
// pub token: String,
|
||||
// /// A set of account information associated with the token.
|
||||
// pub accounts: BTreeSet<AccountInfo>,
|
||||
// /// The timestamp (in milliseconds since epoch) when the token was created.
|
||||
// pub created_at: i64,
|
||||
// /// The timestamp (in milliseconds since epoch) when the token was last updated.
|
||||
// pub updated_at: i64,
|
||||
// /// An optional description of the token's purpose or usage.
|
||||
// pub description: Option<String>,
|
||||
// /// The timestamp (in milliseconds since epoch) when the token was last used.
|
||||
// pub last_access_at: i64,
|
||||
// /// Optional access control settings
|
||||
// pub acl: Option<AccessControl>,
|
||||
// }
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Enum)]
|
||||
pub enum TokenType {
|
||||
WebUI,
|
||||
Api,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
|
||||
#[native_model(id = 1, version = 1)]
|
||||
#[native_model(id = 11, version = 1)]
|
||||
#[native_db]
|
||||
pub struct AccessToken {
|
||||
pub struct AccessTokenModel {
|
||||
/// The ID of the user who owns this token
|
||||
#[secondary_key]
|
||||
pub user_id: u64,
|
||||
/// The unique token string used for authentication
|
||||
#[primary_key]
|
||||
pub token: String,
|
||||
/// A set of account information associated with the token.
|
||||
pub accounts: BTreeSet<AccountInfo>,
|
||||
/// An optional name of the token.
|
||||
pub name: Option<String>,
|
||||
/// Token type: WebUI or API
|
||||
pub token_type: TokenType,
|
||||
/// The timestamp (in milliseconds since epoch) when the token was created.
|
||||
pub created_at: i64,
|
||||
/// The timestamp (in milliseconds since epoch) when the token was last updated.
|
||||
pub updated_at: i64,
|
||||
/// An optional description of the token's purpose or usage.
|
||||
pub description: Option<String>,
|
||||
/// The timestamp (in milliseconds since epoch) when the token expires.
|
||||
/// None means the token does not expire (this applies only to API tokens).
|
||||
pub expire_at: Option<i64>,
|
||||
/// The timestamp (in milliseconds since epoch) when the token was last used.
|
||||
pub last_access_at: i64,
|
||||
/// Optional access control settings
|
||||
pub acl: Option<AccessControl>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize, Serialize, Object)]
|
||||
pub struct AccountInfo {
|
||||
/// The unique identifier for the account.
|
||||
pub id: u64,
|
||||
/// The email address associated with the account.
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl Ord for AccountInfo {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.id.cmp(&other.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for AccountInfo {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
|
||||
pub struct AccessControl {
|
||||
/// An optional set of valid IPv4 or IPv6 addresses allowed to use the access token.
|
||||
pub ip_whitelist: Option<BTreeSet<String>>,
|
||||
/// An optional rate limit configuration for the access token.
|
||||
pub rate_limit: Option<RateLimit>,
|
||||
}
|
||||
|
||||
impl AccessControl {
|
||||
pub fn validate(&self) -> BichonResult<()> {
|
||||
if let Some(ip_whitelist) = &self.ip_whitelist {
|
||||
for ip in ip_whitelist {
|
||||
if ip.parse::<IpAddr>().is_err() {
|
||||
return Err(raise_error!(
|
||||
format!("Invalid IP address: {}", ip),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rate limit
|
||||
if let Some(rate_limit) = &self.rate_limit {
|
||||
if rate_limit.interval < 1 {
|
||||
return Err(raise_error!(
|
||||
"Rate limit interval must be at least 1 second".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
if rate_limit.quota < 1 {
|
||||
return Err(raise_error!(
|
||||
"Rate limit quota must be at least 1".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
|
||||
pub struct RateLimit {
|
||||
/// The time window in seconds for the rate limit.
|
||||
pub interval: u64,
|
||||
/// The maximum number of allowed requests within the time window.
|
||||
pub quota: u32,
|
||||
}
|
||||
|
||||
impl AccessToken {
|
||||
pub fn new(
|
||||
impl AccessTokenModel {
|
||||
pub fn new_api_token(
|
||||
token: String,
|
||||
accounts: BTreeSet<AccountInfo>,
|
||||
description: Option<String>,
|
||||
acl: Option<AccessControl>,
|
||||
user_id: u64,
|
||||
name: Option<String>,
|
||||
expire_at: Option<i64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
token,
|
||||
accounts,
|
||||
created_at: utc_now!(),
|
||||
updated_at: utc_now!(),
|
||||
description,
|
||||
last_access_at: Default::default(),
|
||||
acl,
|
||||
name,
|
||||
user_id,
|
||||
token_type: TokenType::Api,
|
||||
expire_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn try_update_access_timestamp(token: &str) -> BichonResult<AccessToken> {
|
||||
let token = token.to_string();
|
||||
update_impl(
|
||||
DB_MANAGER.meta_db(),
|
||||
|rw| {
|
||||
rw.get()
|
||||
.primary::<AccessToken>(token)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!("Token not exist.".into(), ErrorCode::ResourceNotFound)
|
||||
})
|
||||
},
|
||||
|current| {
|
||||
let mut updated = current.clone();
|
||||
updated.last_access_at = utc_now!();
|
||||
Ok(updated)
|
||||
},
|
||||
)
|
||||
.await
|
||||
pub fn new_webui_token(user_id: u64) -> AccessTokenModel {
|
||||
let now = utc_now!();
|
||||
AccessTokenModel {
|
||||
token: generate_token!(128),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_access_at: Default::default(),
|
||||
name: None,
|
||||
user_id,
|
||||
token_type: TokenType::WebUI,
|
||||
expire_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn grant_account_access(token: &str, account: AccountInfo) -> BichonResult<()> {
|
||||
let token = token.to_string();
|
||||
update_impl(
|
||||
pub async fn reset_webui_token(user_id: u64) -> BichonResult<String> {
|
||||
let old_token = Self::get_user_webui_token(user_id).await?;
|
||||
let new_token = Self::new_webui_token(user_id);
|
||||
let new_token_str = new_token.token.clone();
|
||||
|
||||
match old_token {
|
||||
Some(old) => {
|
||||
with_transaction(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.remove(old)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
rw.insert(new_token)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
insert_impl(DB_MANAGER.meta_db(), new_token).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(new_token_str)
|
||||
}
|
||||
|
||||
pub async fn get_user_webui_token(user_id: u64) -> BichonResult<Option<AccessTokenModel>> {
|
||||
let tokens = filter_by_secondary_key_impl::<AccessTokenModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
move |rw| {
|
||||
rw.get()
|
||||
.primary::<AccessToken>(token.clone())
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"The access token with token={} that you want to modify was not found.",
|
||||
token
|
||||
),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})
|
||||
},
|
||||
|current| {
|
||||
let mut updated = current.clone();
|
||||
updated.accounts.insert(account);
|
||||
updated.updated_at = utc_now!();
|
||||
Ok(updated)
|
||||
},
|
||||
AccessTokenModelKey::user_id,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
|
||||
Ok(tokens
|
||||
.into_iter()
|
||||
.find(|t| t.token_type == TokenType::WebUI))
|
||||
}
|
||||
|
||||
pub async fn update(token: &str, request: AccessTokenUpdateRequest) -> BichonResult<()> {
|
||||
if request.should_skip_update() {
|
||||
return Err(raise_error!(
|
||||
"No changes detected in access scopes, description, or accounts. \
|
||||
Please modify at least one of these fields to perform an update."
|
||||
.into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
request.validate().await?;
|
||||
pub async fn get_user_api_tokens(user_id: u64) -> BichonResult<Vec<AccessTokenModel>> {
|
||||
let tokens = filter_by_secondary_key_impl::<AccessTokenModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
AccessTokenModelKey::user_id,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let account_infos = if let Some(accounts) = &request.accounts {
|
||||
let mut account_infos = BTreeSet::new();
|
||||
for account_id in accounts {
|
||||
let account = AccountModel::get(*account_id).await?;
|
||||
account_infos.insert(AccountInfo {
|
||||
id: *account_id,
|
||||
email: account.email,
|
||||
});
|
||||
Ok(tokens
|
||||
.into_iter()
|
||||
.filter(|t| t.token_type == TokenType::Api)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn resolve_user_from_token(token: &str) -> BichonResult<UserModel> {
|
||||
let token = token.to_string();
|
||||
let token_option = async_find_impl::<AccessTokenModel>(DB_MANAGER.meta_db(), token).await?;
|
||||
let token = match token_option {
|
||||
Some(token) => token,
|
||||
None => {
|
||||
return Err(raise_error!(
|
||||
"Permission denied: no valid access token provided.".into(),
|
||||
ErrorCode::PermissionDenied
|
||||
))
|
||||
}
|
||||
account_infos
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
|
||||
let token = token.to_string();
|
||||
update_impl(
|
||||
DB_MANAGER.meta_db(),
|
||||
move |rw| {
|
||||
rw.get()
|
||||
.primary::<AccessToken>(token.clone())
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"The access token with token={} that you want to modify was not found.",
|
||||
token
|
||||
),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})
|
||||
},
|
||||
move |current| {
|
||||
let mut updated = current.clone();
|
||||
if let Some(description) = request.description {
|
||||
updated.description = Some(description);
|
||||
}
|
||||
if matches!(token.token_type, TokenType::WebUI) {
|
||||
let life = utc_now!() - token.created_at;
|
||||
let max_life = SETTINGS.bichon_webui_token_expiration_hours * 60 * 60 * 1000;
|
||||
|
||||
if request.accounts.is_some() {
|
||||
updated.accounts = account_infos;
|
||||
}
|
||||
|
||||
if let Some(acl) = request.acl {
|
||||
updated.acl = Some(acl);
|
||||
}
|
||||
|
||||
updated.updated_at = utc_now!();
|
||||
Ok(updated)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create(request: AccessTokenCreateRequest) -> BichonResult<String> {
|
||||
// Validate request parameters first
|
||||
request.validate().await?;
|
||||
|
||||
let AccessTokenCreateRequest {
|
||||
accounts,
|
||||
description,
|
||||
acl,
|
||||
} = request;
|
||||
|
||||
let mut account_infos = BTreeSet::new();
|
||||
for &account_id in &accounts {
|
||||
let account = AccountModel::get(account_id).await?;
|
||||
account_infos.insert(AccountInfo {
|
||||
id: account_id,
|
||||
email: account.email,
|
||||
});
|
||||
if life > (max_life as i64) {
|
||||
return Err(raise_error!(
|
||||
"Permission denied: the WebUI token has expired.".into(),
|
||||
ErrorCode::PermissionDenied
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(token.token_type, TokenType::Api) {
|
||||
if let Some(expire_at) = token.expire_at {
|
||||
if utc_now!() > expire_at {
|
||||
return Err(raise_error!(
|
||||
"Your API token has expired and is no longer valid.".into(),
|
||||
ErrorCode::PermissionDenied
|
||||
));
|
||||
}
|
||||
}
|
||||
let token = token.token.clone();
|
||||
update_impl(
|
||||
DB_MANAGER.meta_db(),
|
||||
|rw| {
|
||||
rw.get()
|
||||
.primary::<AccessTokenModel>(token)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"The access token does not exist or has been reset.".into(),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})
|
||||
},
|
||||
|current| {
|
||||
let mut updated = current.clone();
|
||||
updated.last_access_at = utc_now!();
|
||||
Ok(updated)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let user = UserModel::find(token.user_id)
|
||||
.await?
|
||||
.ok_or_else(|| raise_error!("The user associated with this access token does not exist or may have been deleted.".into(), ErrorCode::ResourceNotFound))?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn create_api_token(
|
||||
user_id: u64,
|
||||
request: AccessTokenCreateRequest,
|
||||
) -> BichonResult<String> {
|
||||
// Validate request parameters first
|
||||
request.validate().await?;
|
||||
let expire_at = request
|
||||
.expire_in
|
||||
.map(|hours| utc_now!() + (hours as i64) * 60 * 60 * 1000);
|
||||
let token = generate_token!(128);
|
||||
let access_token = AccessToken::new(token.clone(), account_infos, description, acl);
|
||||
let access_token =
|
||||
AccessTokenModel::new_api_token(token.clone(), user_id, request.name, expire_at);
|
||||
insert_impl(DB_MANAGER.meta_db(), access_token).await?;
|
||||
Ok(token)
|
||||
}
|
||||
@@ -290,7 +263,7 @@ impl AccessToken {
|
||||
let token = token.to_string();
|
||||
delete_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get()
|
||||
.primary::<AccessToken>(token.clone())
|
||||
.primary::<AccessTokenModel>(token.clone())
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
@@ -302,56 +275,47 @@ impl AccessToken {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_all() -> BichonResult<Vec<AccessToken>> {
|
||||
list_all_impl(DB_MANAGER.meta_db()).await
|
||||
pub async fn get_token(token: &str) -> BichonResult<AccessTokenModel> {
|
||||
async_find_impl(DB_MANAGER.meta_db(), token.to_string())
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("Access token '{}' not found", token),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_account_tokens(account_id: u64) -> BichonResult<Vec<AccessToken>> {
|
||||
let all = AccessToken::list_all().await?;
|
||||
let result: Vec<AccessToken> = all
|
||||
pub async fn list_all_api_tokens() -> BichonResult<Vec<AccessTokenResp>> {
|
||||
let users = UserModel::list_all().await?;
|
||||
let mut all = list_all_impl::<AccessTokenModel>(DB_MANAGER.meta_db()).await?;
|
||||
|
||||
all.retain(|t| t.token_type == TokenType::Api);
|
||||
let user_map: HashMap<u64, UserModel> = users.into_iter().map(|u| (u.id, u)).collect();
|
||||
|
||||
let resp = all
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
e.accounts
|
||||
.iter()
|
||||
.any(|account_info| account_info.id == account_id)
|
||||
.map(|token| {
|
||||
let user = user_map.get(&token.user_id);
|
||||
AccessTokenResp {
|
||||
user_name: user
|
||||
.map(|u| u.username.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
user_email: user
|
||||
.map(|u| u.email.clone())
|
||||
.unwrap_or_else(|| "N/A".to_string()),
|
||||
user_id: token.user_id,
|
||||
name: token.name,
|
||||
token: token.token,
|
||||
token_type: token.token_type,
|
||||
created_at: token.created_at,
|
||||
updated_at: token.updated_at,
|
||||
expire_at: token.expire_at,
|
||||
last_access_at: token.last_access_at,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn cleanup_account(account_id: u64) -> BichonResult<()> {
|
||||
let tokens = Self::list_account_tokens(account_id).await?;
|
||||
if tokens.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for token in tokens {
|
||||
update_impl(
|
||||
DB_MANAGER.meta_db(),
|
||||
move |rw| {
|
||||
rw.get()
|
||||
.primary::<AccessToken>(token.token.clone())
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("Cannot find access token, {}", token.token),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})
|
||||
},
|
||||
move |current| {
|
||||
let mut updated = current.clone();
|
||||
updated.updated_at = utc_now!();
|
||||
updated.accounts.retain(|account| account.id != account_id);
|
||||
Ok(updated)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn can_access_account(&self, account_id: u64) -> bool {
|
||||
self.accounts.iter().any(|account| account.id == account_id)
|
||||
Ok(resp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,15 +16,8 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
account::migration::AccountModel,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
token::AccessControl,
|
||||
},
|
||||
modules::error::{code::ErrorCode, BichonResult},
|
||||
raise_error,
|
||||
};
|
||||
use poem_openapi::Object;
|
||||
@@ -32,91 +25,28 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct AccessTokenCreateRequest {
|
||||
/// A set of account information associated with the token.
|
||||
pub accounts: BTreeSet<u64>,
|
||||
/// An optional description of the token's purpose or usage.
|
||||
#[oai(validator(max_length = "255"))]
|
||||
pub description: Option<String>,
|
||||
/// Optional access control settings
|
||||
pub acl: Option<AccessControl>,
|
||||
#[oai(validator(max_length = "32"))]
|
||||
pub name: Option<String>,
|
||||
/// The expiration interval for this token, in hours.
|
||||
/// None means the token does not expire (this applies only to API tokens).
|
||||
pub expire_in: Option<u64>,
|
||||
/// The ID of the user for whom the token is being created.
|
||||
/// If not specified, the token will be created for the current authenticated user.
|
||||
/// Accessing this for another user typically requires `USER_MANAGE` permissions.
|
||||
pub user_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl AccessTokenCreateRequest {
|
||||
pub async fn validate(&self) -> BichonResult<()> {
|
||||
if let Some(acl) = &self.acl {
|
||||
acl.validate()?;
|
||||
}
|
||||
|
||||
if self.accounts.is_empty() {
|
||||
return Err(raise_error!(
|
||||
"Account list cannot be empty. Please provide at least one valid account ID."
|
||||
.into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
let mut not_found = Vec::new();
|
||||
for account_id in &self.accounts {
|
||||
if AccountModel::find(*account_id).await?.is_none() {
|
||||
not_found.push(*account_id);
|
||||
}
|
||||
}
|
||||
if !not_found.is_empty() {
|
||||
return Err(raise_error!(
|
||||
format!("The following account IDs were not found: {}. Please provide valid account IDs.", not_found.iter().map(u64::to_string).collect::<Vec<_>>().join(", ")).into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct AccessTokenUpdateRequest {
|
||||
/// A set of account information associated with the token.
|
||||
pub accounts: Option<BTreeSet<u64>>,
|
||||
/// An optional description of the token's purpose or usage.
|
||||
#[oai(validator(max_length = "255"))]
|
||||
pub description: Option<String>,
|
||||
/// Optional access control settings
|
||||
pub acl: Option<AccessControl>,
|
||||
}
|
||||
|
||||
impl AccessTokenUpdateRequest {
|
||||
pub async fn validate(&self) -> BichonResult<()> {
|
||||
if let Some(acl) = &self.acl {
|
||||
acl.validate()?;
|
||||
}
|
||||
if let Some(accounts) = &self.accounts {
|
||||
if accounts.is_empty() {
|
||||
if let Some(expire_in) = self.expire_in {
|
||||
if expire_in == 0 {
|
||||
return Err(raise_error!(
|
||||
"Account list cannot be empty. Please provide at least one valid account ID."
|
||||
.into(),
|
||||
"expire_in must be a positive duration in hours; zero is not allowed.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
let mut not_found = Vec::new();
|
||||
for account_id in accounts {
|
||||
if AccountModel::find(*account_id).await?.is_none() {
|
||||
not_found.push(*account_id);
|
||||
}
|
||||
}
|
||||
if !not_found.is_empty() {
|
||||
return Err(raise_error!(
|
||||
format!("The following account IDs were not found: {}. Please provide valid account IDs.", not_found.iter().map(u64::to_string).collect::<Vec<_>>().join(", ")).into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AccessTokenUpdateRequest {
|
||||
pub fn should_skip_update(&self) -> bool {
|
||||
self.description.is_none() && self.accounts.is_none() && self.acl.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
+94
-95
@@ -16,110 +16,109 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// use crate::{
|
||||
// decrypt, encrypt, generate_token,
|
||||
// modules::{
|
||||
// error::{code::ErrorCode, BichonResult},
|
||||
// settings::{dir::DATA_DIR_MANAGER, system::SystemSetting},
|
||||
// },
|
||||
// raise_error,
|
||||
// };
|
||||
// use std::fs::File;
|
||||
// use std::io::Write;
|
||||
|
||||
use crate::{
|
||||
decrypt, encrypt, generate_token,
|
||||
modules::{
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
settings::{dir::DATA_DIR_MANAGER, system::SystemSetting},
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
// pub const ROOT_TOKEN: &str = "root-token";
|
||||
// pub const ROOT_PASSWORD: &str = "root-password";
|
||||
// pub const DEFAULT_ROOT_PASSWORD: &str = "root";
|
||||
// pub const ROOT_TOKEN_FILE: &str = "root";
|
||||
|
||||
pub const ROOT_TOKEN: &str = "root-token";
|
||||
pub const ROOT_PASSWORD: &str = "root-password";
|
||||
pub const DEFAULT_ROOT_PASSWORD: &str = "root";
|
||||
pub const ROOT_TOKEN_FILE: &str = "root";
|
||||
// async fn get_or_generate(
|
||||
// key: &str,
|
||||
// generate: impl Fn() -> String,
|
||||
// save_file_name: Option<&str>,
|
||||
// force: bool,
|
||||
// ) -> BichonResult<String> {
|
||||
// if let Some(existing_value) = SystemSetting::get_existing_value(key)? {
|
||||
// if force {
|
||||
// // If force is true, write the existing value to the file
|
||||
// if let Some(filename) = save_file_name {
|
||||
// save_to_file(&existing_value.to_string(), filename).await?;
|
||||
// }
|
||||
// }
|
||||
// Ok(existing_value)
|
||||
// } else {
|
||||
// // If no value exists, generate a new value
|
||||
// let new_value = generate();
|
||||
// SystemSetting::set_value(key, new_value.clone()).await?;
|
||||
|
||||
async fn get_or_generate(
|
||||
key: &str,
|
||||
generate: impl Fn() -> String,
|
||||
save_file_name: Option<&str>,
|
||||
force: bool,
|
||||
) -> BichonResult<String> {
|
||||
if let Some(existing_value) = SystemSetting::get_existing_value(key)? {
|
||||
if force {
|
||||
// If force is true, write the existing value to the file
|
||||
if let Some(filename) = save_file_name {
|
||||
save_to_file(&existing_value.to_string(), filename).await?;
|
||||
}
|
||||
}
|
||||
Ok(existing_value)
|
||||
} else {
|
||||
// If no value exists, generate a new value
|
||||
let new_value = generate();
|
||||
SystemSetting::set_value(key, new_value.clone()).await?;
|
||||
// // Write the new value to the file, if specified
|
||||
// if let Some(filename) = save_file_name {
|
||||
// save_to_file(&new_value.to_string(), filename).await?;
|
||||
// }
|
||||
// Ok(new_value)
|
||||
// }
|
||||
// }
|
||||
|
||||
// Write the new value to the file, if specified
|
||||
if let Some(filename) = save_file_name {
|
||||
save_to_file(&new_value.to_string(), filename).await?;
|
||||
}
|
||||
Ok(new_value)
|
||||
}
|
||||
}
|
||||
// pub async fn ensure_root_token() -> BichonResult<()> {
|
||||
// get_or_generate(
|
||||
// ROOT_TOKEN,
|
||||
// || generate_token!(128),
|
||||
// Some(ROOT_TOKEN_FILE),
|
||||
// true,
|
||||
// )
|
||||
// .await?;
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
pub async fn ensure_root_token() -> BichonResult<()> {
|
||||
get_or_generate(
|
||||
ROOT_TOKEN,
|
||||
|| generate_token!(128),
|
||||
Some(ROOT_TOKEN_FILE),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
// pub async fn reset_root_token() -> BichonResult<String> {
|
||||
// let new_token = generate_token!(128);
|
||||
// save_new_token(&new_token).await?;
|
||||
// save_to_file(&new_token, ROOT_TOKEN_FILE).await?;
|
||||
// Ok(new_token)
|
||||
// }
|
||||
|
||||
pub async fn reset_root_token() -> BichonResult<String> {
|
||||
let new_token = generate_token!(128);
|
||||
save_new_token(&new_token).await?;
|
||||
save_to_file(&new_token, ROOT_TOKEN_FILE).await?;
|
||||
Ok(new_token)
|
||||
}
|
||||
// async fn save_new_token(token: &str) -> BichonResult<()> {
|
||||
// let setting = SystemSetting::new(ROOT_TOKEN.to_string(), token.to_string());
|
||||
// setting.set().await
|
||||
// }
|
||||
|
||||
async fn save_new_token(token: &str) -> BichonResult<()> {
|
||||
let setting = SystemSetting::new(ROOT_TOKEN.to_string(), token.to_string());
|
||||
setting.set().await
|
||||
}
|
||||
// async fn save_to_file(content: &str, filename: &str) -> BichonResult<()> {
|
||||
// let file_path = DATA_DIR_MANAGER.root_dir.join(filename);
|
||||
// let mut file = File::create(&file_path)
|
||||
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
// writeln!(file, "{}", content)
|
||||
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
async fn save_to_file(content: &str, filename: &str) -> BichonResult<()> {
|
||||
let file_path = DATA_DIR_MANAGER.root_dir.join(filename);
|
||||
let mut file = File::create(&file_path)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
writeln!(file, "{}", content)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
Ok(())
|
||||
}
|
||||
// pub fn check_root_password(password: &str) -> BichonResult<String> {
|
||||
// let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD)?;
|
||||
// let matched = match stored_encrypted_password {
|
||||
// Some(ref stored) => {
|
||||
// let decrypted = decrypt!(stored)?;
|
||||
// decrypted == password
|
||||
// }
|
||||
// None => DEFAULT_ROOT_PASSWORD == password,
|
||||
// };
|
||||
|
||||
pub fn check_root_password(password: &str) -> BichonResult<String> {
|
||||
let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD)?;
|
||||
let matched = match stored_encrypted_password {
|
||||
Some(ref stored) => {
|
||||
let decrypted = decrypt!(stored)?;
|
||||
decrypted == password
|
||||
}
|
||||
None => DEFAULT_ROOT_PASSWORD == password,
|
||||
};
|
||||
// if !matched {
|
||||
// return Err(raise_error!(
|
||||
// "Invalid password".into(),
|
||||
// ErrorCode::PermissionDenied
|
||||
// ));
|
||||
// }
|
||||
|
||||
if !matched {
|
||||
return Err(raise_error!(
|
||||
"Invalid password".into(),
|
||||
ErrorCode::PermissionDenied
|
||||
));
|
||||
}
|
||||
// let root_token = SystemSetting::get_existing_value(ROOT_TOKEN)?.ok_or_else(|| {
|
||||
// raise_error!(
|
||||
// "Root token not found — this should never happen".into(),
|
||||
// ErrorCode::InternalError
|
||||
// )
|
||||
// })?;
|
||||
|
||||
let root_token = SystemSetting::get_existing_value(ROOT_TOKEN)?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Root token not found — this should never happen".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
// Ok(root_token)
|
||||
// }
|
||||
|
||||
Ok(root_token)
|
||||
}
|
||||
|
||||
pub async fn set_root_password(new_password: &str) -> BichonResult<()> {
|
||||
let encrypted_password = encrypt!(new_password)?;
|
||||
SystemSetting::set_value(ROOT_PASSWORD, encrypted_password).await
|
||||
}
|
||||
// pub async fn set_root_password(new_password: &str) -> BichonResult<()> {
|
||||
// let encrypted_password = encrypt!(new_password)?;
|
||||
// SystemSetting::set_value(ROOT_PASSWORD, encrypted_password).await
|
||||
// }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::token::TokenType;
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
|
||||
pub struct AccessTokenResp {
|
||||
pub user_id: u64,
|
||||
pub token: String,
|
||||
/// An optional name of the token.
|
||||
pub name: Option<String>,
|
||||
/// Token type: WebUI or API
|
||||
pub token_type: TokenType,
|
||||
/// The timestamp (in milliseconds since epoch) when the token was created.
|
||||
pub created_at: i64,
|
||||
/// The timestamp (in milliseconds since epoch) when the token was last updated.
|
||||
pub updated_at: i64,
|
||||
/// The timestamp (in milliseconds since epoch) when the token expires.
|
||||
/// None means the token does not expire (this applies only to API tokens).
|
||||
pub expire_at: Option<i64>,
|
||||
/// The timestamp (in milliseconds since epoch) when the token was last used.
|
||||
pub last_access_at: i64,
|
||||
|
||||
pub user_name: String,
|
||||
pub user_email: String,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::{collections::BTreeSet, net::IpAddr};
|
||||
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{modules::error::{BichonResult, code::ErrorCode}, raise_error};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
|
||||
pub struct RateLimit {
|
||||
/// The time window in seconds for the rate limit.
|
||||
pub interval: u64,
|
||||
/// The maximum number of allowed requests within the time window.
|
||||
pub quota: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
|
||||
pub struct AccessControl {
|
||||
/// An optional set of valid IPv4 or IPv6 addresses allowed to use the access token.
|
||||
pub ip_whitelist: Option<BTreeSet<String>>,
|
||||
/// An optional rate limit configuration for the access token.
|
||||
pub rate_limit: Option<RateLimit>,
|
||||
}
|
||||
|
||||
impl AccessControl {
|
||||
pub fn validate(&self) -> BichonResult<()> {
|
||||
if let Some(ip_whitelist) = &self.ip_whitelist {
|
||||
for ip in ip_whitelist {
|
||||
if ip.parse::<IpAddr>().is_err() {
|
||||
return Err(raise_error!(
|
||||
format!("Invalid IP address: {}", ip),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rate limit
|
||||
if let Some(rate_limit) = &self.rate_limit {
|
||||
if rate_limit.interval < 1 {
|
||||
return Err(raise_error!(
|
||||
"Rate limit interval must be at least 1 second".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
if rate_limit.quota < 1 {
|
||||
return Err(raise_error!(
|
||||
"Rate limit quota must be at least 1".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -16,30 +16,17 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::modules::{
|
||||
context::Initialize,
|
||||
error::BichonResult,
|
||||
users::{role::UserRole, UserModel},
|
||||
};
|
||||
|
||||
interface AccountInfo {
|
||||
id: number;
|
||||
email: string;
|
||||
pub struct UserManager;
|
||||
|
||||
impl Initialize for UserManager {
|
||||
async fn initialize() -> BichonResult<()> {
|
||||
UserRole::ensure_default_roles_exists().await?;
|
||||
UserModel::ensure_default_admin_exists().await
|
||||
}
|
||||
}
|
||||
|
||||
interface RateLimit {
|
||||
quota: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
interface AccessControl {
|
||||
ip_whitelist?: string[];
|
||||
rate_limit?: RateLimit;
|
||||
}
|
||||
|
||||
interface AccessToken {
|
||||
token: string;
|
||||
accounts: AccountInfo[];
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
description?: string;
|
||||
last_access_at: number;
|
||||
acl?: AccessControl;
|
||||
}
|
||||
|
||||
export type { AccessToken, AccountInfo, AccessControl, RateLimit };
|
||||
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::modules::{
|
||||
database::{list_all_impl, manager::DB_MANAGER},
|
||||
error::BichonResult,
|
||||
users::BichonUser,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct MinimalUser {
|
||||
pub id: u64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl MinimalUser {
|
||||
pub async fn list_all() -> BichonResult<Vec<MinimalUser>> {
|
||||
let all_users = list_all_impl::<BichonUser>(DB_MANAGER.meta_db()).await?;
|
||||
let minimal_list = all_users
|
||||
.into_iter()
|
||||
.map(|user| MinimalUser {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(minimal_list)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::{
|
||||
decrypt, encrypt, generate_token, id,
|
||||
modules::{
|
||||
database::{
|
||||
async_find_impl, batch_delete_impl, delete_impl, list_all_impl, manager::DB_MANAGER,
|
||||
secondary_find_impl, update_impl, with_transaction,
|
||||
},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
token::{AccessTokenModel, AccessTokenModelKey, TokenType},
|
||||
users::{
|
||||
acl::AccessControl,
|
||||
payload::{UserCreateRequest, UserUpdateRequest},
|
||||
permissions::Permission,
|
||||
role::{UserRole, DEFAULT_ADMIN_ROLE_ID},
|
||||
view::UserView,
|
||||
},
|
||||
},
|
||||
raise_error, utc_now,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use native_db::*;
|
||||
use native_model::{native_model, Model};
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use tracing::warn;
|
||||
|
||||
pub mod acl;
|
||||
pub mod manager;
|
||||
pub mod minimal;
|
||||
pub mod payload;
|
||||
pub mod permissions;
|
||||
pub mod role;
|
||||
pub mod view;
|
||||
|
||||
pub type UserModel = BichonUserV2;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct LoginResult {
|
||||
pub success: bool,
|
||||
pub error_message: Option<String>,
|
||||
pub access_token: Option<String>,
|
||||
pub theme: Option<String>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
pub const DEFAULT_ADMIN_USER_ID: u64 = 100000000000000;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
#[native_model(id = 10, version = 1)]
|
||||
#[native_db]
|
||||
pub struct BichonUser {
|
||||
#[primary_key]
|
||||
pub id: u64,
|
||||
#[secondary_key(unique)]
|
||||
pub username: String,
|
||||
#[secondary_key(unique)]
|
||||
pub email: String,
|
||||
|
||||
pub password: Option<String>,
|
||||
|
||||
/// Scoped Access: Defines per-account permissions.
|
||||
/// Example:
|
||||
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
|
||||
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
|
||||
pub account_access_map: BTreeMap<u64, u64>,
|
||||
|
||||
pub description: Option<String>,
|
||||
|
||||
/// System Roles: Permissions that apply to the whole system
|
||||
/// (e.g., system settings, creating new users).
|
||||
pub global_roles: Vec<u64>,
|
||||
|
||||
pub avatar: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
/// Optional access control settings
|
||||
pub acl: Option<AccessControl>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
#[native_model(id = 10, version = 2, from = BichonUser)]
|
||||
#[native_db]
|
||||
pub struct BichonUserV2 {
|
||||
#[primary_key]
|
||||
pub id: u64,
|
||||
#[secondary_key(unique)]
|
||||
pub username: String,
|
||||
#[secondary_key(unique)]
|
||||
pub email: String,
|
||||
|
||||
pub password: Option<String>,
|
||||
|
||||
/// Scoped Access: Defines per-account permissions.
|
||||
/// Example:
|
||||
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
|
||||
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
|
||||
pub account_access_map: BTreeMap<u64, u64>,
|
||||
|
||||
pub description: Option<String>,
|
||||
|
||||
/// System Roles: Permissions that apply to the whole system
|
||||
/// (e.g., system settings, creating new users).
|
||||
pub global_roles: Vec<u64>,
|
||||
|
||||
pub avatar: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
/// Optional access control settings
|
||||
pub acl: Option<AccessControl>,
|
||||
|
||||
pub theme: Option<String>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
impl BichonUserV2 {
|
||||
pub async fn list_all() -> BichonResult<Vec<UserModel>> {
|
||||
Ok(list_all_impl::<UserModel>(DB_MANAGER.meta_db()).await?)
|
||||
}
|
||||
|
||||
async fn get_all_permissions(&self) -> HashSet<String> {
|
||||
let mut all_perms = HashSet::new();
|
||||
|
||||
for &role_id in &self.global_roles {
|
||||
if let Ok(Some(role)) = UserRole::find(role_id).await {
|
||||
for perm in role.permissions {
|
||||
all_perms.insert(perm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_perms
|
||||
}
|
||||
|
||||
pub fn to_view(self, role_lookup: &BTreeMap<u64, UserRole>) -> UserView {
|
||||
let global_roles_names = self
|
||||
.global_roles
|
||||
.iter()
|
||||
.filter_map(|role_id| role_lookup.get(role_id))
|
||||
.map(|role| role.name.clone())
|
||||
.collect();
|
||||
|
||||
let account_roles_summary = self
|
||||
.account_access_map
|
||||
.iter()
|
||||
.map(|(acc_id, role_id)| {
|
||||
let role_name = role_lookup
|
||||
.get(role_id)
|
||||
.map(|r| r.name.clone())
|
||||
.unwrap_or_else(|| "Unknown Role".to_string());
|
||||
(*acc_id, role_name)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let global_permissions = {
|
||||
let mut perms = BTreeSet::new();
|
||||
|
||||
for role_id in &self.global_roles {
|
||||
if let Some(role) = role_lookup.get(role_id) {
|
||||
perms.extend(role.permissions.iter().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
perms.into_iter().collect()
|
||||
};
|
||||
|
||||
let account_permissions = {
|
||||
let mut map: BTreeMap<u64, BTreeSet<String>> = BTreeMap::new();
|
||||
|
||||
for (account_id, role_id) in &self.account_access_map {
|
||||
if let Some(role) = role_lookup.get(role_id) {
|
||||
let entry = map.entry(*account_id).or_default();
|
||||
entry.extend(role.permissions.iter().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
map.into_iter()
|
||||
.map(|(acc_id, perms)| (acc_id, perms.into_iter().collect()))
|
||||
.collect()
|
||||
};
|
||||
UserView {
|
||||
id: self.id,
|
||||
username: self.username,
|
||||
email: self.email,
|
||||
password: self.password.map(|_| "************".to_string()),
|
||||
account_access_map: self.account_access_map,
|
||||
account_roles_summary,
|
||||
description: self.description,
|
||||
global_roles: self.global_roles,
|
||||
global_roles_names,
|
||||
avatar: self.avatar,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
acl: self.acl,
|
||||
account_permissions,
|
||||
global_permissions,
|
||||
theme: self.theme,
|
||||
language: self.language,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_admin(&self) -> bool {
|
||||
self.get_all_permissions().await.contains(Permission::ROOT)
|
||||
}
|
||||
|
||||
pub async fn ensure_default_admin_exists() -> BichonResult<()> {
|
||||
with_transaction(DB_MANAGER.meta_db(), move |rw| {
|
||||
let now = utc_now!();
|
||||
|
||||
// 1. Try to get the existing admin user
|
||||
let admin = rw
|
||||
.get()
|
||||
.primary::<UserModel>(DEFAULT_ADMIN_USER_ID)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
if admin.is_none() {
|
||||
// 2. Insert the BichonUser with the updated schema
|
||||
rw.insert(UserModel {
|
||||
id: DEFAULT_ADMIN_USER_ID,
|
||||
username: "admin".into(),
|
||||
email: "placeholder@example.com".into(),
|
||||
password: Some(encrypt!("admin@bichon")?),
|
||||
|
||||
// Use global_roles as defined in our new schema
|
||||
global_roles: vec![DEFAULT_ADMIN_ROLE_ID],
|
||||
|
||||
// Admin usually doesn't need specific scoped access
|
||||
account_access_map: BTreeMap::new(),
|
||||
|
||||
avatar: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
description: Some("System default administrator".into()),
|
||||
acl: None,
|
||||
theme: None,
|
||||
language: None,
|
||||
})
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
// 3. Generate and insert an initial access token for the first-time setup
|
||||
let access_token = AccessTokenModel {
|
||||
token: generate_token!(128),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_access_at: Default::default(),
|
||||
name: Some("Initial Setup Token".into()),
|
||||
user_id: DEFAULT_ADMIN_USER_ID,
|
||||
token_type: TokenType::WebUI,
|
||||
expire_at: None, // Admin setup token usually persistent until changed
|
||||
};
|
||||
|
||||
rw.upsert(access_token)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn authenticate_user(
|
||||
username: String,
|
||||
password: String,
|
||||
) -> BichonResult<LoginResult> {
|
||||
let user_option = secondary_find_impl::<UserModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
BichonUserV2Key::username,
|
||||
username.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user = match user_option {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
match secondary_find_impl::<UserModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
BichonUserV2Key::email,
|
||||
username,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(u) => u,
|
||||
None => {
|
||||
return Ok(LoginResult {
|
||||
success: false,
|
||||
error_message: Some("User or email not found.".to_string()),
|
||||
access_token: None,
|
||||
theme: None,
|
||||
language: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match user.password.as_ref() {
|
||||
Some(encrypted_password) => {
|
||||
let decrypted = decrypt!(encrypted_password)?;
|
||||
if password == decrypted {
|
||||
let new_token = AccessTokenModel::reset_webui_token(user.id).await?;
|
||||
Ok(LoginResult {
|
||||
success: true,
|
||||
error_message: None,
|
||||
access_token: Some(new_token),
|
||||
theme: user.theme,
|
||||
language: user.language,
|
||||
})
|
||||
} else {
|
||||
warn!(
|
||||
"Login failed: Incorrect password for user '{}'.",
|
||||
user.username
|
||||
);
|
||||
Ok(LoginResult {
|
||||
success: false,
|
||||
error_message: Some("Incorrect password.".to_string()),
|
||||
access_token: None,
|
||||
theme: None,
|
||||
language: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warn!(
|
||||
"Login failed: User '{}' has no password set.",
|
||||
user.username
|
||||
);
|
||||
Ok(LoginResult {
|
||||
success: false,
|
||||
error_message: Some(
|
||||
format!(
|
||||
"User '{}' has no password set. Please try logging in with an alternative method (e.g., OAuth/SSO).",
|
||||
user.username
|
||||
)
|
||||
),
|
||||
access_token: None,
|
||||
theme: None,
|
||||
language: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find(user_id: u64) -> BichonResult<Option<UserModel>> {
|
||||
async_find_impl(DB_MANAGER.meta_db(), user_id).await
|
||||
}
|
||||
|
||||
pub async fn check_username_conflict(username: &str) -> BichonResult<()> {
|
||||
// Check username duplicate
|
||||
if secondary_find_impl::<UserModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
BichonUserV2Key::username,
|
||||
username.to_string(),
|
||||
)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(raise_error!(
|
||||
format!("Username '{}' is already taken.", username).into(),
|
||||
ErrorCode::AlreadyExists
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_email_conflict(email: &str) -> BichonResult<()> {
|
||||
// Check email duplicate
|
||||
if secondary_find_impl::<UserModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
BichonUserV2Key::email,
|
||||
email.to_string(),
|
||||
)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(raise_error!(
|
||||
format!("Email '{}' is already registered.", email).into(),
|
||||
ErrorCode::AlreadyExists
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create(request: UserCreateRequest) -> BichonResult<UserModel> {
|
||||
request.validate().await?;
|
||||
Self::check_username_conflict(&request.username).await?;
|
||||
Self::check_email_conflict(&request.email).await?;
|
||||
|
||||
let password_hash = Some(encrypt!(&request.password)?);
|
||||
let now = utc_now!();
|
||||
|
||||
let user = UserModel {
|
||||
id: id!(96),
|
||||
username: request.username,
|
||||
email: request.email,
|
||||
password: password_hash,
|
||||
global_roles: request.global_roles,
|
||||
avatar: request.avatar_base64,
|
||||
description: request.description,
|
||||
acl: request.acl,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
account_access_map: request.account_access_map,
|
||||
theme: request.theme,
|
||||
language: request.language,
|
||||
};
|
||||
|
||||
let user_clone = user.clone();
|
||||
|
||||
// 4. Atomic transaction for User and Initial Token
|
||||
with_transaction(DB_MANAGER.meta_db(), move |rw| {
|
||||
let user_id = user.id;
|
||||
|
||||
// Insert User
|
||||
rw.insert(user)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
// Create initial WebUI access token
|
||||
let access_token = AccessTokenModel {
|
||||
token: generate_token!(128),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_access_at: Default::default(),
|
||||
name: Some("Default WebUI Token".into()),
|
||||
user_id,
|
||||
token_type: TokenType::WebUI,
|
||||
expire_at: None,
|
||||
};
|
||||
|
||||
rw.insert(access_token)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(user_clone)
|
||||
}
|
||||
|
||||
//delete user,
|
||||
pub async fn remove(id: u64) -> BichonResult<()> {
|
||||
if DEFAULT_ADMIN_USER_ID == id {
|
||||
return Err(raise_error!(
|
||||
format!("The default admin user (id={}) cannot be removed", id),
|
||||
ErrorCode::PermissionDenied
|
||||
));
|
||||
}
|
||||
|
||||
delete_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get()
|
||||
.primary::<BichonUser>(id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("The User with id={id} that you want to delete was not found."),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
batch_delete_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
let tokens: Vec<AccessTokenModel> = rw
|
||||
.scan()
|
||||
.secondary::<AccessTokenModel>(AccessTokenModelKey::user_id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.start_with(id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.try_collect()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
Ok(tokens)
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update(id: u64, request: UserUpdateRequest) -> BichonResult<()> {
|
||||
let _ = &request.validate().await?;
|
||||
let password_changed = request.password.is_some();
|
||||
//
|
||||
let is_default_admin = id == DEFAULT_ADMIN_USER_ID;
|
||||
let is_valid_admin_roles = matches!(
|
||||
request.global_roles.as_deref(),
|
||||
Some([role]) if *role == DEFAULT_ADMIN_ROLE_ID
|
||||
);
|
||||
|
||||
if is_default_admin && !is_valid_admin_roles {
|
||||
return Err(raise_error!(format!("The role assignments for default admin (id={}) are immutable to ensure system accessibility.", id), ErrorCode::Forbidden));
|
||||
}
|
||||
|
||||
if let Some(username) = &request.username {
|
||||
let user_option = secondary_find_impl::<UserModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
BichonUserV2Key::username,
|
||||
username.to_string(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(u) = user_option {
|
||||
if u.id != id {
|
||||
return Err(raise_error!(
|
||||
format!("Username '{}' is already taken.", username).into(),
|
||||
ErrorCode::AlreadyExists
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(email) = &request.email {
|
||||
let user_option = secondary_find_impl::<UserModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
BichonUserV2Key::email,
|
||||
email.to_string(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(u) = user_option {
|
||||
if u.id != id {
|
||||
return Err(raise_error!(
|
||||
format!("Email '{}' is already registered.", email).into(),
|
||||
ErrorCode::AlreadyExists
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update_impl(
|
||||
DB_MANAGER.meta_db(),
|
||||
move |rw| {
|
||||
rw.get()
|
||||
.primary::<UserModel>(id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("User with id={} not found", id),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})
|
||||
},
|
||||
move |current| {
|
||||
let mut updated = current.clone();
|
||||
if let Some(username) = request.username {
|
||||
updated.username = username;
|
||||
}
|
||||
if let Some(email) = request.email {
|
||||
updated.email = email;
|
||||
}
|
||||
if let Some(desc) = request.description {
|
||||
updated.description = Some(desc);
|
||||
}
|
||||
if let Some(password) = request.password {
|
||||
updated.password = Some(encrypt!(&password)?);
|
||||
}
|
||||
|
||||
if let Some(global_roles) = request.global_roles {
|
||||
updated.global_roles = global_roles;
|
||||
}
|
||||
|
||||
if let Some(acl) = request.acl {
|
||||
updated.acl = Some(acl);
|
||||
}
|
||||
|
||||
if let Some(account_access_map) = request.account_access_map {
|
||||
updated.account_access_map = account_access_map;
|
||||
}
|
||||
|
||||
if let Some(avatar_base64) = request.avatar_base64 {
|
||||
updated.avatar = Some(avatar_base64);
|
||||
}
|
||||
|
||||
if let Some(theme) = request.theme {
|
||||
updated.theme = Some(theme);
|
||||
}
|
||||
|
||||
if let Some(language) = request.language {
|
||||
updated.language = Some(language);
|
||||
}
|
||||
|
||||
updated.updated_at = utc_now!();
|
||||
|
||||
Ok(updated)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
if password_changed {
|
||||
AccessTokenModel::reset_webui_token(id).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_authorized_users(account_id: u64) -> BichonResult<Vec<UserModel>> {
|
||||
let all = Self::list_all().await?;
|
||||
let result: Vec<UserModel> = all
|
||||
.into_iter()
|
||||
.filter(|e| e.account_access_map.contains_key(&account_id))
|
||||
.collect();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn cleanup_account(account_id: u64) -> BichonResult<()> {
|
||||
let users = Self::list_authorized_users(account_id).await?;
|
||||
if users.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
with_transaction(DB_MANAGER.meta_db(), move |rw| {
|
||||
let now = utc_now!();
|
||||
for user in users {
|
||||
let current = rw
|
||||
.get()
|
||||
.primary::<UserModel>(user.id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("User {} not found", user.id),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut updated = current.clone();
|
||||
|
||||
if updated.account_access_map.remove(&account_id).is_some() {
|
||||
updated.updated_at = now;
|
||||
rw.update(current, updated)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BichonUserV2> for BichonUser {
|
||||
fn from(value: BichonUserV2) -> Self {
|
||||
BichonUser {
|
||||
id: value.id,
|
||||
username: value.username,
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
account_access_map: value.account_access_map,
|
||||
description: value.description,
|
||||
global_roles: value.global_roles,
|
||||
avatar: value.avatar,
|
||||
created_at: value.created_at,
|
||||
updated_at: value.updated_at,
|
||||
acl: value.acl,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BichonUser> for BichonUserV2 {
|
||||
fn from(value: BichonUser) -> Self {
|
||||
BichonUserV2 {
|
||||
id: value.id,
|
||||
username: value.username,
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
account_access_map: value.account_access_map,
|
||||
description: value.description,
|
||||
global_roles: value.global_roles,
|
||||
avatar: value.avatar,
|
||||
created_at: value.created_at,
|
||||
updated_at: value.updated_at,
|
||||
acl: value.acl,
|
||||
theme: None,
|
||||
language: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
account::migration::AccountModel,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
users::{
|
||||
acl::AccessControl,
|
||||
permissions::{Permission, VALID_PERMISSION_SET},
|
||||
role::{RoleType, UserRole},
|
||||
},
|
||||
utils::decode_avatar_bytes,
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
fn allowed_themes() -> HashSet<&'static str> {
|
||||
["light", "dark"].into_iter().collect()
|
||||
}
|
||||
|
||||
fn allowed_languages() -> HashSet<&'static str> {
|
||||
[
|
||||
"ar", "da", "de", "en", "es", "fi", "fr", "it", "jp", "ko", "nl", "no", "pl", "pt", "ru",
|
||||
"sv", "zh", "zh-tw",
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_option_in_set(
|
||||
value: &Option<String>,
|
||||
allowed: &std::collections::HashSet<&'static str>,
|
||||
field_name: &str,
|
||||
) -> BichonResult<()> {
|
||||
if let Some(v) = value {
|
||||
if !allowed.contains(v.as_str()) {
|
||||
return Err(raise_error!(
|
||||
format!("invalid {} value: '{}'", field_name, v),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_theme(theme: &Option<String>) -> BichonResult<()> {
|
||||
validate_option_in_set(theme, &allowed_themes(), "theme")
|
||||
}
|
||||
|
||||
fn validate_language(language: &Option<String>) -> BichonResult<()> {
|
||||
validate_option_in_set(language, &allowed_languages(), "language")
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct RoleCreateRequest {
|
||||
pub name: String,
|
||||
pub role_type: RoleType,
|
||||
pub description: Option<String>,
|
||||
pub permissions: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl RoleCreateRequest {
|
||||
pub async fn validate(&self) -> BichonResult<()> {
|
||||
let trimmed_name = self.name.trim();
|
||||
if trimmed_name.is_empty() {
|
||||
return Err(raise_error!(
|
||||
"Role name cannot be empty or consist only of whitespace.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
let name_lower = trimmed_name.to_lowercase();
|
||||
if name_lower == "admin" || name_lower == "manager" || name_lower == "viewer" {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"The name '{}' is reserved for system builtin roles.",
|
||||
trimmed_name
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
if self.permissions.is_empty() {
|
||||
return Err(raise_error!(
|
||||
"Role must be assigned at least one permission.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
for permission in &self.permissions {
|
||||
if !VALID_PERMISSION_SET.contains(permission.as_str()) {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Invalid permission '{}' specified in the request.",
|
||||
permission
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Permission::validate_role_permissions(&self.role_type, &self.permissions)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct RoleUpdateRequest {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub permissions: Option<BTreeSet<String>>,
|
||||
}
|
||||
|
||||
impl RoleUpdateRequest {
|
||||
pub async fn validate(&self) -> BichonResult<()> {
|
||||
// 1. Ensure at least one field is provided for the update
|
||||
if self.name.is_none() && self.description.is_none() && self.permissions.is_none() {
|
||||
return Err(raise_error!(
|
||||
"Update request must contain at least one field to modify (name, description, or permissions).".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Validate Name if present
|
||||
if let Some(name) = &self.name {
|
||||
let trimmed_name = name.trim();
|
||||
if trimmed_name.is_empty() {
|
||||
return Err(raise_error!(
|
||||
"Role name cannot be set to an empty string or consist only of whitespace."
|
||||
.into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
// Prevent renaming to reserved system names
|
||||
let name_lower = trimmed_name.to_lowercase();
|
||||
if name_lower == "admin" || name_lower == "manager" || name_lower == "viewer" {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"The name '{}' is reserved for system builtin roles.",
|
||||
trimmed_name
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Validate Permissions if present
|
||||
if let Some(permissions) = &self.permissions {
|
||||
// Ensure the role doesn't end up with zero permissions
|
||||
if permissions.is_empty() {
|
||||
return Err(raise_error!(
|
||||
"Permissions list cannot be empty. A role must have at least one permission."
|
||||
.into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
// Check for invalid permission strings using a functional approach
|
||||
if let Some(invalid_permission) = permissions
|
||||
.iter()
|
||||
.find(|p| !VALID_PERMISSION_SET.contains(p.as_str()))
|
||||
{
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Invalid permission '{}' specified in the update request.",
|
||||
invalid_permission
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct UserCreateRequest {
|
||||
pub username: String,
|
||||
|
||||
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
|
||||
pub email: String,
|
||||
|
||||
pub password: String,
|
||||
|
||||
/// Global Roles: System-wide permissions (e.g., Admin, User Manager).
|
||||
pub global_roles: Vec<u64>,
|
||||
|
||||
/// Scoped Access: List of accounts paired with specific roles.
|
||||
/// This allows different permissions per account.
|
||||
pub account_access_map: BTreeMap<u64, u64>,
|
||||
|
||||
pub acl: Option<AccessControl>,
|
||||
pub avatar_base64: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub theme: Option<String>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
impl UserCreateRequest {
|
||||
pub async fn validate(&self) -> BichonResult<()> {
|
||||
let username_len = self.username.len();
|
||||
|
||||
// 1. Username constraints
|
||||
if username_len < 5 {
|
||||
return Err(raise_error!(
|
||||
"Username must be at least 5 characters long.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
if username_len > 32 {
|
||||
return Err(raise_error!(
|
||||
"Username cannot exceed 32 characters.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Password constraints
|
||||
let password_len = self.password.len();
|
||||
if password_len < 8 {
|
||||
return Err(raise_error!(
|
||||
"Password must be at least 8 characters long.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
if password_len > 256 {
|
||||
return Err(raise_error!(
|
||||
"Password cannot exceed 256 characters.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
// 3. Global Roles validation
|
||||
if self.global_roles.is_empty() {
|
||||
return Err(raise_error!(
|
||||
"Global roles list cannot be empty. At least one role must be selected.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
validate_theme(&self.theme)?;
|
||||
validate_language(&self.language)?;
|
||||
|
||||
let all_roles = UserRole::list_all().await?;
|
||||
let role_type_map: HashMap<u64, RoleType> =
|
||||
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();
|
||||
|
||||
for rid in &self.global_roles {
|
||||
match role_type_map.get(rid) {
|
||||
Some(RoleType::Global) => {}
|
||||
Some(_) => {
|
||||
return Err(raise_error!(
|
||||
format!("Role {} is not a System role", rid),
|
||||
ErrorCode::InvalidParameter
|
||||
))
|
||||
}
|
||||
None => {
|
||||
return Err(raise_error!(
|
||||
format!("System Role {} not found", rid),
|
||||
ErrorCode::InvalidParameter
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (aid, rid) in &self.account_access_map {
|
||||
if AccountModel::find(*aid).await?.is_none() {
|
||||
return Err(raise_error!(
|
||||
format!("Account {} not found", aid),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
match role_type_map.get(rid) {
|
||||
Some(RoleType::Account) => {}
|
||||
Some(_) => {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Role {} assigned to account {} must be an Account role",
|
||||
rid, aid
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
))
|
||||
}
|
||||
None => {
|
||||
return Err(raise_error!(
|
||||
format!("Role {} for account {} not found", rid, aid),
|
||||
ErrorCode::InvalidParameter
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(acl) = &self.acl {
|
||||
acl.validate()?;
|
||||
}
|
||||
|
||||
if let Some(desc) = &self.description {
|
||||
if desc.len() > 256 {
|
||||
return Err(raise_error!(
|
||||
"Description cannot exceed 256 characters.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(avatar_base64) = &self.avatar_base64 {
|
||||
decode_avatar_bytes(&avatar_base64)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct UserUpdateRequest {
|
||||
pub username: Option<String>,
|
||||
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
|
||||
pub email: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub avatar_base64: Option<String>,
|
||||
pub global_roles: Option<Vec<u64>>,
|
||||
/// Scoped Access
|
||||
pub account_access_map: Option<BTreeMap<u64, u64>>,
|
||||
pub acl: Option<AccessControl>,
|
||||
pub description: Option<String>,
|
||||
pub theme: Option<String>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
impl UserUpdateRequest {
|
||||
pub async fn validate(&self) -> BichonResult<()> {
|
||||
if let Some(username) = &self.username {
|
||||
let len = username.len();
|
||||
if len < 5 || len > 32 {
|
||||
return Err(raise_error!(
|
||||
"Username must be 5-32 characters.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(password) = &self.password {
|
||||
let len = password.len();
|
||||
if len < 8 || len > 256 {
|
||||
return Err(raise_error!(
|
||||
"Password must be 8-256 characters.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
validate_theme(&self.theme)?;
|
||||
validate_language(&self.language)?;
|
||||
|
||||
let all_roles = UserRole::list_all().await?;
|
||||
let role_type_map: HashMap<u64, RoleType> =
|
||||
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();
|
||||
|
||||
if let Some(roles) = &self.global_roles {
|
||||
if roles.is_empty() {
|
||||
return Err(raise_error!(
|
||||
"Roles list cannot be empty.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
for role_id in roles {
|
||||
match role_type_map.get(role_id) {
|
||||
Some(RoleType::Global) => {}
|
||||
Some(_) => {
|
||||
return Err(raise_error!(
|
||||
format!("Role {} is not a System role", role_id),
|
||||
ErrorCode::InvalidParameter
|
||||
))
|
||||
}
|
||||
None => {
|
||||
return Err(raise_error!(
|
||||
format!("System Role {} not found", role_id),
|
||||
ErrorCode::InvalidParameter
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(account_access_map) = &self.account_access_map {
|
||||
for (aid, rid) in account_access_map {
|
||||
if AccountModel::find(*aid).await?.is_none() {
|
||||
return Err(raise_error!(
|
||||
format!("Account {} not found", aid),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
match role_type_map.get(rid) {
|
||||
Some(RoleType::Account) => {}
|
||||
Some(_) => {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Role {} assigned to account {} must be an Account role",
|
||||
rid, aid
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
))
|
||||
}
|
||||
None => {
|
||||
return Err(raise_error!(
|
||||
format!("Role {} for account {} not found", rid, aid),
|
||||
ErrorCode::InvalidParameter
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(desc) = &self.description {
|
||||
if desc.len() > 256 {
|
||||
return Err(raise_error!(
|
||||
"Description too long.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(acl) = &self.acl {
|
||||
acl.validate()?;
|
||||
}
|
||||
|
||||
if let Some(avatar) = &self.avatar_base64 {
|
||||
decode_avatar_bytes(avatar)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeSet, HashSet},
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
users::role::RoleType,
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
|
||||
pub static VALID_PERMISSION_SET: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
||||
Permission::all_permissions()
|
||||
.into_iter()
|
||||
.map(|(key, _)| key)
|
||||
.collect()
|
||||
});
|
||||
|
||||
pub struct Permission;
|
||||
|
||||
impl Permission {
|
||||
// ----------------------------------------------------------------------
|
||||
// 1. Global Management Permissions (System, Users, Tokens)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// Basic platform access. Required for any user to log in and access the dashboard.
|
||||
/// This provides no administrative powers.
|
||||
pub const SYSTEM_ACCESS: &str = "system:access";
|
||||
|
||||
/// Manage core system configurations (OAuth Client ID/Secret, Proxy settings).
|
||||
pub const ROOT: &str = "system:root";
|
||||
|
||||
/// Create, modify, and delete all users and their roles (Admin only).
|
||||
pub const USER_MANAGE: &str = "user:manage";
|
||||
|
||||
/// View the minimal user list and basic profiles (Managers and Admins).
|
||||
pub const USER_VIEW: &str = "user:view";
|
||||
|
||||
/// View and revoke all access tokens in the system.
|
||||
pub const TOKEN_MANAGE: &str = "token:manage";
|
||||
|
||||
/// Create new email account connections.
|
||||
pub const ACCOUNT_CREATE: &str = "account:create";
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 2. Global "ALL" Scoped Permissions (Reserved for Admin)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// Manage configuration for all accounts (Global control).
|
||||
pub const ACCOUNT_MANAGE_ALL: &str = "account:manage:all";
|
||||
|
||||
/// Read mail data from all accounts (Search, view messages).
|
||||
pub const DATA_READ_ALL: &str = "data:read:all";
|
||||
|
||||
/// Download raw EML/MIME files from all accounts.
|
||||
pub const DATA_RAW_DOWNLOAD_ALL: &str = "data:raw:download:all";
|
||||
|
||||
/// Delete messages from all accounts.
|
||||
pub const DATA_DELETE_ALL: &str = "data:delete:all";
|
||||
|
||||
/// Manage metadata (e.g., tags, categories, notes) for messages in ALL email accounts.
|
||||
pub const DATA_MANAGE_ALL: &str = "data:manage:all";
|
||||
|
||||
/// Export messages in batches from all accounts.
|
||||
pub const DATA_EXPORT_BATCH_ALL: &str = "data:export:batch:all";
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 3. Scoped/Limited Permissions (Manager & Viewer)
|
||||
// Authorization requires checking the user's Account Access List (ACL)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// Manage (modify/delete/sync) configuration for a specific set of accounts.
|
||||
pub const ACCOUNT_MANAGE: &str = "account:manage";
|
||||
|
||||
/// Read details and sync status for a specific set of accounts.
|
||||
pub const ACCOUNT_READ_DETAILS: &str = "account:read_details";
|
||||
|
||||
/// Manage mail data metadata (e.g., updating tags, adding notes)
|
||||
/// for specific accounts.
|
||||
pub const DATA_MANAGE: &str = "data:manage";
|
||||
|
||||
/// Read mail data (Search, view) from a specific set of accounts.
|
||||
pub const DATA_READ: &str = "data:read";
|
||||
|
||||
/// Download raw EML/MIME files from a specific set of accounts.
|
||||
pub const DATA_RAW_DOWNLOAD: &str = "data:raw:download";
|
||||
|
||||
/// Delete messages from a specific set of accounts.
|
||||
pub const DATA_DELETE: &str = "data:delete";
|
||||
|
||||
/// Export messages in batches from a specific set of accounts.
|
||||
pub const DATA_EXPORT_BATCH: &str = "data:export:batch";
|
||||
|
||||
/// Import EML/PST data into a SPECIFIC account.
|
||||
/// Authorization requires checking access to the target account_id.
|
||||
pub const DATA_IMPORT_BATCH: &str = "data:import:batch";
|
||||
|
||||
pub fn global_permissions() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
(
|
||||
Self::SYSTEM_ACCESS,
|
||||
"Basic platform access for dashboard and personal settings.",
|
||||
),
|
||||
(Self::ROOT, "Full system access and configuration."),
|
||||
(Self::USER_MANAGE, "Create, update, and delete users."),
|
||||
(
|
||||
Self::USER_VIEW,
|
||||
"Read-only access to user list and profiles.",
|
||||
),
|
||||
(Self::TOKEN_MANAGE, "View and revoke all active API tokens."),
|
||||
(
|
||||
Self::ACCOUNT_CREATE,
|
||||
"Connect new email accounts to the system.",
|
||||
),
|
||||
(
|
||||
Self::ACCOUNT_MANAGE_ALL,
|
||||
"Manage configurations for all email accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_READ_ALL,
|
||||
"Search and read messages across all accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_MANAGE_ALL,
|
||||
"Manage metadata and tags for all accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_RAW_DOWNLOAD_ALL,
|
||||
"Download raw EML data from any account.",
|
||||
),
|
||||
(
|
||||
Self::DATA_DELETE_ALL,
|
||||
"Permanently delete messages from any account.",
|
||||
),
|
||||
(
|
||||
Self::DATA_EXPORT_BATCH_ALL,
|
||||
"Export bulk message data from all accounts.",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn account_permissions() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
(
|
||||
Self::ACCOUNT_MANAGE,
|
||||
"Update or sync settings for authorized accounts.",
|
||||
),
|
||||
(
|
||||
Self::ACCOUNT_READ_DETAILS,
|
||||
"View status and details of authorized accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_READ,
|
||||
"Read messages from authorized email accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_MANAGE,
|
||||
"Manage tags and metadata for authorized accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_RAW_DOWNLOAD,
|
||||
"Download raw EML files from authorized accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_DELETE,
|
||||
"Delete messages from authorized email accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_EXPORT_BATCH,
|
||||
"Export messages from authorized accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_IMPORT_BATCH,
|
||||
"Import external EML/PST data into authorized accounts.",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn all_permissions() -> Vec<(&'static str, &'static str)> {
|
||||
let mut all = Self::global_permissions();
|
||||
all.extend(Self::account_permissions());
|
||||
all
|
||||
}
|
||||
|
||||
fn is_account_permission(perm: &str) -> bool {
|
||||
Self::account_permissions().iter().any(|(p, _)| *p == perm)
|
||||
}
|
||||
|
||||
fn is_global_permission(perm: &str) -> bool {
|
||||
Self::global_permissions().iter().any(|(p, _)| *p == perm)
|
||||
}
|
||||
|
||||
pub fn validate_role_permissions(
|
||||
role_type: &RoleType,
|
||||
permissions: &BTreeSet<String>,
|
||||
) -> BichonResult<()> {
|
||||
for p in permissions {
|
||||
match role_type {
|
||||
RoleType::Global => {
|
||||
if !Self::is_global_permission(p) {
|
||||
return Err(raise_error!(
|
||||
format!("Permission '{}' is not a valid Global permission", p),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
RoleType::Account => {
|
||||
if !Self::is_account_permission(p) {
|
||||
return Err(raise_error!(
|
||||
format!("Permission '{}' is not a valid Account permission", p),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeSet, HashSet},
|
||||
fmt::{self, Display},
|
||||
};
|
||||
|
||||
use native_db::*;
|
||||
use native_model::{native_model, Model};
|
||||
use poem_openapi::{Enum, Object};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
id,
|
||||
modules::{
|
||||
database::{
|
||||
async_find_impl, delete_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
|
||||
update_impl, with_transaction,
|
||||
},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
users::{
|
||||
payload::{RoleCreateRequest, RoleUpdateRequest},
|
||||
permissions::*,
|
||||
},
|
||||
},
|
||||
raise_error, utc_now,
|
||||
};
|
||||
|
||||
/// Enumerates the built-in roles in the Bichon system.
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
pub enum BuiltinRole {
|
||||
Admin,
|
||||
Manager,
|
||||
Member,
|
||||
AccountManager,
|
||||
AccountViewer,
|
||||
}
|
||||
|
||||
impl Display for BuiltinRole {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let s = match self {
|
||||
BuiltinRole::Admin => "admin",
|
||||
BuiltinRole::Manager => "manager",
|
||||
BuiltinRole::Member => "member",
|
||||
BuiltinRole::AccountManager => "account_manager",
|
||||
BuiltinRole::AccountViewer => "account_viewer",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
impl BuiltinRole {
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
BuiltinRole::Admin => {
|
||||
"Full system administrator with unrestricted access to all accounts, user management, and system configurations."
|
||||
}
|
||||
BuiltinRole::Manager => {
|
||||
"Standard operational manager. Can manage users, create accounts, and perform data operations on authorized email accounts."
|
||||
}
|
||||
BuiltinRole::Member => {
|
||||
"Regular platform member. Provides basic login access to the system without any administrative or global management privileges."
|
||||
}
|
||||
BuiltinRole::AccountManager => {
|
||||
"Specific account manager. Has full administrative control over a particular email account, including configuration and data deletion."
|
||||
}
|
||||
BuiltinRole::AccountViewer => {
|
||||
"Specific account observer. Has read-only access to messages and metadata for a particular email account."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves the set of static permissions associated with the role.
|
||||
pub fn get_permissions(&self) -> HashSet<&'static str> {
|
||||
match self {
|
||||
BuiltinRole::Admin => Self::admin_permissions(),
|
||||
BuiltinRole::Manager => Self::manager_permissions(),
|
||||
BuiltinRole::Member => Self::member_permissions(),
|
||||
BuiltinRole::AccountManager => Self::account_owner_permissions(),
|
||||
BuiltinRole::AccountViewer => Self::account_viewer_permissions(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin Role: Full control over the system and all data.
|
||||
fn admin_permissions() -> HashSet<&'static str> {
|
||||
[
|
||||
// System-Wide
|
||||
Permission::ROOT,
|
||||
Permission::USER_MANAGE,
|
||||
Permission::USER_VIEW,
|
||||
Permission::TOKEN_MANAGE,
|
||||
// Account Configuration
|
||||
Permission::ACCOUNT_CREATE,
|
||||
Permission::ACCOUNT_MANAGE_ALL, // Global account management
|
||||
// Data Access (Global ALL)
|
||||
Permission::DATA_READ_ALL,
|
||||
Permission::DATA_MANAGE_ALL,
|
||||
Permission::DATA_RAW_DOWNLOAD_ALL,
|
||||
Permission::DATA_DELETE_ALL,
|
||||
Permission::DATA_EXPORT_BATCH_ALL,
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Manager Role: Data and account configuration management, limited user management.
|
||||
/// ALL data/account access must be scoped by the user's ACL.
|
||||
fn manager_permissions() -> HashSet<&'static str> {
|
||||
[Permission::USER_VIEW, Permission::ACCOUNT_CREATE]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn member_permissions() -> HashSet<&'static str> {
|
||||
[Permission::SYSTEM_ACCESS].into_iter().collect()
|
||||
}
|
||||
|
||||
fn account_owner_permissions() -> HashSet<&'static str> {
|
||||
[
|
||||
Permission::ACCOUNT_MANAGE,
|
||||
Permission::ACCOUNT_READ_DETAILS,
|
||||
Permission::DATA_READ,
|
||||
Permission::DATA_MANAGE,
|
||||
Permission::DATA_RAW_DOWNLOAD,
|
||||
Permission::DATA_DELETE,
|
||||
Permission::DATA_EXPORT_BATCH,
|
||||
Permission::DATA_IMPORT_BATCH,
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn account_viewer_permissions() -> HashSet<&'static str> {
|
||||
[Permission::ACCOUNT_READ_DETAILS, Permission::DATA_READ]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// Global Roles (Starting with 1)
|
||||
pub const DEFAULT_ADMIN_ROLE_ID: u64 = 100_000_000_000_000; // System Admin
|
||||
pub const DEFAULT_MANAGER_ROLE_ID: u64 = 100_100_000_000_000; // System Manager
|
||||
pub const DEFAULT_MEMBER_ROLE_ID: u64 = 100_200_000_000_000; // Regular Member (system:access)
|
||||
|
||||
// Account-specific Roles (Starting with 2)
|
||||
pub const DEFAULT_ACCOUNT_MANAGER_ROLE_ID: u64 = 200_100_000_000_000;
|
||||
pub const DEFAULT_ACCOUNT_VIEWER_ROLE_ID: u64 = 200_200_000_000_000;
|
||||
|
||||
fn is_builtin(id: u64) -> bool {
|
||||
matches!(
|
||||
id,
|
||||
DEFAULT_ADMIN_ROLE_ID
|
||||
| DEFAULT_MANAGER_ROLE_ID
|
||||
| DEFAULT_MEMBER_ROLE_ID
|
||||
| DEFAULT_ACCOUNT_MANAGER_ROLE_ID
|
||||
| DEFAULT_ACCOUNT_VIEWER_ROLE_ID
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
|
||||
pub enum RoleType {
|
||||
#[default]
|
||||
Global,
|
||||
Account,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
#[native_model(id = 9, version = 1)]
|
||||
#[native_db]
|
||||
pub struct UserRole {
|
||||
#[primary_key]
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub permissions: BTreeSet<String>,
|
||||
pub is_builtin: bool,
|
||||
pub created_at: i64,
|
||||
pub role_type: RoleType,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl UserRole {
|
||||
pub async fn ensure_default_roles_exists() -> BichonResult<()> {
|
||||
let builtin_roles = vec![
|
||||
(BuiltinRole::Admin, DEFAULT_ADMIN_ROLE_ID, RoleType::Global),
|
||||
(
|
||||
BuiltinRole::Manager,
|
||||
DEFAULT_MANAGER_ROLE_ID,
|
||||
RoleType::Global,
|
||||
),
|
||||
(
|
||||
BuiltinRole::Member,
|
||||
DEFAULT_MEMBER_ROLE_ID,
|
||||
RoleType::Global,
|
||||
),
|
||||
(
|
||||
BuiltinRole::AccountManager,
|
||||
DEFAULT_ACCOUNT_MANAGER_ROLE_ID,
|
||||
RoleType::Account,
|
||||
),
|
||||
(
|
||||
BuiltinRole::AccountViewer,
|
||||
DEFAULT_ACCOUNT_VIEWER_ROLE_ID,
|
||||
RoleType::Account,
|
||||
),
|
||||
];
|
||||
|
||||
with_transaction(DB_MANAGER.meta_db(), move |rw| {
|
||||
let now = utc_now!();
|
||||
|
||||
for (role, role_id, role_type) in builtin_roles {
|
||||
let exists = rw
|
||||
.get()
|
||||
.primary::<UserRole>(role_id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.is_some();
|
||||
|
||||
if !exists {
|
||||
let permissions: BTreeSet<String> = role
|
||||
.get_permissions()
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
rw.insert(UserRole {
|
||||
id: role_id,
|
||||
name: role.to_string(),
|
||||
description: Some(role.description().to_string()),
|
||||
permissions,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
is_builtin: true,
|
||||
role_type,
|
||||
})
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_all() -> BichonResult<Vec<UserRole>> {
|
||||
list_all_impl(DB_MANAGER.meta_db()).await
|
||||
}
|
||||
|
||||
pub async fn find(role_id: u64) -> BichonResult<Option<UserRole>> {
|
||||
async_find_impl(DB_MANAGER.meta_db(), role_id).await
|
||||
}
|
||||
|
||||
pub async fn create(request: RoleCreateRequest) -> BichonResult<UserRole> {
|
||||
let _ = &request.validate().await?;
|
||||
let now = utc_now!();
|
||||
let new_role = UserRole {
|
||||
id: id!(64),
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
permissions: request.permissions,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
is_builtin: false,
|
||||
role_type: request.role_type,
|
||||
};
|
||||
insert_impl(DB_MANAGER.meta_db(), new_role.clone()).await?;
|
||||
Ok(new_role)
|
||||
}
|
||||
|
||||
pub async fn update(id: u64, request: RoleUpdateRequest) -> BichonResult<()> {
|
||||
if is_builtin(id) && request.permissions.is_some() {
|
||||
return Err(raise_error!(
|
||||
"The permissions of a builtin role are immutable. Please create a custom role instead.".into(),
|
||||
ErrorCode::Forbidden
|
||||
));
|
||||
}
|
||||
let _ = &request.validate().await?;
|
||||
|
||||
if let Some(permissions) = &request.permissions {
|
||||
let role = Self::find(id).await?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("UserRole with id={} not found", id),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
Permission::validate_role_permissions(&role.role_type, permissions)?;
|
||||
}
|
||||
|
||||
update_impl(
|
||||
DB_MANAGER.meta_db(),
|
||||
move |rw| {
|
||||
rw.get()
|
||||
.primary::<UserRole>(id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("UserRole with id={} not found", id),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})
|
||||
},
|
||||
move |current| {
|
||||
let mut updated = current.clone();
|
||||
if let Some(name) = request.name {
|
||||
updated.name = name;
|
||||
}
|
||||
|
||||
if let Some(desc) = request.description {
|
||||
updated.description = Some(desc);
|
||||
}
|
||||
|
||||
if let Some(permissions) = request.permissions {
|
||||
updated.permissions = permissions;
|
||||
}
|
||||
updated.updated_at = utc_now!();
|
||||
Ok(updated)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(id: u64) -> BichonResult<()> {
|
||||
if is_builtin(id) {
|
||||
return Err(raise_error!(
|
||||
format!("Cannot delete a default system role (ID: {}).", id),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
delete_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get()
|
||||
.primary::<UserRole>(id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("UserRole '{}' not found during deletion process.", id),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::modules::users::acl::AccessControl;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct UserView {
|
||||
pub id: u64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
|
||||
pub password: Option<String>,
|
||||
|
||||
/// Scoped Access: Defines per-account permissions.
|
||||
/// Example:
|
||||
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
|
||||
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
|
||||
pub account_access_map: BTreeMap<u64, u64>,
|
||||
pub account_roles_summary: BTreeMap<u64, String>,
|
||||
pub account_permissions: BTreeMap<u64, Vec<String>>,
|
||||
pub description: Option<String>,
|
||||
/// Global Roles: Permissions that apply to the whole system
|
||||
/// (e.g., system settings, creating new users).
|
||||
pub global_roles: Vec<u64>,
|
||||
pub global_roles_names: Vec<String>,
|
||||
pub global_permissions: Vec<String>,
|
||||
pub avatar: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
/// Optional access control settings
|
||||
pub acl: Option<AccessControl>,
|
||||
pub theme: Option<String>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
@@ -16,18 +16,34 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use ring::aead::{Aad, BoundKey, Nonce, NonceSequence, OpeningKey, SealingKey, AES_256_GCM};
|
||||
use ring::pbkdf2::{self, derive};
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
use std::fs;
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::raise_error;
|
||||
|
||||
static ENCRYPT_PASSWORD: LazyLock<String> = LazyLock::new(|| {
|
||||
if let Some(file_path) = &SETTINGS.bichon_encrypt_password_file {
|
||||
return fs::read_to_string(file_path)
|
||||
.expect("failed to read the file with the encrypt password")
|
||||
.trim()
|
||||
.to_string();
|
||||
}
|
||||
|
||||
if let Some(p) = &SETTINGS.bichon_encrypt_password {
|
||||
return p.clone();
|
||||
}
|
||||
|
||||
panic!("Neither encrypt_password nor encrypt_password_file is set. This should have been validated by SETTINGS.");
|
||||
});
|
||||
|
||||
struct SingleNonceSequence([u8; 12]);
|
||||
|
||||
impl SingleNonceSequence {
|
||||
@@ -43,12 +59,12 @@ impl NonceSequence for SingleNonceSequence {
|
||||
}
|
||||
|
||||
pub fn encrypt_string(plaintext: &str) -> BichonResult<String> {
|
||||
internal_encrypt_string(&SETTINGS.bichon_encrypt_password, plaintext)
|
||||
internal_encrypt_string(&ENCRYPT_PASSWORD, plaintext)
|
||||
.map_err(|_| raise_error!("Failed to encrypt string.".into(), ErrorCode::InternalError))
|
||||
}
|
||||
|
||||
pub fn decrypt_string(data: &str) -> BichonResult<String> {
|
||||
internal_decrypt_string(&SETTINGS.bichon_encrypt_password, data).map_err(|_| {
|
||||
internal_decrypt_string(&ENCRYPT_PASSWORD, data).map_err(|_| {
|
||||
raise_error!(
|
||||
"Decryption failed, likely due to incorrect encryption key or corrupted data".into(),
|
||||
ErrorCode::InternalError
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use std::{fs, io, path::PathBuf};
|
||||
|
||||
use crate::modules::error::BichonResult;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use rand::{rng, Rng};
|
||||
|
||||
@@ -310,3 +311,26 @@ pub fn get_total_size(path: &PathBuf) -> io::Result<u64> {
|
||||
|
||||
Ok(total_size)
|
||||
}
|
||||
|
||||
const MAX_AVATAR_BYTES: usize = 128 * 1024;
|
||||
|
||||
pub fn decode_avatar_bytes(base64_str: &str) -> BichonResult<Vec<u8>> {
|
||||
let bytes = STANDARD.decode(base64_str).map_err(|e| {
|
||||
raise_error!(
|
||||
format!("Invalid avatar base64 encoding: {}", e),
|
||||
ErrorCode::InvalidParameter
|
||||
)
|
||||
})?;
|
||||
|
||||
if bytes.len() > MAX_AVATAR_BYTES {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Avatar image exceeds maximum size ({} KB).",
|
||||
MAX_AVATAR_BYTES / 1024
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use dashmap::DashMap;
|
||||
use governor::{
|
||||
clock::{QuantaClock, QuantaInstant},
|
||||
@@ -30,14 +29,14 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::modules::token::RateLimit;
|
||||
use crate::modules::users::acl::RateLimit;
|
||||
|
||||
pub static RATE_LIMITER_MANAGER: LazyLock<TokenRateLimiter> = LazyLock::new(TokenRateLimiter::new);
|
||||
pub static RATE_LIMITER_MANAGER: LazyLock<UserRateLimiter> = LazyLock::new(UserRateLimiter::new);
|
||||
|
||||
pub struct TokenRateLimiter {
|
||||
pub struct UserRateLimiter {
|
||||
limiters: Arc<
|
||||
DashMap<
|
||||
String,
|
||||
u64,
|
||||
(
|
||||
Arc<RateLimiter<NotKeyed, InMemoryState, QuantaClock, NoOpMiddleware>>,
|
||||
RateLimit,
|
||||
@@ -46,29 +45,29 @@ pub struct TokenRateLimiter {
|
||||
>,
|
||||
}
|
||||
|
||||
impl TokenRateLimiter {
|
||||
impl UserRateLimiter {
|
||||
pub fn new() -> Self {
|
||||
TokenRateLimiter {
|
||||
UserRateLimiter {
|
||||
limiters: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check(
|
||||
&self,
|
||||
token: &str,
|
||||
user_id: u64,
|
||||
limit: RateLimit,
|
||||
) -> Result<(), NotUntil<QuantaInstant>> {
|
||||
let limiter = self.get_or_update_limiter(token, limit).await;
|
||||
let limiter = self.get_or_update_limiter(user_id, limit).await;
|
||||
limiter.check()
|
||||
}
|
||||
|
||||
async fn get_or_update_limiter(
|
||||
&self,
|
||||
token: &str,
|
||||
user_id: u64,
|
||||
limit: RateLimit,
|
||||
) -> Arc<RateLimiter<NotKeyed, InMemoryState, QuantaClock, NoOpMiddleware>> {
|
||||
self.limiters
|
||||
.entry(token.to_string())
|
||||
.entry(user_id)
|
||||
.and_modify(|(existing_limiter, current_limit)| {
|
||||
if current_limit.interval != limit.interval || current_limit.quota != limit.quota {
|
||||
let quota = Quota::with_period(Duration::from_secs(limit.interval))
|
||||
@@ -100,4 +99,4 @@ impl TokenRateLimiter {
|
||||
.0
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -10,6 +10,7 @@
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
@@ -17,5 +18,7 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
"registries": {
|
||||
"@reui": "https://reui.io/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -58,9 +58,10 @@
|
||||
"i18next": "^25.6.3",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.468.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^18.3.1",
|
||||
"react-ace": "^13.0.0",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-day-picker": "9.13.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"react-i18next": "^16.3.5",
|
||||
|
||||
Generated
+1775
-50
File diff suppressed because it is too large
Load Diff
@@ -1,64 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import axiosInstance from "@/api/axiosInstance";
|
||||
import { AccessToken } from "@/features/access-tokens/data/schema";
|
||||
|
||||
export const login = async (password: string) => {
|
||||
const response = await axiosInstance.post(`/api/login`, password, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const reset_root_token = async () => {
|
||||
const response = await axiosInstance.post("/api/v1/reset-root-token");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const reset_root_password = async (password: string) => {
|
||||
const response = await axiosInstance.post("/api/v1/reset-root-password", password, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const list_access_tokens = async () => {
|
||||
const response = await axiosInstance.get<AccessToken[]>("/api/v1/access-token-list");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const create_access_token = async (data: Record<string, any>) => {
|
||||
const response = await axiosInstance.post("/api/v1/access-token", data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const update_access_token = async (token: string, data: Record<string, any>) => {
|
||||
const response = await axiosInstance.post(`/api/v1/access-token/${token}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const delete_access_token = async (token: string) => {
|
||||
const response = await axiosInstance.delete(`/api/v1/access-token/${token}`);
|
||||
return response.data;
|
||||
}
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
|
||||
import axiosInstance from "@/api/axiosInstance";
|
||||
import { AccountModel } from "@/features/accounts/data/schema";
|
||||
import { PaginatedResponse } from "..";
|
||||
|
||||
export interface MinimalAccount {
|
||||
@@ -56,6 +55,59 @@ export interface MailboxBatchProgress {
|
||||
current_batch: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
type Encryption = 'Ssl' | 'StartTls' | 'None';
|
||||
type AuthType = 'Password' | 'OAuth2';
|
||||
type Unit = 'Days' | 'Months' | 'Years';
|
||||
type AccountType = 'IMAP' | 'NoSync';
|
||||
// Interface definitions
|
||||
interface AuthConfig {
|
||||
auth_type: AuthType;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface ImapConfig {
|
||||
host: string;
|
||||
port: number; // integer, 0-65535
|
||||
encryption: Encryption;
|
||||
auth: AuthConfig;
|
||||
use_proxy?: number;
|
||||
}
|
||||
|
||||
interface RelativeDate {
|
||||
unit: Unit;
|
||||
value: number; // integer, minimum 1
|
||||
}
|
||||
|
||||
interface DateSelection {
|
||||
fixed?: string; // format: "YYYY-MM-DD"
|
||||
relative?: RelativeDate;
|
||||
}
|
||||
|
||||
export interface AccountModel {
|
||||
id: number;
|
||||
account_type: AccountType;
|
||||
imap?: ImapConfig;
|
||||
enabled: boolean;
|
||||
name?: string,
|
||||
email: string;
|
||||
capabilities?: string[];
|
||||
date_since?: DateSelection;
|
||||
date_before?: RelativeDate;
|
||||
folder_limit?: number,
|
||||
sync_folders: string[];
|
||||
sync_interval_min?: number;
|
||||
sync_batch_size?: number;
|
||||
created_by: number;
|
||||
created_user_name: string;
|
||||
created_user_email: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
use_proxy?: number
|
||||
use_dangerous: boolean
|
||||
}
|
||||
|
||||
export const account_state = async (account_id: number) => {
|
||||
const response = await axiosInstance.get<AccountRunningState>(`/api/v1/account-state/${account_id}`);
|
||||
return response.data;
|
||||
@@ -103,3 +155,8 @@ export const autoconfig = async (email: string) => {
|
||||
const response = await axiosInstance.get<AutoConfigResult>(`/api/v1/autoconfig/${email}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const access_assign = async (data: Record<string, any>) => {
|
||||
const response = await axiosInstance.post("/api/v1/accounts/access/assignments", data);
|
||||
return response.data;
|
||||
};
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { getAccessToken } from "@/stores/authStore";
|
||||
import { getToken } from "@/stores/authStore";
|
||||
import axios from "axios";
|
||||
|
||||
// Create an Axios instance
|
||||
@@ -36,9 +36,9 @@ const axiosInstance = axios.create({
|
||||
// Add a request interceptor to include the access token in headers
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
const accessToken = getAccessToken(); // Retrieve access token from localStorage
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
const stored = getToken(); // Retrieve access token from localStorage
|
||||
if (stored) {
|
||||
config.headers.Authorization = `Bearer ${stored.accessToken}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface EmailEnvelope {
|
||||
id: number;
|
||||
message_id: string;
|
||||
account_id: number;
|
||||
account_email?: string;
|
||||
mailbox_name?: string;
|
||||
uid: number;
|
||||
subject: string;
|
||||
text: string;
|
||||
|
||||
@@ -48,7 +48,7 @@ export const get_thread_messages = async (accountId: number, thread_id: number,
|
||||
}
|
||||
|
||||
export const download_attachment = async (accountId: number, id: number, attachmentFileName: string) => {
|
||||
const response = await axiosInstance.get(`/api/v1/download-attachment/${accountId}?id=${id}&name=${attachmentFileName}`, { responseType: 'blob' });
|
||||
const response = await axiosInstance.get(`/api/v1/download-attachment/${accountId}/${id}?name=${attachmentFileName}`, { responseType: 'blob' });
|
||||
const blob = new Blob([response.data]);
|
||||
saveAs(blob, attachmentFileName);
|
||||
};
|
||||
@@ -83,7 +83,7 @@ export const getContent = (messageContent: MessageContentResponse): string | nul
|
||||
};
|
||||
|
||||
export const load_message = async (accountId: number, id: number) => {
|
||||
const response = await axiosInstance.get<MessageContentResponse>(`/api/v1/message-content/${accountId}?id=${id}`);
|
||||
const response = await axiosInstance.get<MessageContentResponse>(`/api/v1/message-content/${accountId}/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -93,7 +93,16 @@ export const delete_messages = async (payload: Record<string, number[]>) => {
|
||||
};
|
||||
|
||||
export const download_message = async (accountId: number, id: number) => {
|
||||
const response = await axiosInstance.get(`/api/v1/download-message/${accountId}?id=${id}`, { responseType: 'blob' });
|
||||
const response = await axiosInstance.get(`/api/v1/download-message/${accountId}/${id}`, { responseType: 'blob' });
|
||||
const blob = new Blob([response.data]);
|
||||
saveAs(blob, `${id}.eml`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const restore_message = async (accountId: number, messageIds: number[]) => {
|
||||
const response = await axiosInstance.post(`/api/v1/restore-messages/${accountId}`, {
|
||||
message_ids: messageIds,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
|
||||
import axiosInstance from "@/api/axiosInstance";
|
||||
import { Proxy } from "@/features/settings/proxy/data/schema";
|
||||
|
||||
export interface Release {
|
||||
tag_name: string;
|
||||
@@ -73,6 +72,34 @@ export interface LargestEmail {
|
||||
size_bytes: number; // Email size in bytes
|
||||
}
|
||||
|
||||
export interface Proxy {
|
||||
id: number;
|
||||
url: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export type ServerConfigurations = {
|
||||
bichon_log_level: string
|
||||
bichon_http_port: number
|
||||
bichon_bind_ip?: string | null
|
||||
bichon_public_url: string
|
||||
bichon_cors_origins?: string[] | null
|
||||
bichon_cors_max_age: number
|
||||
bichon_ansi_logs: boolean
|
||||
bichon_log_to_file: boolean
|
||||
bichon_json_logs: boolean
|
||||
bichon_max_server_log_files: number
|
||||
bichon_encrypt_password_set: boolean
|
||||
bichon_webui_token_expiration_hours: number
|
||||
bichon_root_dir: string
|
||||
bichon_metadata_cache_size?: number | null
|
||||
bichon_envelope_cache_size?: number | null
|
||||
bichon_enable_rest_https: boolean
|
||||
bichon_http_compression_enabled: boolean
|
||||
bichon_sync_concurrency?: number | null
|
||||
}
|
||||
|
||||
export const get_dashboard_stats = async () => {
|
||||
const response = await axiosInstance.get<DashboardStats>(`/api/v1/dashboard-stats`);
|
||||
return response.data;
|
||||
@@ -104,4 +131,10 @@ export const add_proxy = async (url: string) => {
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const get_system_configurations = async () => {
|
||||
const response = await axiosInstance.get<ServerConfigurations>(`/api/v1/system-configurations`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import axiosInstance from "@/api/axiosInstance";
|
||||
|
||||
export type RoleType = 'Global' | 'Account';
|
||||
|
||||
export interface UserRole {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: string[];
|
||||
is_builtin: boolean;
|
||||
role_type: RoleType;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export function getPermissions(t: (key: string) => string) {
|
||||
return [
|
||||
// 1. Global Management
|
||||
{ label: t('permission.system.access'), value: 'system:access' },
|
||||
{ label: t('permission.system.root'), value: 'system:root' },
|
||||
{ label: t('permission.user.manage'), value: 'user:manage' },
|
||||
{ label: t('permission.user.view'), value: 'user:view' },
|
||||
{ label: t('permission.token.manage'), value: 'token:manage' },
|
||||
{ label: t('permission.account.create'), value: 'account:create' },
|
||||
|
||||
// 2. Global "ALL" Scoped (Admin)
|
||||
{ label: t('permission.account.manage_all'), value: 'account:manage:all' },
|
||||
{ label: t('permission.data.read_all'), value: 'data:read:all' },
|
||||
{ label: t('permission.data.manage_all'), value: 'data:manage:all' },
|
||||
{ label: t('permission.data.raw_download_all'), value: 'data:raw:download:all' },
|
||||
{ label: t('permission.data.delete_all'), value: 'data:delete:all' },
|
||||
{ label: t('permission.data.export_batch_all'), value: 'data:export:batch:all' },
|
||||
|
||||
// 3. Scoped / Limited
|
||||
{ label: t('permission.account.manage'), value: 'account:manage' },
|
||||
{ label: t('permission.account.read_details'), value: 'account:read_details' },
|
||||
{ label: t('permission.data.read'), value: 'data:read' },
|
||||
{ label: t('permission.data.manage'), value: 'data:manage' },
|
||||
{ label: t('permission.data.raw_download'), value: 'data:raw:download' },
|
||||
{ label: t('permission.data.delete'), value: 'data:delete' },
|
||||
{ label: t('permission.data.export_batch'), value: 'data:export:batch' },
|
||||
{ label: t('permission.data.import_batch'), value: 'data:import:batch' },
|
||||
]
|
||||
}
|
||||
|
||||
export interface RateLimit {
|
||||
quota: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
export interface AccessControl {
|
||||
ip_whitelist?: string[];
|
||||
rate_limit?: RateLimit;
|
||||
}
|
||||
|
||||
export type TokenType = "WebUI" | "Api";
|
||||
|
||||
export interface AccessToken {
|
||||
user_id: number;
|
||||
user_name: string,
|
||||
user_email: string,
|
||||
token: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
name?: string;
|
||||
last_access_at: number;
|
||||
expire_at?: number | null;
|
||||
token_type: TokenType;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
password?: string | null;
|
||||
description?: string | null;
|
||||
global_roles: number[];
|
||||
global_roles_names: string[];
|
||||
avatar?: string;
|
||||
acl?: AccessControl;
|
||||
account_access_map: Record<number, number>;
|
||||
account_roles_summary: Record<number, string>;
|
||||
global_permissions: string[]
|
||||
account_permissions: Record<number, string[]>
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
|
||||
export interface LoginResult {
|
||||
success: boolean;
|
||||
error_message?: string | null;
|
||||
access_token?: string | null;
|
||||
theme?: Theme,
|
||||
language?: string,
|
||||
}
|
||||
|
||||
|
||||
export interface MinimalUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
|
||||
export const login = async (data: Record<string, any>) => {
|
||||
const response = await axiosInstance.post<LoginResult>(`/api/login`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const reset_admin_token = async () => {
|
||||
const response = await axiosInstance.post("/api/v1/reset-admin-token");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const reset_admin_password = async (password: string) => {
|
||||
const response = await axiosInstance.post("/api/v1/reset-admin-password", password, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const list_access_tokens = async () => {
|
||||
const response = await axiosInstance.get<AccessToken[]>("/api/v1/access-token-list");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const create_access_token = async (data: Record<string, any>) => {
|
||||
const response = await axiosInstance.post("/api/v1/access-token", data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const update_access_token = async (token: string, data: Record<string, any>) => {
|
||||
const response = await axiosInstance.post(`/api/v1/access-token/${token}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const remove_access_token = async (token: string) => {
|
||||
const response = await axiosInstance.delete(`/api/v1/access-token/${token}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
export const list_roles = async () => {
|
||||
const response = await axiosInstance.get<UserRole[]>("/api/v1/list-roles");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const remove_role = async (id: number) => {
|
||||
const response = await axiosInstance.delete(`/api/v1/roles/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const create_role = async (data: Record<string, any>) => {
|
||||
const response = await axiosInstance.post("/api/v1/roles", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const update_role = async (id: number, data: Record<string, any>) => {
|
||||
const response = await axiosInstance.post(`/api/v1/roles/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const list_users = async () => {
|
||||
const response = await axiosInstance.get<User[]>("/api/v1/list-users");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const list_minimal_users = async () => {
|
||||
const response = await axiosInstance.get<MinimalUser[]>("/api/v1/minimal-user-list");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const remove_user = async (id: number) => {
|
||||
const response = await axiosInstance.delete(`/api/v1/users/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const create_user = async (data: Record<string, any>) => {
|
||||
const response = await axiosInstance.post("/api/v1/users", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const update_user = async (id: number, data: Record<string, any>) => {
|
||||
const response = await axiosInstance.post(`/api/v1/users/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const get_user_tokens = async (id: number) => {
|
||||
const response = await axiosInstance.get<AccessToken[]>(`/api/v1/user-tokens/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const get_current_user = async () => {
|
||||
const response = await axiosInstance.get<User>("/api/v1/current-user");
|
||||
return response.data;
|
||||
};
|
||||
@@ -22,10 +22,11 @@ import { FixedHeader } from "./layout/fixed-header";
|
||||
import { Main } from "./layout/main";
|
||||
import Logo from '@/assets/logo.svg'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Separator } from "./ui/separator";
|
||||
|
||||
export default function APIDocs() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
||||
const docsOptions = [
|
||||
{ name: t('apiDocs.swaggerUI'), path: "/api-docs/swagger" },
|
||||
{ name: t('apiDocs.reDoc'), path: "/api-docs/redoc" },
|
||||
@@ -51,6 +52,7 @@ export default function APIDocs() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className='mt-2 mb-4 lg:mt-3 lg:mb-6' />
|
||||
<div className='-mx-4 flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0'>
|
||||
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-6 p-4'>
|
||||
<div className="grid w-full gap-4 sm:grid-cols-1 md:grid-cols-2 xl:max-w-4xl">
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
@@ -79,6 +80,7 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
onClick={handleConfirm}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{confirmText ?? t('dialogs.continue')}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import i18n from '@/i18n'
|
||||
import { dateFnsLocaleMap } from '@/lib/utils'
|
||||
import { enUS } from 'date-fns/locale'
|
||||
|
||||
type DatePickerProps = {
|
||||
selected: Date | undefined
|
||||
@@ -19,6 +22,10 @@ export function DatePicker({
|
||||
onSelect,
|
||||
placeholder = 'Pick a date',
|
||||
}: DatePickerProps) {
|
||||
|
||||
const currentLang = i18n.language.toLowerCase().replace('_', '-');
|
||||
const dateLocale = dateFnsLocaleMap[currentLang] || enUS;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -28,7 +35,7 @@ export function DatePicker({
|
||||
className='data-[empty=true]:text-muted-foreground w-[240px] justify-start text-start font-normal'
|
||||
>
|
||||
{selected ? (
|
||||
format(selected, 'MMM d, yyyy')
|
||||
format(selected, 'PPP', { locale: dateLocale })
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
)}
|
||||
|
||||
@@ -39,6 +39,7 @@ const LANGUAGES = [
|
||||
{ code: 'ko', label: '한국어' },
|
||||
{ code: 'nl', label: 'Nederlands' },
|
||||
{ code: 'no', label: 'Norsk' },
|
||||
{ code: 'pl', label: 'Polski' },
|
||||
{ code: 'pt', label: 'Português' },
|
||||
{ code: 'ru', label: 'Русский' },
|
||||
{ code: 'sv', label: 'Svenska' },
|
||||
|
||||
@@ -20,16 +20,18 @@
|
||||
import {
|
||||
IconHelp,
|
||||
IconLayoutDashboard,
|
||||
IconLockAccess,
|
||||
IconSettings
|
||||
} from '@tabler/icons-react'
|
||||
import { IdCard, Inbox, Mailbox, Search } from 'lucide-react'
|
||||
import { IdCard, Inbox, Mailbox, Search, Users2 } from 'lucide-react'
|
||||
import { type SidebarData } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCurrentUser } from '@/hooks/use-current-user'
|
||||
|
||||
export function useSidebarData(): SidebarData {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
||||
const { require_any_permission } = useCurrentUser()
|
||||
|
||||
return {
|
||||
navGroups: [
|
||||
{
|
||||
@@ -69,11 +71,18 @@ export function useSidebarData(): SidebarData {
|
||||
title: t('navigation.oauth2'),
|
||||
url: '/oauth2',
|
||||
icon: IdCard,
|
||||
},
|
||||
visible: require_any_permission(['system:root', 'account:create']),
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('navigation.users'),
|
||||
items: [
|
||||
{
|
||||
title: t('navigation.accessTokens'),
|
||||
url: '/access-tokens',
|
||||
icon: IconLockAccess,
|
||||
title: t('navigation.users'),
|
||||
url: '/users',
|
||||
icon: Users2,
|
||||
visible: require_any_permission(['system:root', 'user:manage']),
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import { Link, useLocation } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
@@ -50,11 +31,16 @@ import { NavCollapsible, NavItem, NavLink, type NavGroup } from './types'
|
||||
export function NavGroup({ title, items }: NavGroup) {
|
||||
const { state } = useSidebar()
|
||||
const href = useLocation({ select: (location) => location.href })
|
||||
|
||||
const visibleItems = items.filter(item => item.visible !== false)
|
||||
|
||||
if (visibleItems.length === 0) return null
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{title}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
{visibleItems.map((item) => {
|
||||
const key = `${item.title}-${item.url}`
|
||||
|
||||
if (!item.items)
|
||||
@@ -103,6 +89,10 @@ const SidebarMenuCollapsible = ({
|
||||
href: string
|
||||
}) => {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
const visibleSubItems = item.items.filter(sub => sub.visible !== false)
|
||||
|
||||
if (visibleSubItems.length === 0) return null
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
asChild
|
||||
@@ -120,7 +110,7 @@ const SidebarMenuCollapsible = ({
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='CollapsibleContent'>
|
||||
<SidebarMenuSub>
|
||||
{item.items.map((subItem) => (
|
||||
{visibleSubItems.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
@@ -148,6 +138,10 @@ const SidebarMenuCollapsedDropdown = ({
|
||||
item: NavCollapsible
|
||||
href: string
|
||||
}) => {
|
||||
const visibleSubItems = item.items.filter(sub => sub.visible !== false)
|
||||
|
||||
if (visibleSubItems.length === 0) return null
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
@@ -167,7 +161,7 @@ const SidebarMenuCollapsedDropdown = ({
|
||||
{item.title} {item.badge ? `(${item.badge})` : ''}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{item.items.map((sub) => (
|
||||
{visibleSubItems.map((sub) => (
|
||||
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
|
||||
<Link
|
||||
to={sub.url}
|
||||
@@ -189,11 +183,11 @@ const SidebarMenuCollapsedDropdown = ({
|
||||
|
||||
function checkIsActive(href: string, item: NavItem, mainNav = false) {
|
||||
return (
|
||||
href === item.url || // /endpint?search=param
|
||||
href.split('?')[0] === item.url || // endpoint
|
||||
!!item?.items?.filter((i) => i.url === href).length || // if child nav is active
|
||||
href === item.url ||
|
||||
href.split('?')[0] === item.url ||
|
||||
!!item?.items?.filter((i) => i.url === href).length ||
|
||||
(mainNav &&
|
||||
href.split('/')[1] !== '' &&
|
||||
href.split('/')[1] === item?.url?.split('/')[1])
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ interface BaseNavItem {
|
||||
title: string
|
||||
badge?: string
|
||||
icon?: React.ElementType
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
type NavLink = BaseNavItem & {
|
||||
@@ -31,7 +32,7 @@ type NavLink = BaseNavItem & {
|
||||
}
|
||||
|
||||
type NavCollapsible = BaseNavItem & {
|
||||
items: (BaseNavItem & { url: LinkProps['to'] })[]
|
||||
items: (BaseNavItem & { url: LinkProps['to']; visible?: boolean })[]
|
||||
url?: never
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DoubleArrowLeftIcon,
|
||||
DoubleArrowRightIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { showNumbers } from '@/lib/utils'
|
||||
|
||||
interface PaginationProps {
|
||||
totalItems: number
|
||||
@@ -66,6 +69,9 @@ export function EnvelopeListPagination({
|
||||
setPageIndex(newPageIndex)
|
||||
}
|
||||
|
||||
const currentPage = pageIndex + 1;
|
||||
const pageNumbers = showNumbers(currentPage, pageCount)
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between space-x-2 overflow-auto px-2'>
|
||||
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
|
||||
@@ -94,6 +100,14 @@ export function EnvelopeListPagination({
|
||||
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
onClick={() => setPageIndex(0)}
|
||||
disabled={pageIndex === 0}
|
||||
>
|
||||
<DoubleArrowLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-8 w-8 p-0'
|
||||
@@ -103,6 +117,22 @@ export function EnvelopeListPagination({
|
||||
<span className='sr-only'>{t("table.prevPage")}</span>
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{pageNumbers.map((pageNumber, index) => (
|
||||
<div key={`${pageNumber}-${index}`} className='flex items-center'>
|
||||
{pageNumber === '...' ? (
|
||||
<span className='px-1 text-sm text-muted-foreground'>...</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === pageNumber ? 'default' : 'outline'}
|
||||
className='h-8 min-w-8 px-2'
|
||||
onClick={() => setPageIndex((pageNumber as number) - 1)}
|
||||
>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-8 w-8 p-0'
|
||||
@@ -112,6 +142,14 @@ export function EnvelopeListPagination({
|
||||
<span className='sr-only'>{t("table.nextPage")}</span>
|
||||
<ChevronRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
onClick={() => setPageIndex(pageCount - 1)}
|
||||
disabled={!hasNextPage()}
|
||||
>
|
||||
<DoubleArrowRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,48 +22,75 @@ import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { LogoutConfirmDialog } from '@/features/auth/sign-in/components/logout';
|
||||
import { resetAccessToken } from '@/stores/authStore';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/use-current-user';
|
||||
import useDialogState from '@/hooks/use-dialog-state';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { SignOutDialog } from './sign-out-dialog';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function ProfileDropdown() {
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useDialogState()
|
||||
const { t } = useTranslation()
|
||||
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false)
|
||||
|
||||
const handleLogout = () => {
|
||||
resetAccessToken()
|
||||
navigate({ to: '/sign-in' })
|
||||
}
|
||||
const { data: user } = useCurrentUser()
|
||||
|
||||
const avatarSrc = useMemo(() => {
|
||||
const base64 = user?.avatar;
|
||||
if (!base64 || base64.length === 0) return null;
|
||||
return `data:image/png;base64,${base64}`;
|
||||
}, [user]);
|
||||
|
||||
const fallbackName = user?.username ? user.username.charAt(0).toUpperCase() : 'U';
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
|
||||
<Avatar className='h-8 w-8'>
|
||||
<AvatarFallback className='text-xs'>root</AvatarFallback>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
{avatarSrc ? (
|
||||
<img src={avatarSrc} alt={t('profile.avatar_alt')} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<AvatarFallback className="text-xs">{fallbackName}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className='w-56' align='end' forceMount>
|
||||
<DropdownMenuItem onClick={() => setIsLogoutDialogOpen(true)}>
|
||||
{t('auth.logout')}
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
<DropdownMenuLabel className='font-normal'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<p className='text-sm leading-none font-medium'>{user?.username}</p>
|
||||
<p className='text-muted-foreground text-xs leading-none'>
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/settings/profile'>{t('profile.menu.profile')}</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/settings'>{t('profile.menu.settings')}</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setOpen(true)}>
|
||||
{t('profile.menu.sign_out')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<LogoutConfirmDialog
|
||||
open={isLogoutDialogOpen}
|
||||
onOpenChange={setIsLogoutDialogOpen}
|
||||
handleConfirm={handleLogout}
|
||||
/>
|
||||
<SignOutDialog open={!!open} onOpenChange={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { resetToken } from '@/stores/authStore'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface SignOutDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { t } = useTranslation()
|
||||
const handleSignOut = () => {
|
||||
resetToken()
|
||||
const currentPath = location.href
|
||||
navigate({
|
||||
to: '/sign-in',
|
||||
search: { redirect: currentPath },
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('sign_out.title', 'Sign out')}
|
||||
desc={t(
|
||||
'sign_out.desc',
|
||||
'Are you sure you want to sign out? You will need to sign in again to access your account.'
|
||||
)}
|
||||
confirmText={t('sign_out.confirm', 'Sign out')}
|
||||
destructive
|
||||
handleConfirm={handleSignOut}
|
||||
className="sm:max-w-sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -55,4 +55,4 @@ const AlertDescription = React.forwardRef<
|
||||
))
|
||||
AlertDescription.displayName = 'AlertDescription'
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -1,69 +1,210 @@
|
||||
import * as React from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { DayPicker } from 'react-day-picker'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-3', className)}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||
month: 'space-y-4',
|
||||
caption: 'flex justify-center pt-1 relative items-center',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'space-x-1 flex items-center',
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months
|
||||
),
|
||||
nav_button_previous: 'absolute left-1',
|
||||
nav_button_next: 'absolute right-1',
|
||||
table: 'w-full border-collapse space-y-1',
|
||||
head_row: 'flex',
|
||||
head_cell:
|
||||
'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
|
||||
row: 'flex w-full mt-2',
|
||||
cell: cn(
|
||||
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
|
||||
props.mode === 'range'
|
||||
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
|
||||
: '[&:has([aria-selected])]:rounded-md'
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"bg-popover absolute inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-[--cell-size] select-none",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-muted-foreground select-none text-[0.8rem]",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-8 w-8 p-0 font-normal aria-selected:opacity-100'
|
||||
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
day_range_start: 'day-range-start',
|
||||
day_range_end: 'day-range-end',
|
||||
day_selected:
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
day_today: 'bg-accent text-accent-foreground',
|
||||
day_outside:
|
||||
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
|
||||
day_disabled: 'text-muted-foreground opacity-50',
|
||||
day_range_middle:
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
day_hidden: 'invisible',
|
||||
range_start: cn(
|
||||
"bg-accent rounded-l-md",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: () => <ChevronLeft className='h-4 w-4' />,
|
||||
IconRight: () => <ChevronRight className='h-4 w-4' />,
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-[--cell-size] items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = 'Calendar'
|
||||
|
||||
export { Calendar }
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
|
||||
@@ -273,8 +273,7 @@ export function VirtualizedSelect({
|
||||
.filter(Boolean);
|
||||
|
||||
if (selectedLabels.length === 0) return placeholder;
|
||||
if (selectedLabels.length <= 3) return selectedLabels.join(', ');
|
||||
return `${selectedLabels[0]}, ${selectedLabels[1]} +${selectedLabels.length - 2} more`;
|
||||
return `${selectedLabels[0]} +${selectedLabels.length - 1} more`;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import LongText from '@/components/long-text'
|
||||
import { AccountInfo } from '../data/schema'
|
||||
import { DataTableColumnHeader } from './data-table-column-header'
|
||||
|
||||
export const getColumns = (t: (key: string) => string): ColumnDef<AccountInfo>[] => [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('accessTokens.accountId')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<LongText className='max-w-80'>{row.original.id}</LongText>
|
||||
),
|
||||
meta: { className: 'w-80' },
|
||||
enableHiding: false,
|
||||
enableSorting: false
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('accounts.email')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<LongText className='max-w-80'>{row.getValue('email')}</LongText>
|
||||
),
|
||||
meta: { className: 'w-80' },
|
||||
enableHiding: true,
|
||||
enableSorting: false
|
||||
},
|
||||
]
|
||||
@@ -1,69 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { AccessToken } from '../data/schema'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AccountsDetailTable } from './accounts-detail-table'
|
||||
import { getColumns } from './accounts-detail-columns'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
currentRow: AccessToken
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AccountDetailDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const columns = getColumns(t)
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(state) => {
|
||||
onOpenChange(state)
|
||||
}}
|
||||
>
|
||||
<DialogContent className='sm:max-w-4xl'>
|
||||
<DialogHeader className='text-left'>
|
||||
<DialogTitle>{t('settings.accounts')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('accessTokens.theListOfAccountsThatCanBeQueried')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="h-[33rem] overflow-x-auto overflow-y-auto">
|
||||
<AccountsDetailTable data={currentRow.accounts} columns={columns} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant='outline' className="px-2 py-1 text-sm h-auto">{t('common.close')}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { AccessToken } from '../data/schema'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
currentRow: AccessToken
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AclDetailDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(state) => {
|
||||
onOpenChange(state)
|
||||
}}
|
||||
>
|
||||
<DialogContent className='sm:max-w-xl'>
|
||||
<DialogHeader className='text-left'>
|
||||
<DialogTitle>{t('settings.acl')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('accessTokens.aclRulesForAccessTokens')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className='h-[33rem] w-full pr-4 -mr-4 py-1'>
|
||||
<div className="space-y-4">
|
||||
{/* IP Whitelist */}
|
||||
<div className="grid w-full items-center">
|
||||
<Label className="mb-2">IP Whitelist</Label>
|
||||
<Textarea
|
||||
className="col-span-5 max-h-[240px] min-h-[300px]"
|
||||
value={currentRow.acl?.ip_whitelist?.join('\n')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quota */}
|
||||
<div className="grid w-full items-center">
|
||||
<Label className="mb-2">Quota</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={currentRow.acl?.rate_limit?.quota}
|
||||
className="col-span-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Interval (seconds) */}
|
||||
<div className="grid w-full items-center">
|
||||
<Label className="mb-2">Interval (seconds)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="col-span-5"
|
||||
value={currentRow.acl?.rate_limit?.interval}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
// import { MultiSelect } from '@/components/multi-select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { AccessToken } from '../data/schema'
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { create_access_token, update_access_token } from '@/api/access-tokens/api'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { AxiosError } from 'axios'
|
||||
import { VirtualizedSelect } from '@/components/virtualized-select'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const isValidIP = (ip: string) => {
|
||||
const ipv4Regex = /^(?:(?:\d{1,3}\.){3}\d{1,3})$/;
|
||||
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
|
||||
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
|
||||
};
|
||||
|
||||
const RateLimitBaseSchema = z.object({
|
||||
quota: z.optional(z.number()),
|
||||
interval: z.optional(z.number()),
|
||||
});
|
||||
|
||||
const AccessControlBaseSchema = z.object({
|
||||
ip_whitelist: z.string().optional(),
|
||||
rate_limit: z.optional(RateLimitBaseSchema),
|
||||
});
|
||||
|
||||
const AccessTokenBaseSchema = z.object({
|
||||
accounts: z.array(z.number()),
|
||||
description: z.optional(z.string()),
|
||||
acl: z.optional(AccessControlBaseSchema),
|
||||
});
|
||||
|
||||
export type AccessTokenForm = z.infer<typeof AccessTokenBaseSchema>;
|
||||
|
||||
const getRateLimitSchema = (t: (key: string) => string) => z.object({
|
||||
quota: z.optional(z.number().int().positive({ message: t('accessTokens.quotaMustBeAPositiveInteger') })),
|
||||
interval: z.optional(z.number().int().positive({ message: t('accessTokens.intervalMustBeAPositiveInteger') })),
|
||||
});
|
||||
|
||||
const getAccessControlSchema = (t: (key: string) => string) => AccessControlBaseSchema.extend({
|
||||
rate_limit: getRateLimitSchema(t).optional(),
|
||||
}).transform((data) => {
|
||||
if (data.ip_whitelist) {
|
||||
const ips = data.ip_whitelist
|
||||
.split('\n')
|
||||
.map((ip) => ip.trim())
|
||||
.filter((ip) => ip !== '');
|
||||
|
||||
return {
|
||||
...data,
|
||||
ip_whitelist: ips.join('\n'),
|
||||
};
|
||||
}
|
||||
return data;
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.ip_whitelist) {
|
||||
const ips = data.ip_whitelist.split('\n');
|
||||
const invalidIPs = ips.filter((ip) => !isValidIP(ip));
|
||||
return invalidIPs.length === 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t('accessTokens.invalidIpAddressesFound'),
|
||||
path: ['ip_whitelist'],
|
||||
}
|
||||
).transform((data) => {
|
||||
if (data.rate_limit && !data.rate_limit.interval && !data.rate_limit.quota) {
|
||||
return {
|
||||
...data,
|
||||
rate_limit: undefined,
|
||||
};
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.transform((data) => {
|
||||
if (!data.ip_whitelist && !data.rate_limit) {
|
||||
return undefined;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
const getAccessTokenFormSchema = (t: (key: string) => string) => AccessTokenBaseSchema.extend({
|
||||
accounts: z
|
||||
.array(z.number())
|
||||
.min(1, { message: t('accessTokens.atLeastOneAccountIsRequired') }),
|
||||
description: z
|
||||
.optional(z.string().max(255, { message: t('accessTokens.descriptionMustNotExceed255Characters') })),
|
||||
acl: z.optional(getAccessControlSchema(t)),
|
||||
});
|
||||
|
||||
interface Props {
|
||||
currentRow?: AccessToken
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const defaultValues = {
|
||||
accounts: [],
|
||||
description: undefined,
|
||||
access_scopes: [],
|
||||
acl: undefined,
|
||||
};
|
||||
|
||||
|
||||
export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = !!currentRow
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<AccessTokenForm>({
|
||||
resolver: zodResolver(getAccessTokenFormSchema(t)),
|
||||
defaultValues: isEdit
|
||||
? {
|
||||
accounts: currentRow.accounts.map(value => value.id),
|
||||
description: currentRow.description ?? undefined,
|
||||
acl: currentRow.acl
|
||||
? {
|
||||
ip_whitelist: currentRow.acl.ip_whitelist
|
||||
? currentRow.acl.ip_whitelist.join('\n')
|
||||
: undefined,
|
||||
rate_limit: currentRow.acl.rate_limit ? currentRow.acl.rate_limit : undefined
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: defaultValues,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: create_access_token,
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, any>) => update_access_token(currentRow?.token ?? '', data),
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError
|
||||
})
|
||||
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updated') : t('accessTokens.created')}`,
|
||||
description: t('accessTokens.yourAccessTokenHasBeenSuccessfully', { action: isEdit ? t('accessTokens.updated').toLowerCase() : t('accessTokens.created').toLowerCase() }),
|
||||
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['access-tokens'] });
|
||||
form.reset();
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleError(error: AxiosError) {
|
||||
const errorMessage = (error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
t('accessTokens.updateOrCreationFailed', { action: isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed') });
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed')}`,
|
||||
description: errorMessage as string,
|
||||
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const { accountsOptions, isLoading } = useMinimalAccountList();
|
||||
|
||||
const onSubmit = (values: AccessTokenForm) => {
|
||||
const payload = {
|
||||
accounts: values.accounts,
|
||||
description: values.description,
|
||||
acl: values.acl
|
||||
? {
|
||||
...values.acl,
|
||||
ip_whitelist: values.acl.ip_whitelist
|
||||
? (() => {
|
||||
const ipSet = new Set(
|
||||
values.acl.ip_whitelist
|
||||
.split('\n')
|
||||
.map(ip => ip.trim())
|
||||
.filter(ip => ip !== ''),
|
||||
);
|
||||
return ipSet.size > 0 ? Array.from(ipSet) : undefined;
|
||||
})()
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(payload);
|
||||
} else {
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(state) => {
|
||||
form.reset()
|
||||
onOpenChange(state)
|
||||
}}
|
||||
>
|
||||
<DialogContent className='max-w-4xl'>
|
||||
<DialogHeader className='text-left mb-4'>
|
||||
<DialogTitle>{isEdit ? t('accessTokens.editToken') : t('accessTokens.addNew')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? t('accessTokens.updateTheAccessTokenHere') : t('accessTokens.createNewAccessTokenHere')}
|
||||
{t('accounts.clickSaveWhenDone')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className='h-[28rem] w-full pr-4 -mr-4 py-1'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='token-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-4 p-0.5'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='accounts'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col gap-y-1 space-y-0'>
|
||||
<FormLabel className='mb-1'>{t('accessTokens.accounts')}:</FormLabel>
|
||||
<FormControl>
|
||||
<VirtualizedSelect
|
||||
multiple
|
||||
options={accountsOptions}
|
||||
className='w-full'
|
||||
isLoading={isLoading}
|
||||
onSelectOption={(options) => {
|
||||
const numberArray = options.map((v) => parseInt(v, 10));
|
||||
return field.onChange(numberArray);
|
||||
}}
|
||||
value={field.value.map(String)}
|
||||
placeholder={t('accessTokens.selectAccounts')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t('accessTokens.selectMultipleAccountsForTheAccessToken')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="acl.ip_whitelist"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-y-1 space-y-0">
|
||||
<FormLabel className='mb-1'>{t('settings.acl')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('accessTokens.enterOneIpAddressPerLine')}
|
||||
{...field}
|
||||
className="max-h-[500px] min-h-[180px]"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('accessTokens.aListOfIpAddressesAllowed')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="acl.rate_limit.quota"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
|
||||
<FormLabel className='mb-1'>{t('accessTokens.quota')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('accessTokens.enterQuota')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('accessTokens.theMaximumNumberOfRequestsAllowed')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="acl.rate_limit.interval"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
|
||||
<FormLabel className='mb-1'>{t('accessTokens.interval')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('accessTokens.enterIntervalInSeconds')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('accessTokens.theTimeWindowForTheRateLimit')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col gap-y-1 space-y-0'>
|
||||
<FormLabel className='mb-1'>{t('settings.description')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('accessTokens.describeThePurposeOfTheAccessToken')}
|
||||
{...field}
|
||||
className="max-h-[240px] min-h-[80px]"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('oauth2.optional')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="token-form"
|
||||
disabled={isEdit ? updateMutation.isPending : createMutation.isPending}
|
||||
className="min-w-[100px] relative transition-all"
|
||||
>
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
{(isEdit ? updateMutation.isPending : createMutation.isPending) && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
<span>
|
||||
{isEdit
|
||||
? updateMutation.isPending
|
||||
? t('accessTokens.updating')
|
||||
: t('accessTokens.saveChanges')
|
||||
: createMutation.isPending
|
||||
? t('accessTokens.creating')
|
||||
: t('accessTokens.save')}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import LongText from '@/components/long-text'
|
||||
|
||||
import { AccessToken } from '../data/schema'
|
||||
import { DataTableColumnHeader } from './data-table-column-header'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
import { format, formatDistanceToNow, Locale } from 'date-fns'
|
||||
import { AccountCellAction } from './account-action'
|
||||
import { AclCellAction } from './acl-action'
|
||||
|
||||
export const getColumns = (t: (key: string) => string, locale: Locale): ColumnDef<AccessToken>[] => [
|
||||
{
|
||||
accessorKey: 'token',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('settings.token')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <LongText className='w-40'>{row.original.token}</LongText>
|
||||
},
|
||||
meta: { className: 'w-40' },
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'accounts',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('settings.accounts')} />
|
||||
),
|
||||
cell: AccountCellAction,
|
||||
meta: { className: 'w-10 text-center' },
|
||||
filterFn: (row, columnId, filterValue) => {
|
||||
const accounts = row.getValue(columnId) as { account_id: number; email: string }[];
|
||||
if (!filterValue) return true;
|
||||
return accounts.some(
|
||||
(account) =>
|
||||
`${account.account_id}`.includes(filterValue) ||
|
||||
account.email.includes(filterValue)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'acl',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('settings.acl')} />
|
||||
),
|
||||
cell: AclCellAction,
|
||||
meta: { className: 'w-8 text-center' },
|
||||
enableSorting: false
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('settings.description')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<LongText className='max-w-80'>{row.original.description}</LongText>
|
||||
),
|
||||
meta: { className: 'w-80' },
|
||||
enableHiding: true,
|
||||
enableSorting: false
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('settings.createdAt')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const created_at = row.original.created_at;
|
||||
const date = format(new Date(created_at), 'yyyy-MM-dd HH:mm:ss');
|
||||
return <LongText className='max-w-36'>{date}</LongText>;
|
||||
},
|
||||
meta: { className: 'w-36' },
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('settings.updatedAt')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const updated_at = row.original.updated_at;
|
||||
const date = format(new Date(updated_at), 'yyyy-MM-dd HH:mm:ss');
|
||||
return <LongText className='max-w-36'>{date}</LongText>;
|
||||
},
|
||||
meta: { className: 'w-36' },
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_access_at',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('settings.lastAccess')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const last_access_at = row.original.last_access_at;
|
||||
if (last_access_at === 0) {
|
||||
return <LongText className='max-w-40'>{t('accessTokens.notUsedYet')}</LongText>;
|
||||
}
|
||||
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true, locale });
|
||||
return <LongText className='max-w-40'>{result}</LongText>;
|
||||
},
|
||||
meta: { className: 'w-40' },
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: DataTableRowActions,
|
||||
},
|
||||
]
|
||||
@@ -1,103 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { Table } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>
|
||||
showSelected: boolean,
|
||||
showPageSizeSelector: boolean
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
showSelected = true,
|
||||
showPageSizeSelector = true
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='flex items-center justify-between overflow-auto px-2'>
|
||||
{showSelected && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
|
||||
{table.getFilteredRowModel().rows.length} {t("table.results")}
|
||||
</div>}
|
||||
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
|
||||
10 {t("table.rowsPerPage")}.
|
||||
</div>}
|
||||
<div className='flex items-center sm:space-x-6 lg:space-x-8 ml-auto'>
|
||||
{showPageSizeSelector && <div className='flex items-center space-x-2'>
|
||||
<p className='hidden text-sm font-medium sm:block'>{t("table.rowsPerPage")}</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[70px]'>
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top'>
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>}
|
||||
<div className='flex w-[100px] items-center justify-center text-sm font-medium'>
|
||||
{t("table.page")} {table.getState().pagination.pageIndex + 1}{" "}
|
||||
{t("table.of")} {table.getPageCount()}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-8 w-8 p-0'
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>{t("table.prevPage")}</span>
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-8 w-8 p-0'
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>{t("table.nextPage")}</span>
|
||||
<ChevronRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { useState } from 'react'
|
||||
import useDialogState from '@/hooks/use-dialog-state'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Main } from '@/components/layout/main'
|
||||
import { TokensActionDialog } from './components/action-dialog'
|
||||
import { getColumns } from './components/columns'
|
||||
import { TokenDeleteDialog } from './components/delete-dialog'
|
||||
import { AccessTokensTable } from './components/access-token-table'
|
||||
import AccessTokensProvider, {
|
||||
type AccessTokensDialogType,
|
||||
} from './context'
|
||||
import Logo from '@/assets/logo.svg'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { AccessToken } from './data/schema'
|
||||
import { AccountDetailDialog } from './components/accounts-detail-dialog'
|
||||
import { AclDetailDialog } from './components/acl-detail-dialog'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { list_access_tokens } from '@/api/access-tokens/api'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { FixedHeader } from '@/components/layout/fixed-header'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { dateFnsLocaleMap } from '@/lib/utils'
|
||||
import { enUS } from 'date-fns/locale'
|
||||
|
||||
export default function AccessTokens() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
// Dialog states
|
||||
const [currentRow, setCurrentRow] = useState<AccessToken | null>(null)
|
||||
const [open, setOpen] = useDialogState<AccessTokensDialogType>(null)
|
||||
|
||||
const { data: accessTokens, isLoading } = useQuery({
|
||||
queryKey: ['access-tokens'],
|
||||
queryFn: list_access_tokens,
|
||||
})
|
||||
|
||||
const columns = getColumns(t, locale)
|
||||
|
||||
return (
|
||||
<AccessTokensProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
|
||||
{/* ===== Top Heading ===== */}
|
||||
<FixedHeader />
|
||||
|
||||
<Main>
|
||||
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('accessTokens.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="space-x-1" onClick={() => setOpen('add')}>
|
||||
<span>{t('common.add')}</span> <Plus size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
|
||||
{isLoading ? (
|
||||
<TableSkeleton columns={columns.length} rows={10} />
|
||||
) : accessTokens?.length ? (
|
||||
<AccessTokensTable data={accessTokens} columns={columns} />
|
||||
) : (
|
||||
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<img
|
||||
src={Logo}
|
||||
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
|
||||
alt="Bichon Logo"
|
||||
/>
|
||||
<h3 className="mt-4 text-lg font-semibold">{t('accessTokens.noTokens')}</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
{t('accessTokens.noTokensDesc')}
|
||||
</p>
|
||||
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Main>
|
||||
|
||||
|
||||
<TokensActionDialog
|
||||
key='token-add'
|
||||
open={open === 'add'}
|
||||
onOpenChange={() => setOpen('add')}
|
||||
/>
|
||||
|
||||
{currentRow && (
|
||||
<>
|
||||
<TokensActionDialog
|
||||
key={`token-edit-${currentRow.token}`}
|
||||
open={open === 'edit'}
|
||||
onOpenChange={() => {
|
||||
setOpen('edit')
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}}
|
||||
currentRow={currentRow}
|
||||
/>
|
||||
|
||||
<TokenDeleteDialog
|
||||
key={`token-delete-${currentRow.token}`}
|
||||
open={open === 'delete'}
|
||||
onOpenChange={() => {
|
||||
setOpen('delete')
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}}
|
||||
currentRow={currentRow}
|
||||
/>
|
||||
|
||||
<AccountDetailDialog
|
||||
key={`accounts-detail-${currentRow.token}`}
|
||||
currentRow={currentRow}
|
||||
open={open === 'account-detail'}
|
||||
onOpenChange={() => {
|
||||
setOpen('account-detail')
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}} />
|
||||
|
||||
<AclDetailDialog
|
||||
key={`acl-detail-${currentRow.token}`}
|
||||
currentRow={currentRow}
|
||||
open={open === 'acl-detail'}
|
||||
onOpenChange={() => {
|
||||
setOpen('acl-detail')
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}} />
|
||||
</>
|
||||
)}
|
||||
</AccessTokensProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import React from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, ShieldCheck, Users, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { useRoles } from '@/hooks/use-roles'
|
||||
import { useMinimalUsers } from '@/hooks/use-minimal-users'
|
||||
import { access_assign, AccountModel } from '@/api/account/api'
|
||||
|
||||
interface Props {
|
||||
currentRow: AccountModel
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AccountAccessAssignmentDialog({
|
||||
currentRow,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { accountRoles, isLoading: isLoadingRoles } = useRoles()
|
||||
const { users, isLoading: isLoadingUsers } = useMinimalUsers()
|
||||
|
||||
const [keyword, setKeyword] = React.useState('')
|
||||
|
||||
// 1. 定义校验 Schema (集成国际化错误提示)
|
||||
const assignmentSchema = z.object({
|
||||
account_ids: z.array(z.number()),
|
||||
user_ids: z.array(z.number()).min(1, {
|
||||
message: t('accounts.access_control.validation.user_required'),
|
||||
}),
|
||||
role_id: z.number({
|
||||
required_error: t('accounts.access_control.validation.role_required'),
|
||||
}),
|
||||
})
|
||||
|
||||
type AssignmentFormValues = z.infer<typeof assignmentSchema>
|
||||
|
||||
const form = useForm<AssignmentFormValues>({
|
||||
resolver: zodResolver(assignmentSchema),
|
||||
defaultValues: {
|
||||
account_ids: [currentRow.id],
|
||||
user_ids: [],
|
||||
role_id: undefined as any,
|
||||
},
|
||||
})
|
||||
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
if (!keyword.trim()) return users
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
return users.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(lowerKeyword) ||
|
||||
user.email.toLowerCase().includes(lowerKeyword)
|
||||
)
|
||||
}, [users, keyword])
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: access_assign,
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t('accounts.access_control.toast.success_title'),
|
||||
description: t('accounts.access_control.toast.success_desc', { email: currentRow.email }),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['account-access-list'] })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('accounts.access_control.toast.failed_title'),
|
||||
description: error.response?.data?.message || error.message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = (data: AssignmentFormValues) => {
|
||||
mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md gap-0 p-0 overflow-hidden">
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-5 h-5 text-blue-600" />
|
||||
{t('accounts.access_control.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('accounts.access_control.description', { email: currentRow.email })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="px-6 space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('accounts.access_control.role_label')}</FormLabel>
|
||||
<Select
|
||||
disabled={isLoadingRoles}
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
value={field.value?.toString()}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingRoles
|
||||
? t('accounts.access_control.role_loading')
|
||||
: t('accounts.access_control.role_placeholder')
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{accountRoles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id.toString()}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
{t('accounts.access_control.user_label')}
|
||||
</FormLabel>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('accounts.access_control.user_search_placeholder')}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-64">
|
||||
{isLoadingUsers ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 space-y-1">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
{t('accounts.access_control.user_empty')}
|
||||
</div>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<FormField
|
||||
key={user.id}
|
||||
control={form.control}
|
||||
name="user_ids"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-md hover:bg-accent/50 px-2 py-2 transition-colors">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(user.id) ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
field.onChange([...(field.value ?? []), user.id])
|
||||
} else {
|
||||
field.onChange(
|
||||
field.value?.filter((id: number) => id !== user.id) ?? []
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<label className="flex-1 cursor-pointer select-none space-y-1">
|
||||
<div className="font-medium text-sm">{user.username}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</div>
|
||||
</label>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<FormMessage>{form.formState.errors.user_ids?.message}</FormMessage>
|
||||
|
||||
{form.watch('user_ids')?.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('accounts.access_control.user_selected_count', { count: form.watch('user_ids').length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-muted/50 px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('accounts.access_control.buttons.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('accounts.access_control.buttons.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,6 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { AccountModel } from '../data/schema'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -25,6 +24,7 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccountModel } from '@/api/account/api'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -34,6 +34,30 @@ interface Props {
|
||||
|
||||
export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
||||
|
||||
|
||||
const sinceText = (() => {
|
||||
if (currentRow.date_since?.fixed) {
|
||||
return currentRow.date_since.fixed;
|
||||
}
|
||||
|
||||
if (currentRow.date_since?.relative?.value) {
|
||||
return `${t('accounts.sinceRelativeValue', {
|
||||
value: currentRow.date_since!.relative!.value,
|
||||
unit: t(`accounts.${currentRow.date_since!.relative!.unit!.toLowerCase()}`)
|
||||
})}`;
|
||||
}
|
||||
|
||||
return t('accounts.syncAll');
|
||||
})();
|
||||
|
||||
const hasSince = !!currentRow.date_since;
|
||||
const hasBefore = !!currentRow.date_before?.value;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -77,6 +101,10 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
<span className="text-muted-foreground">{t('accounts.incrementalSyncInterval')}:</span>
|
||||
<span>{t('accounts.everyMinutes', { minutes: currentRow.sync_interval_min })}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t('accounts.syncBatchSize')}:</span>
|
||||
<span>{currentRow.sync_batch_size}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-muted-foreground">{t('accounts.capabilities')}:</span>
|
||||
<code className="rounded-md bg-muted/50 px-2 py-1 text-sm border overflow-x-auto inline-block">
|
||||
@@ -84,14 +112,33 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t('accounts.dateSelection')}:</span>
|
||||
<span>
|
||||
{currentRow.date_since?.fixed
|
||||
? t('accounts.since') + ' ' + currentRow.date_since.fixed
|
||||
: currentRow.date_since?.relative
|
||||
? t('accounts.recent') + ' ' + currentRow.date_since.relative.value + ' ' + currentRow.date_since.relative.unit
|
||||
: t('accounts.notAvailable')}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{t('accounts.syncScope')}:</span>
|
||||
{hasSince && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('accounts.sinceFixed')}:
|
||||
</span>
|
||||
<span className="text-sm">{sinceText}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasBefore && (
|
||||
<div className="flex flex-col border-t pt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('accounts.beforeRelative')}:
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
{t('accounts.beforeRelativeValue', {
|
||||
value: currentRow.date_before!.value,
|
||||
unit: t(`accounts.${currentRow.date_before!.unit!.toLowerCase()}`)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasSince && !hasBefore && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('accounts.syncAll')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t('accounts.folderLimit')}:</span>
|
||||
@@ -100,8 +147,6 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Server Configuration Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('accounts.serverConfiguration')}</CardTitle>
|
||||
@@ -143,8 +188,6 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sync Folders Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('accounts.syncFoldersTitle')}</CardTitle>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user