feat(metrics): add /api/v1/metrics endpoint with 34 Prometheus metrics #162

This commit is contained in:
rustmailer
2026-10-22 06:05:50 +08:00
parent 7f31a5e95e
commit 510d69214d
47 changed files with 2694 additions and 62 deletions

36
Cargo.lock generated
View File

@@ -426,6 +426,7 @@ dependencies = [
"object_store",
"outlook-pst",
"poem-openapi",
"prometheus",
"quick-xml 0.41.0",
"rand 0.10.3",
"regex",
@@ -3686,6 +3687,41 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "prometheus"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a"
dependencies = [
"cfg-if",
"fnv",
"lazy_static",
"memchr",
"parking_lot",
"protobuf",
"thiserror 2.0.18",
]
[[package]]
name = "protobuf"
version = "3.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4"
dependencies = [
"once_cell",
"protobuf-support",
"thiserror 1.0.69",
]
[[package]]
name = "protobuf-support"
version = "3.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6"
dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "pulldown-cmark"
version = "0.9.6"

View File

@@ -93,6 +93,7 @@ fjall = { version = "3.1", features = ["lz4", "metrics", "bytes_1"] }
tracing-log = "0.2.0"
tokio-util = "0.7"
indicatif = "0.18.6"
prometheus = "0.14"
[profile.release]
strip = true

View File

@@ -315,6 +315,7 @@ On first start, Bichon creates a built-in admin user:
|------------|-------------|
| `system:access` | Login and access the dashboard |
| `system:root` | Manage system configurations (OAuth providers, proxy settings) |
| `system:metrics` | Read server-wide operational metrics (Prometheus endpoint) |
| `user:manage` | Create, update, and delete users |
| `user:view` | View user list and basic profiles |
| `token:manage` | View and revoke all API tokens |
@@ -391,6 +392,44 @@ Interactive API documentation is available at:
All `/api/v1/*` endpoints require `Authorization: Bearer <token>`.
### Monitoring
`GET /api/v1/metrics` serves the Prometheus text exposition format
(`text/plain; version=0.0.4`) for the whole installation. It requires a token
whose user has the global `system:metrics` permission — use a dedicated
monitoring user with `system:access` and `system:metrics` only. Prefer
`Authorization: Bearer` over `?access_token=` so the token does not end up in
proxy/access logs, and do not attach a rate limit to the monitoring token's
ACL (scrapes would be throttled with HTTP 429).
Create a **metrics-only token** so the monitoring credential cannot be used
anywhere else in the API:
```bash
curl -X POST https://bichon.example.com/api/v1/access-token \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d '{"user_id": 42, "name": "prometheus", "metrics_only": true}'
```
The token keeps the normal API-token lifecycle (expiry, revocation and
last-access tracking), is accepted only on `/api/v1/metrics`, and is rejected
by every other endpoint. Creation fails unless the token's owner holds
`system:metrics`; the permission is checked again on every scrape.
```yaml
scrape_configs:
- job_name: bichon
scrape_interval: 30s
scheme: https # or http, matching the deployment
authorization:
type: Bearer
credentials: <api-token-of-a-user-with-system:metrics>
metrics_path: /api/v1/metrics
static_configs:
- targets: ["bichon.example.com:443"]
```
## Import & Export
### Supported Formats

View File

@@ -640,6 +640,8 @@ impl From<AccessTokenModel> for bichon_core::token::AccessTokenModel {
updated_at: value.updated_at,
expire_at: value.expire_at,
last_access_at: value.last_access_at,
// Legacy tokens predate the metrics-only scope.
metrics_only: false,
}
}
}

View File

@@ -80,3 +80,4 @@ codepage-strings = "1.0.2"
hex.workspace = true
object_store = { version = "0.14.2", features = ["aws", "http"] }
flate2 = "1.1.10"
prometheus.workspace = true

View File

@@ -19,6 +19,7 @@
use crate::{
database::{delete_impl, find_impl, manager::DB_MANAGER, update_impl, upsert_impl, MemDbModel},
error::BichonResult,
metrics::classify::{classify_error, ErrorKind},
utc_now,
};
use serde::{Deserialize, Serialize};
@@ -162,6 +163,23 @@ impl MemDbModel for DownloadState {
pub struct AccountError {
pub error: String,
pub at: i64,
/// Classified kind of this error. Older persisted entries deserialize
/// as Internal via `#[serde(default)]`.
#[serde(default)]
pub kind: ErrorKind,
}
/// Record a finished download session in the metrics registry (session
/// counter, duration histogram). Terminal-state bookkeeping only; call
/// exactly once per session that leaves the Running state.
fn record_finished_session(account_id: u64, session: &DownloadSession) {
let duration_ms = session.end_time.unwrap_or(session.start_time) - session.start_time;
crate::metrics::count_sync_session(
account_id,
crate::metrics::trigger_label(&session.trigger),
crate::metrics::download_status_label(&session.status),
duration_ms,
);
}
impl DownloadState {
@@ -203,6 +221,7 @@ impl DownloadState {
old_session.status = DownloadStatus::Cancelled;
old_session.end_time = Some(utc_now!());
old_session.message = Some("Interrupted by a new download session.".into());
record_finished_session(account_id, &old_session);
}
updated.history.push(old_session);
if updated.history.len() > 30 {
@@ -240,6 +259,7 @@ impl DownloadState {
let now = utc_now!();
session.end_time = Some(now);
updated.last_finished_at = Some(now);
record_finished_session(account_id, &session);
updated.history.push(session);
let to_remove = updated.history.len().saturating_sub(10);
if to_remove > 0 {
@@ -281,6 +301,7 @@ impl DownloadState {
"Previous sync did not finish cleanly; marked as cancelled on startup."
.into(),
);
record_finished_session(account_id, &session);
updated.history.push(session);
updated.last_finished_at = Some(utc_now!());
updated.active_session = None;
@@ -358,11 +379,16 @@ impl DownloadState {
}
pub fn append_session_error(account_id: u64, error: String) -> BichonResult<()> {
let kind = classify_error(&error);
// Count before persisting: the error did happen even if the session
// record cannot be written (e.g. the database is unavailable).
crate::metrics::count_sync_error(account_id, kind);
Self::update_state(account_id, move |current| {
let mut updated = current.clone();
let new_error = AccountError {
error,
at: utc_now!(),
kind,
};
let target = updated
.active_session
@@ -567,3 +593,31 @@ impl GapFillState {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{AccountError, ErrorKind};
/// Download history written by an older version has no `kind` field. It
/// must keep deserializing (as Internal) instead of breaking the account
/// state.
#[test]
fn legacy_account_error_without_kind_deserializes_as_internal() {
let error: AccountError =
serde_json::from_str(r#"{"error":"connection reset","at":123}"#)
.expect("legacy AccountError must deserialize");
assert_eq!(error.kind, ErrorKind::Internal);
}
#[test]
fn account_error_round_trips_with_kind() {
let error = AccountError {
error: "authentication failed".into(),
at: 123,
kind: ErrorKind::Auth,
};
let json = serde_json::to_string(&error).unwrap();
assert!(json.contains("\"kind\":\"auth\""));
assert_eq!(serde_json::from_str::<AccountError>(&json).unwrap(), error);
}
}

View File

@@ -315,6 +315,7 @@ impl BackupManager {
});
let elapsed = finished_at - started_at;
crate::metrics::record_backup_run(status == BackupRunStatus::Success, elapsed);
match status {
BackupRunStatus::Success => {
info!("backup: run ({trigger}) succeeded in {elapsed}ms");

View File

@@ -32,6 +32,7 @@ use crate::{
id,
imap::executor::ImapExecutor,
message::content::AttachmentInfo,
metrics,
raise_error,
store::{
blob::{DetachedEmail, BLOB_MANAGER},
@@ -77,20 +78,26 @@ pub async fn extract_envelope_and_store_it(
uid = fetch.uid,
"FETCH response has no body, skipping message"
);
// The message was not archived, so it counts as a failed ingest.
metrics::count_email_ingested(account_id, "imap", "failed");
return Ok(());
}
};
let size = fetch.size.unwrap_or(body.len() as u32);
extract_envelope_core(
body,
uid,
size,
internal_date,
record_ingest_outcome(
account_id,
mailbox_id,
None,
"imap",
extract_envelope_core(
body,
uid,
size,
internal_date,
account_id,
mailbox_id,
None,
)
.await,
)
.await
.map(|_| ())
}
@@ -100,7 +107,11 @@ pub async fn extract_envelope_from_eml(
mailbox_id: u64,
) -> BichonResult<ExtractOutcome> {
let _write_guard = WRITE_GATE.acquire(BACKUP_ACQUIRE_TIMEOUT).await?;
extract_envelope_core(body, 0, body.len() as u32, 0, account_id, mailbox_id, None).await
record_ingest_outcome(
account_id,
"import",
extract_envelope_core(body, 0, body.len() as u32, 0, account_id, mailbox_id, None).await,
)
}
pub async fn extract_envelope_from_smtp(
@@ -109,16 +120,20 @@ pub async fn extract_envelope_from_smtp(
mailbox_id: u64,
) -> BichonResult<ExtractOutcome> {
let _write_guard = WRITE_GATE.acquire(BACKUP_ACQUIRE_TIMEOUT).await?;
extract_envelope_core(
body,
0,
body.len() as u32,
utc_now!(),
record_ingest_outcome(
account_id,
mailbox_id,
None,
"smtp",
extract_envelope_core(
body,
0,
body.len() as u32,
utc_now!(),
account_id,
mailbox_id,
None,
)
.await,
)
.await
}
/// Archive a journal delivery: `inner` is the original message the journal
@@ -144,16 +159,40 @@ pub async fn extract_envelope_from_journal(
let _write_guard = WRITE_GATE.acquire(BACKUP_ACQUIRE_TIMEOUT).await?;
// `size` describes the archived message (the inner one); the report is a
// separate attachment and has its own size.
extract_envelope_core(
inner,
0,
inner.len() as u32,
utc_now!(),
record_ingest_outcome(
account_id,
mailbox_id,
report,
"journal",
extract_envelope_core(
inner,
0,
inner.len() as u32,
utc_now!(),
account_id,
mailbox_id,
report,
)
.await,
)
.await
}
/// Count one ingestion attempt by its final outcome, then hand the result
/// back unchanged. The counter helpers are infallible, so this never alters
/// the ingest path's behavior.
fn record_ingest_outcome(
account_id: u64,
source: &str,
result: BichonResult<ExtractOutcome>,
) -> BichonResult<ExtractOutcome> {
match &result {
Ok(ExtractOutcome::Imported) => {
metrics::count_email_ingested(account_id, source, "imported")
}
Ok(ExtractOutcome::Duplicate) => {
metrics::count_email_ingested(account_id, source, "duplicate")
}
Err(_) => metrics::count_email_ingested(account_id, source, "failed"),
}
result
}
/// If `raw` is a journal report that wraps the original message in a

View File

@@ -16,6 +16,7 @@ pub mod import;
pub mod logger;
pub mod mailbox;
pub mod message;
pub mod metrics;
pub mod migrate;
pub mod oauth2;
pub mod retention;

View File

@@ -0,0 +1,212 @@
//
// Copyright (c) 2025-2026 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/>.
//! Classification of sync/download errors into Prometheus-friendly kinds.
//!
//! IMAP has no status codes, so the kind is inferred from the error message.
//! The original message is still persisted verbatim; the kind only feeds the
//! `bichon_sync_errors_total` counter (and the persisted `AccountError.kind`).
//! Classification is intentionally best effort: an unknown message stays
//! `Internal` rather than guessing.
use serde::{Deserialize, Serialize};
/// Classified kind of a download/sync error.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
#[cfg_attr(feature = "web-api", oai(rename_all = "lowercase"))]
pub enum ErrorKind {
/// Login / OAuth2 failure (IMAP analogue of HTTP 401/403).
Auth,
/// Server-side throttling or over-quota (IMAP analogue of HTTP 429).
Throttled,
/// Connection-level failure (refused, reset, DNS, unreachable).
Network,
/// Operation exceeded its deadline.
Timeout,
/// Server violated or rejected the protocol (bad response, parse error).
Protocol,
#[default]
/// Anything else; bugs included.
Internal,
}
impl ErrorKind {
/// Label value used by `bichon_sync_errors_total`.
pub const fn as_str(self) -> &'static str {
match self {
ErrorKind::Auth => "auth",
ErrorKind::Throttled => "throttled",
ErrorKind::Network => "network",
ErrorKind::Timeout => "timeout",
ErrorKind::Protocol => "protocol",
ErrorKind::Internal => "internal",
}
}
}
/// Substring matchers, in precedence order: the first matching kind wins.
///
/// Auth failures must not be masked as throttling, and throttling must not be
/// masked as generic network noise, so an auth message that also mentions
/// `too many connections` still classifies as `auth`.
const MATCHERS: &[(ErrorKind, &[&str])] = &[
(
ErrorKind::Auth,
&[
"authenticationfailed",
"invalidcredentials",
"login failed",
"authentication failed",
"auth",
"credentials",
"unauthorized",
"forbidden",
],
),
(
ErrorKind::Throttled,
&[
"throttl",
"overquota",
"quota exceeded",
"unavailable",
"too many simultaneous connections",
"too many connections",
"rate limit",
"try again later",
],
),
(ErrorKind::Timeout, &["timeout", "timed out"]),
(
ErrorKind::Network,
&[
"connection refused",
"connection reset",
"connection closed",
"connection aborted",
"broken pipe",
"dns",
"resolve",
"name or service not known",
"network",
"unreachable",
"io error",
],
),
(
ErrorKind::Protocol,
&[
"bad command",
"invalid response",
"parse error",
"unexpected response",
"protocol error",
"malformed",
],
),
];
/// Classify an error message; case-insensitive substring matching, first
/// match wins, unknown or empty messages are `Internal`.
pub fn classify_error(message: &str) -> ErrorKind {
let lower = message.to_lowercase();
for (kind, needles) in MATCHERS {
if needles.iter().any(|needle| lower.contains(needle)) {
return *kind;
}
}
ErrorKind::Internal
}
#[cfg(test)]
mod tests {
use super::{classify_error, ErrorKind};
#[test]
fn classifies_each_kind() {
assert_eq!(
classify_error("IMAP error: authentication failed for user"),
ErrorKind::Auth
);
assert_eq!(
classify_error("Server replied: OVERQUOTA only 42 MB left"),
ErrorKind::Throttled
);
assert_eq!(classify_error("operation timed out"), ErrorKind::Timeout);
assert_eq!(
classify_error("connection reset by peer"),
ErrorKind::Network
);
assert_eq!(
classify_error("bad command or sequence number"),
ErrorKind::Protocol
);
assert_eq!(classify_error("unexpected internal bug"), ErrorKind::Internal);
}
#[test]
fn auth_takes_precedence_over_throttling() {
assert_eq!(
classify_error("authentication failed after too many connections"),
ErrorKind::Auth
);
}
#[test]
fn throttling_takes_precedence_over_network_noise() {
assert_eq!(
classify_error("service temporarily unavailable due to rate limit"),
ErrorKind::Throttled
);
}
#[test]
fn unknown_and_empty_messages_are_internal() {
assert_eq!(classify_error(""), ErrorKind::Internal);
assert_eq!(classify_error("something odd happened"), ErrorKind::Internal);
}
#[test]
fn label_values_match_the_metric_contract() {
for (kind, label) in [
(ErrorKind::Auth, "auth"),
(ErrorKind::Throttled, "throttled"),
(ErrorKind::Network, "network"),
(ErrorKind::Timeout, "timeout"),
(ErrorKind::Protocol, "protocol"),
(ErrorKind::Internal, "internal"),
] {
assert_eq!(kind.as_str(), label);
}
}
#[test]
fn error_kind_serializes_as_lowercase() {
assert_eq!(
serde_json::to_string(&ErrorKind::Throttled).unwrap(),
"\"throttled\""
);
assert_eq!(
serde_json::from_str::<ErrorKind>("\"auth\"").unwrap(),
ErrorKind::Auth
);
assert_eq!(ErrorKind::default(), ErrorKind::Internal);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -60,11 +60,13 @@ impl BichonTask for OAuth2RefreshTask {
tokio::spawn(async move {
let flow = OAuth2Flow::new(token.oauth2_id.clone());
if let Err(error) = flow.refresh_access_token(&token).await {
crate::metrics::count_oauth2_refresh("failure");
error!(
"Failed to refresh access token for {}: {}",
token.account_id, error
);
} else {
crate::metrics::count_oauth2_refresh("success");
info!(
"Successfully refreshed access token for {}",
token.account_id

View File

@@ -17,7 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
collections::{HashMap, HashSet},
collections::{BTreeMap, HashMap, HashSet},
ops::Bound,
path::PathBuf,
sync::{Arc, LazyLock},
@@ -1205,6 +1205,44 @@ impl IndexManager {
}
}
/// Total archived emails grouped by account id. One aggregation over the
/// whole index instead of one query per account, so the metrics endpoint
/// can refresh per-account gauges in a single pass.
pub fn total_emails_by_account(&self) -> BichonResult<BTreeMap<u64, u64>> {
let searcher = self.create_searcher()?;
// The terms aggregation needs a bucket limit; this is far above any
// realistic number of accounts on one server (and far below a size
// that would make the collector allocate excessively).
const MAX_ACCOUNT_BUCKETS: usize = 10_000;
let aggregations: Aggregations = serde_json::from_value(json!({
"by_account": {
"terms": {
"field": F_ACCOUNT_ID,
"size": MAX_ACCOUNT_BUCKETS
}
}
}))
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let collector = AggregationCollector::from_aggs(aggregations, Default::default());
let agg_results = searcher
.search(&AllQuery, &collector)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut counts = BTreeMap::new();
if let Some(AggregationResult::BucketResult(BucketResult::Terms { buckets, .. })) =
agg_results.0.get("by_account")
{
for entry in buckets {
if let Key::U64(account_id) = &entry.key {
counts.insert(*account_id, entry.doc_count);
}
}
}
Ok(counts)
}
pub fn get_max_uid(&self, account_id: u64, mailbox_id: u64) -> BichonResult<Option<u64>> {
let searcher = self.create_searcher()?;

View File

@@ -25,8 +25,10 @@ use crate::database::{
};
use crate::error::code::ErrorCode;
use crate::raise_error;
use crate::common::auth::ClientContext;
use crate::settings::cli::SETTINGS;
use crate::token::view::AccessTokenResp;
use crate::users::permissions::Permission;
use crate::users::UserModel;
use crate::{
error::BichonResult, generate_token, token::payload::AccessTokenCreateRequest, utc_now,
@@ -66,6 +68,13 @@ pub struct AccessTokenModel {
pub expire_at: Option<i64>,
/// The timestamp (in milliseconds since epoch) when the token was last used.
pub last_access_at: i64,
/// When true, this API token may only be used on the metrics endpoint
/// (`GET /api/v1/metrics`). It is otherwise a normal API token: it has
/// the same expiry, revocation and last-access semantics, and the user
/// behind it must still hold the `system:metrics` permission. Older
/// records deserialize as `false` via `#[serde(default)]`.
#[serde(default)]
pub metrics_only: bool,
}
impl MemDbModel for AccessTokenModel {
@@ -89,6 +98,7 @@ impl AccessTokenModel {
user_id: u64,
name: Option<String>,
expire_at: Option<i64>,
metrics_only: bool,
) -> Self {
Self {
token,
@@ -99,6 +109,7 @@ impl AccessTokenModel {
user_id,
token_type: TokenType::Api,
expire_at,
metrics_only,
}
}
@@ -113,6 +124,7 @@ impl AccessTokenModel {
user_id,
token_type: TokenType::WebUI,
expire_at: None,
metrics_only: false,
}
}
@@ -164,6 +176,7 @@ impl AccessTokenModel {
user_id,
token_type: TokenType::MfaChallenge,
expire_at: Some(expire_at),
metrics_only: false,
};
let token_str = token.token.clone();
insert_impl(DB_MANAGER.db(), token)?;
@@ -187,7 +200,9 @@ impl AccessTokenModel {
.collect())
}
pub fn resolve_user_from_token(token: &str) -> BichonResult<UserModel> {
/// Validate a token's lifecycle (type and expiry) and refresh its
/// last-access timestamp. Returns the stored token record.
fn resolve_token(token: &str) -> BichonResult<AccessTokenModel> {
let token_str = token.to_string();
let token_model = find_impl::<AccessTokenModel>(DB_MANAGER.db(), &token_str)?
.ok_or_else(|| {
@@ -241,10 +256,41 @@ impl AccessTokenModel {
Ok(updated)
})?;
let user = UserModel::find(token_model.user_id)
?
.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)
Ok(token_model)
}
fn user_for_token(token_model: &AccessTokenModel) -> BichonResult<UserModel> {
UserModel::find(token_model.user_id)?
.ok_or_else(|| raise_error!("The user associated with this access token does not exist or may have been deleted.".into(), ErrorCode::ResourceNotFound))
}
/// Resolve a user from a general-purpose access token.
///
/// Metrics-only tokens are rejected here: every caller of this function
/// (SMTP authentication, login helpers, the REST guard on non-metrics
/// routes) must not accept a credential that is scoped to the metrics
/// endpoint. The metrics endpoint uses
/// [`Self::resolve_user_from_token_for_metrics`] instead.
pub fn resolve_user_from_token(token: &str) -> BichonResult<UserModel> {
let token_model = Self::resolve_token(token)?;
if token_model.metrics_only {
return Err(raise_error!(
"Permission denied: this token is restricted to the metrics endpoint."
.into(),
ErrorCode::Forbidden
));
}
Self::user_for_token(&token_model)
}
/// Resolve a user from a token presented on the metrics endpoint.
///
/// Accepts both normal tokens and metrics-only tokens. The caller must
/// still check the user's `system:metrics` permission — the token scope
/// only decides *where* it may be used, not *what* it may read.
pub fn resolve_user_from_token_for_metrics(token: &str) -> BichonResult<UserModel> {
let token_model = Self::resolve_token(token)?;
Self::user_for_token(&token_model)
}
pub fn create_api_token(
@@ -253,12 +299,39 @@ impl AccessTokenModel {
) -> BichonResult<String> {
// Validate request parameters first
request.validate()?;
// A metrics-only token is useless — and misleading — for a user who
// cannot read metrics. Resolve the owner's effective permissions now
// so the failure is reported where the token is created. The metrics
// endpoint re-checks at use time as well.
if request.metrics_only {
let user = UserModel::find(user_id)?.ok_or_else(|| {
raise_error!(
format!("Cannot create a token for user {}: user not found", user_id),
ErrorCode::ResourceNotFound
)
})?;
if !ClientContext::check_has_permission(&user, None, Permission::SYSTEM_METRICS) {
return Err(raise_error!(
"Cannot create a metrics-only token for a user without the \
system:metrics permission."
.into(),
ErrorCode::Forbidden
));
}
}
let expire_at = request
.expire_in
.map(|hours| utc_now!() + (hours as i64) * 60 * 60 * 1000);
let token = generate_token!(128);
let access_token =
AccessTokenModel::new_api_token(token.clone(), user_id, request.name, expire_at);
let access_token = AccessTokenModel::new_api_token(
token.clone(),
user_id,
request.name,
expire_at,
request.metrics_only,
);
insert_impl(DB_MANAGER.db(), access_token)?;
Ok(token)
}
@@ -302,6 +375,7 @@ impl AccessTokenModel {
updated_at: token.updated_at,
expire_at: token.expire_at,
last_access_at: token.last_access_at,
metrics_only: token.metrics_only,
}
})
.collect();
@@ -309,3 +383,27 @@ impl AccessTokenModel {
Ok(resp)
}
}
#[cfg(test)]
mod tests {
use super::AccessTokenModel;
/// Tokens written before the metrics-only scope existed carry no
/// `metrics_only` field and must still deserialize — as unrestricted
/// tokens, preserving their previous behavior.
#[test]
fn legacy_token_records_default_to_not_metrics_only() {
let json = r#"{
"user_id": 1,
"token": "legacy-token",
"name": null,
"token_type": "Api",
"created_at": 0,
"updated_at": 0,
"expire_at": null,
"last_access_at": 0
}"#;
let model: AccessTokenModel = serde_json::from_str(json).unwrap();
assert!(!model.metrics_only);
}
}

View File

@@ -36,6 +36,15 @@ pub struct AccessTokenCreateRequest {
/// 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>,
/// Restrict this token to the metrics endpoint (`GET /api/v1/metrics`).
///
/// The token keeps the normal API-token lifecycle (expiry, revocation,
/// last-access tracking), but every other endpoint rejects it. The owner
/// must hold the `system:metrics` permission, otherwise creation fails.
/// Defaults to `false` for backward compatibility.
#[serde(default)]
#[cfg_attr(feature = "web-api", oai(default))]
pub metrics_only: bool,
}
impl AccessTokenCreateRequest {

View File

@@ -40,4 +40,8 @@ pub struct AccessTokenResp {
pub user_name: String,
pub user_email: String,
/// Whether this token may only be used on the metrics endpoint.
#[serde(default)]
pub metrics_only: bool,
}

View File

@@ -372,6 +372,7 @@ impl BichonUserV2 {
user_id: DEFAULT_ADMIN_USER_ID,
token_type: TokenType::WebUI,
expire_at: None, // Admin setup token usually persistent until changed
metrics_only: false,
};
with_transaction(DB_MANAGER.db(), move |txn| {
@@ -708,6 +709,7 @@ impl BichonUserV2 {
user_id: user.id,
token_type: TokenType::WebUI,
expire_at: None,
metrics_only: false,
};
with_transaction(DB_MANAGER.db(), move |txn| {

View File

@@ -50,6 +50,11 @@ impl Permission {
/// Manage core system configurations (OAuth Client ID/Secret, Proxy settings).
pub const ROOT: &str = "system:root";
/// Read server-wide operational metrics (Prometheus/OpenMetrics endpoint).
/// Intended for a dedicated monitoring token; exposes per-account counts
/// and error rates across ALL accounts regardless of the Account Access List.
pub const SYSTEM_METRICS: &str = "system:metrics";
/// Create, modify, and delete all users and their roles (Admin only).
pub const USER_MANAGE: &str = "user:manage";
@@ -151,6 +156,10 @@ impl Permission {
"Basic platform access for dashboard and personal settings.",
),
(Self::ROOT, "Full system access and configuration."),
(
Self::SYSTEM_METRICS,
"Read server-wide operational metrics (Prometheus endpoint).",
),
(Self::USER_MANAGE, "Create, update, and delete users."),
(
Self::USER_VIEW,

View File

@@ -78,6 +78,14 @@ impl<'a> FromRequest<'a> for WrappedContext {
}
}
/// The one endpoint a metrics-only token may be used on.
///
/// The route is nested under the configured base path (e.g. `/bichon`), so
/// match by suffix instead of an exact path.
fn is_metrics_endpoint(path: &str) -> bool {
path.trim_end_matches('/').ends_with("/api/v1/metrics")
}
pub async fn extract_client_context(req: &Request) -> Result<ClientContext> {
let ip_addr = RealIp::from_request_without_body(req)
.await
@@ -105,10 +113,15 @@ pub async fn extract_client_context(req: &Request) -> Result<ClientContext> {
create_api_error_response("Valid access token not found", ErrorCode::PermissionDenied)
})?;
// Validate and update access token
let user = AccessTokenModel::resolve_user_from_token(&token).map_err(|e| {
create_api_error_response(&format!("{:#?}", e), ErrorCode::PermissionDenied)
})?;
// Validate and update the access token. A metrics-only token is accepted
// only on the metrics endpoint; every other route uses the strict
// resolver, which rejects it even though the token itself is valid.
let user = if is_metrics_endpoint(req.uri().path()) {
AccessTokenModel::resolve_user_from_token_for_metrics(&token)
} else {
AccessTokenModel::resolve_user_from_token(&token)
}
.map_err(|e| create_api_error_response(&format!("{:#?}", e), ErrorCode::PermissionDenied))?;
return Ok(ClientContext {
ip_addr: Some(ip_addr),

View File

@@ -30,6 +30,7 @@ use crate::error::code::IntoStatusCode;
pub mod auth;
pub mod error;
pub mod log;
pub mod recorder;
pub mod status;
pub mod timeout;
pub mod tls;

View File

@@ -0,0 +1,69 @@
//
// Copyright (c) 2025-2026 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/>.
//! HTTP request metrics middleware.
//!
//! Records only the method and the status class — never the concrete path.
//! Paths carry unbounded values (email ids, account ids, tokens) and would
//! explode the metric cardinality.
use bichon_core::metrics;
use poem::{Endpoint, IntoResponse, Middleware, Request, Response, Result};
use std::time::Instant;
pub struct Recorder;
pub struct RecorderEndpoint<E> {
ep: E,
}
impl<E: Endpoint> Middleware<E> for Recorder {
type Output = RecorderEndpoint<E>;
fn transform(&self, ep: E) -> Self::Output {
RecorderEndpoint { ep }
}
}
impl<E: Endpoint> Endpoint for RecorderEndpoint<E> {
type Output = Response;
async fn call(&self, req: Request) -> Result<Self::Output> {
let method = req.method().as_str().to_uppercase();
let started = Instant::now();
match self.ep.call(req).await {
Ok(response) => {
let response = response.into_response();
let elapsed = started.elapsed().as_secs_f64();
record(&method, response.status().as_u16(), elapsed);
Ok(response)
}
Err(error) => {
let elapsed = started.elapsed().as_secs_f64();
record(&method, error.status().as_u16(), elapsed);
Err(error)
}
}
}
}
fn record(method: &str, status: u16, elapsed: f64) {
let status_class = format!("{}xx", status / 100);
metrics::count_http_request(method, &status_class, elapsed);
}

View File

@@ -72,6 +72,11 @@ pub async fn run() -> BichonResult<()> {
info!("Git: [{}]", env!("GIT_HASH"));
info!("GitHub: https://github.com/rustmailer/bichon");
// Register the community metric families and start the uptime clock now,
// so `bichon_uptime_seconds` measures the real process uptime rather than
// the time since the first scrape.
bichon_core::metrics::init();
// Disaster recovery (`restore`) lives in the `bichon-admin` tool, not
// here: a fresh box has no data root yet, and this binary requires one
// at the clap level (the non-optional `bichon_root_dir` field) before

View File

@@ -0,0 +1,54 @@
//
// Copyright (c) 2025-2026 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/>.
//! Prometheus / OpenMetrics endpoint.
//!
//! Registered as a plain poem route (not through poem-openapi) because the
//! response is the Prometheus text exposition format, not JSON. It is wrapped
//! in `ApiGuard`, so token validation, the IP whitelist and rate limiting all
//! apply exactly as on the OpenAPI tree.
use bichon_core::error::code::ErrorCode;
use bichon_core::metrics;
use bichon_core::users::permissions::Permission;
use poem::{handler, http::StatusCode, Response};
use crate::common::auth::WrappedContext;
use crate::common::create_api_error_response;
#[handler]
pub async fn get_metrics(context: WrappedContext) -> Result<Response, poem::Error> {
// `system:metrics` is a global permission, so the account scope is None.
if !context.has_permission(None, Permission::SYSTEM_METRICS) {
// The token is valid but lacks the permission: 403, not 401 (which
// ApiGuard already uses for a missing/invalid token).
return Err(create_api_error_response(
"Missing permission: system:metrics",
ErrorCode::Forbidden,
));
}
let body = metrics::scrape().await.map_err(|e| {
create_api_error_response(&format!("{:#?}", e), ErrorCode::InternalError)
})?;
Ok(Response::builder()
.status(StatusCode::OK)
.content_type("text/plain; version=0.0.4; charset=utf-8")
.body(body))
}

View File

@@ -19,6 +19,7 @@
use crate::common::auth::ApiGuard;
use crate::common::error::ErrorCapture;
use crate::common::log::Tracing;
use crate::common::recorder::Recorder;
use crate::common::tls::rustls_config;
use crate::common::timeout::{Timeout, TIMEOUT_HEADER};
use crate::error::handler::error_handler;
@@ -52,6 +53,7 @@ use {
pub mod api;
pub mod assets;
pub mod maintenance;
pub mod metrics;
pub mod public;
pub type ApiResult<T, E = ApiErrorResponse> = std::result::Result<T, E>;
@@ -76,7 +78,8 @@ pub fn build_routes() -> impl Endpoint {
.with(ApiGuard)
.with(ErrorCapture)
.with(Timeout)
.with(Tracing);
.with(Tracing)
.with(Recorder);
let cors_origins: Option<HashSet<String>> = SETTINGS.bichon_cors_origins.clone();
let cors_origins: Vec<String> = cors_origins.unwrap_or_default().into_iter().collect();
@@ -113,6 +116,13 @@ pub fn build_routes() -> impl Endpoint {
.nest("/api-docs/spec.json", spec_json)
.nest("/api-docs/spec.yaml", spec_yaml)
.nest("/oauth2/callback", get(oauth2_callback))
// `.at` (not `.nest`) so the inner endpoint sees the full request path:
// `ApiGuard` uses it to decide whether a metrics-only token may be
// used, and `nest` would strip the prefix before the guard runs.
.at(
"/api/v1/metrics",
get(crate::rest::metrics::get_metrics).with(ApiGuard),
)
.nest("/api/v1/features", get(get_features))
.nest("/api/status", get(get_status))
.nest("/api/login", post(login))

View File

@@ -0,0 +1,357 @@
//
// Copyright (c) 2025-2026 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, BTreeSet};
use poem::test::TestClient;
use poem::{get, EndpointExt, Route};
use serde::{Deserialize, Serialize};
use crate::common::{auth::ApiGuard, error::ErrorCapture};
use crate::rest::metrics::get_metrics;
use super::{admin_token, build_api_route, setup};
fn metrics_route() -> impl poem::Endpoint {
Route::new()
.at("/api/v1/metrics", get(get_metrics).with(ApiGuard))
.with(ErrorCapture)
}
fn api_client(route: impl poem::Endpoint) -> TestClient<impl poem::Endpoint> {
TestClient::new(route).default_header("X-Forwarded-For", "127.0.0.1")
}
#[derive(Debug, Serialize)]
struct LoginPayload {
username: String,
password: String,
}
#[derive(Debug, Deserialize)]
struct LoginResult {
success: bool,
access_token: Option<String>,
}
#[derive(Debug, Serialize)]
struct CreateRolePayload {
name: String,
role_type: String,
permissions: BTreeSet<String>,
}
#[derive(Debug, Deserialize)]
struct RoleView {
id: u64,
}
#[derive(Debug, Serialize)]
struct CreateUserPayload {
username: String,
email: String,
password: String,
global_roles: Vec<u64>,
account_access_map: BTreeMap<u64, u64>,
}
#[derive(Debug, Deserialize)]
struct UserView {
id: u64,
}
/// Log in with a username/password and return the access token.
async fn login(username: &str, password: &str) -> String {
let route = Route::new().at("/api/login", poem::post(crate::rest::public::login::login));
let cli = TestClient::new(route);
let resp = cli
.post("/api/login")
.body_json(&LoginPayload {
username: username.into(),
password: password.into(),
})
.send()
.await;
resp.assert_status_is_ok();
let result: LoginResult = resp.json().await.value().deserialize();
assert!(result.success, "login failed for {username}");
result.access_token.expect("access_token should be present")
}
#[tokio::test]
async fn metrics_endpoint_requires_a_token() {
setup().await;
let cli = api_client(metrics_route());
let resp = cli.get("/api/v1/metrics").send().await;
assert!(
resp.0.status().is_client_error(),
"metrics without a token must be rejected"
);
}
#[tokio::test]
async fn admin_can_scrape_metrics() {
setup().await;
let token = admin_token().await;
let cli = api_client(metrics_route());
let resp = cli
.get("/api/v1/metrics")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
resp.assert_status_is_ok();
let content_type = resp
.0
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(
content_type.starts_with("text/plain"),
"unexpected content type: {content_type}"
);
let body = resp.0.into_body().into_string().await.unwrap_or_default();
assert!(body.contains("# HELP bichon_build_info"));
assert!(body.contains("# TYPE bichon_uptime_seconds gauge"));
assert!(body.contains("bichon_accounts_total{type=\"imap\"}"));
// Community purity: edition-specific families must never leak.
assert!(!body.contains("bichon_edition_info"));
assert!(!body.contains("bichon_audit_events_total"));
assert!(!body.contains("bichon_legal_holds_total"));
}
#[tokio::test]
async fn token_without_system_metrics_is_forbidden() {
setup().await;
let admin = admin_token().await;
let cli = api_client(build_api_route());
// A global role with only the basic platform permission.
let mut permissions = BTreeSet::new();
permissions.insert("system:access".to_string());
let resp = cli
.post("/api/v1/roles")
.header("Authorization", &format!("Bearer {}", admin))
.body_json(&CreateRolePayload {
name: "metrics-test-role".into(),
role_type: "Global".into(),
permissions,
})
.send()
.await;
resp.assert_status_is_ok();
let role: RoleView = resp.json().await.value().deserialize();
let username = "metrics_test_user";
let resp = cli
.post("/api/v1/users")
.header("Authorization", &format!("Bearer {}", admin))
.body_json(&CreateUserPayload {
username: username.into(),
email: "metrics-test-user@example.com".into(),
password: "metrics-test-pass".into(),
global_roles: vec![role.id],
account_access_map: BTreeMap::new(),
})
.send()
.await;
resp.assert_status_is_ok();
let user: UserView = resp.json().await.value().deserialize();
let token = login(username, "metrics-test-pass").await;
let metrics = api_client(metrics_route());
let resp = metrics
.get("/api/v1/metrics")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
assert_eq!(
resp.0.status(),
poem::http::StatusCode::FORBIDDEN,
"a valid token without system:metrics must be forbidden"
);
// Clean up so a rerun against the same test root starts clean.
let resp = cli
.delete(&format!("/api/v1/users/{}", user.id))
.header("Authorization", &format!("Bearer {}", admin))
.send()
.await;
resp.assert_status_is_ok();
let resp = cli
.delete(&format!("/api/v1/roles/{}", role.id))
.header("Authorization", &format!("Bearer {}", admin))
.send()
.await;
resp.assert_status_is_ok();
}
#[derive(Debug, Serialize)]
struct CreateTokenPayload {
user_id: u64,
name: String,
metrics_only: bool,
}
/// A metrics-only token keeps the API-token lifecycle, is accepted on the
/// metrics endpoint, and is rejected everywhere else — even on endpoints its
/// owner could otherwise call.
#[tokio::test]
async fn metrics_only_token_is_scoped_to_the_metrics_endpoint() {
setup().await;
let admin = admin_token().await;
let admin_cli = api_client(build_api_route());
let mut good_permissions = BTreeSet::new();
good_permissions.insert("system:access".to_string());
good_permissions.insert("system:metrics".to_string());
let resp = admin_cli
.post("/api/v1/roles")
.header("Authorization", &format!("Bearer {}", admin))
.body_json(&CreateRolePayload {
name: "metrics-scope-role".into(),
role_type: "Global".into(),
permissions: good_permissions,
})
.send()
.await;
resp.assert_status_is_ok();
let good_role: RoleView = resp.json().await.value().deserialize();
let mut bad_permissions = BTreeSet::new();
bad_permissions.insert("system:access".to_string());
let resp = admin_cli
.post("/api/v1/roles")
.header("Authorization", &format!("Bearer {}", admin))
.body_json(&CreateRolePayload {
name: "metrics-scope-role-bad".into(),
role_type: "Global".into(),
permissions: bad_permissions,
})
.send()
.await;
resp.assert_status_is_ok();
let bad_role: RoleView = resp.json().await.value().deserialize();
let resp = admin_cli
.post("/api/v1/users")
.header("Authorization", &format!("Bearer {}", admin))
.body_json(&CreateUserPayload {
username: "metrics_scope_user".into(),
email: "metrics-scope-user@example.com".into(),
password: "metrics-scope-pass".into(),
global_roles: vec![good_role.id],
account_access_map: BTreeMap::new(),
})
.send()
.await;
resp.assert_status_is_ok();
let good_user: UserView = resp.json().await.value().deserialize();
let resp = admin_cli
.post("/api/v1/users")
.header("Authorization", &format!("Bearer {}", admin))
.body_json(&CreateUserPayload {
username: "metrics_scope_user_bad".into(),
email: "metrics-scope-user-bad@example.com".into(),
password: "metrics-scope-pass".into(),
global_roles: vec![bad_role.id],
account_access_map: BTreeMap::new(),
})
.send()
.await;
resp.assert_status_is_ok();
let bad_user: UserView = resp.json().await.value().deserialize();
// Creating a metrics-only token for a user without system:metrics fails.
let resp = admin_cli
.post("/api/v1/access-token")
.header("Authorization", &format!("Bearer {}", admin))
.body_json(&CreateTokenPayload {
user_id: bad_user.id,
name: "forbidden-metrics-token".into(),
metrics_only: true,
})
.send()
.await;
assert_eq!(
resp.0.status(),
poem::http::StatusCode::FORBIDDEN,
"metrics-only tokens require the owner to hold system:metrics"
);
// The permitted owner gets a working metrics token...
let resp = admin_cli
.post("/api/v1/access-token")
.header("Authorization", &format!("Bearer {}", admin))
.body_json(&CreateTokenPayload {
user_id: good_user.id,
name: "prometheus".into(),
metrics_only: true,
})
.send()
.await;
resp.assert_status_is_ok();
let scoped_token = resp.0.into_body().into_string().await.unwrap_or_default();
assert!(!scoped_token.is_empty(), "token creation must return the token");
// ...accepted on the metrics endpoint...
let metrics = api_client(metrics_route());
let resp = metrics
.get("/api/v1/metrics")
.header("Authorization", &format!("Bearer {}", scoped_token))
.send()
.await;
resp.assert_status_is_ok();
// ...and rejected on a normal endpoint, even one requiring no permission.
let api = api_client(build_api_route());
let resp = api
.get("/api/v1/current-user")
.header("Authorization", &format!("Bearer {}", scoped_token))
.send()
.await;
assert!(
resp.0.status().is_client_error(),
"a metrics-only token must not authenticate on other endpoints"
);
// Clean up.
for user in [&good_user, &bad_user] {
let resp = admin_cli
.delete(&format!("/api/v1/users/{}", user.id))
.header("Authorization", &format!("Bearer {}", admin))
.send()
.await;
resp.assert_status_is_ok();
}
for role in [&good_role, &bad_role] {
let resp = admin_cli
.delete(&format!("/api/v1/roles/{}", role.id))
.header("Authorization", &format!("Bearer {}", admin))
.send()
.await;
resp.assert_status_is_ok();
}
}

View File

@@ -6,6 +6,7 @@
pub mod access_token_tests;
pub mod account_tests;
pub mod backup_tests;
pub mod metrics_tests;
pub mod oauth2_tests;
pub mod proxy_tests;
pub mod role_tests;

View File

@@ -26,6 +26,7 @@ use bichon_core::{
envelope::extractor::extract_envelope_from_smtp,
error::BichonResult,
ext::journal,
metrics,
settings::cli::{EncryptionMode, SETTINGS},
token::AccessTokenModel,
users::{permissions::Permission, UserModel},
@@ -57,6 +58,7 @@ pub async fn run_smtp_server(
tracing::debug!("SMTP connection from {addr}");
let config = config.clone();
tokio::spawn(async move {
let _connection = metrics::SmtpConnectionGuard::new();
let res = timeout(GLOBAL_SESSION_TIMEOUT, handle_connection(stream, config)).await;
match res {
Ok(Ok(_)) => tracing::debug!("SMTP session from {addr} finished"),
@@ -92,6 +94,7 @@ pub async fn run_smtps_server(
let config = config.clone();
let acceptor = tls_acceptor.clone();
tokio::spawn(async move {
let _connection = metrics::SmtpConnectionGuard::new();
match acceptor.accept(stream).await {
Ok(tls_stream) => {
let peer_ip = addr.ip();
@@ -320,6 +323,7 @@ where
.await?;
}
Err(error) => {
metrics::count_smtp_rejection("auth");
tracing::error!(
"SMTP Auth failed for user '{}' (AUTH LOGIN): {:?}",
username,
@@ -388,6 +392,7 @@ where
}
} else if cmd.starts_with("MAIL FROM:") {
if session.auth_required && !session.authenticated {
metrics::count_smtp_rejection("auth");
stream.write_all(b"530 Authentication required\r\n").await?;
} else if !session.peer_allowed() {
// Journaling is enabled and this unauthenticated peer is neither
@@ -433,6 +438,7 @@ where
}
if session.auth_required && !session.authenticated {
metrics::count_smtp_rejection("auth");
stream.write_all(b"530 Authentication required\r\n").await?;
} else if session.mail_from.is_none() {
stream
@@ -505,6 +511,7 @@ where
addr,
account.account_type
);
metrics::count_smtp_rejection("not_local_account");
let err = format!(
"550 5.7.1 <{}>: Not a local account that accepts SMTP delivery\r\n",
account.email
@@ -517,6 +524,7 @@ where
}
}
Ok(None) => {
metrics::count_smtp_rejection("not_local_account");
let err = format!("550 5.1.1 <{}>: Bichon account not found\r\n", addr);
stream.write_all(err.as_bytes()).await?;
}
@@ -532,6 +540,7 @@ where
}
} else if cmd == "DATA" {
if session.auth_required && !session.authenticated {
metrics::count_smtp_rejection("auth");
stream.write_all(b"530 Authentication required\r\n").await?;
} else if session.mail_from.is_none() {
stream
@@ -555,6 +564,7 @@ where
Ok(d) => d,
Err(e) => {
if e.to_string().contains("552") {
metrics::count_smtp_rejection("size_limit");
let error_msg = format!(
"552 5.3.4 Message size exceeds limit of {} bytes ({}MB)\r\n",
MAX_MAIL_SIZE,
@@ -585,6 +595,7 @@ where
match outcome {
Ok(_) => {
metrics::count_smtp_message("accepted");
stream
.write_all(b"250 2.0.0 OK: queued in Bichon\r\n")
.await?;
@@ -597,6 +608,16 @@ where
session.reset();
}
Err(e) => {
// The write gate rejects delivery with a retryable error
// while a backup holds it closed; everything else is an
// archive-side failure.
let reason = if e.code() == bichon_core::error::code::ErrorCode::TooManyRequest
{
"backup_active"
} else {
"other"
};
metrics::count_smtp_rejection(reason);
let context = if session.journal {
"journal ingest"
} else {
@@ -661,6 +682,7 @@ async fn verify_plain_auth<S: AsyncRead + AsyncWrite + Unpin>(
}
}
metrics::count_smtp_rejection("auth");
stream.write_all(b"535 Authentication failed\r\n").await?;
stream.flush().await?;
Ok(())

View File

@@ -39,6 +39,7 @@ export function getPermissions(
// 1. Global Management
{ label: t('permission.system.access'), value: 'system:access' },
{ label: t('permission.system.root'), value: 'system:root' },
{ label: t('permission.system.metrics'), value: 'system:metrics' },
{ label: t('permission.user.manage'), value: 'user:manage' },
{ label: t('permission.user.view'), value: 'user:view' },
{ label: t('permission.token.manage'), value: 'token:manage' },
@@ -100,6 +101,8 @@ export interface AccessToken {
last_access_at: number;
expire_at?: number | null;
token_type: TokenType;
/** When true, the token may only call GET /api/v1/metrics. */
metrics_only?: boolean;
}
export interface User {

View File

@@ -42,6 +42,7 @@ import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { useNavigate } from '@tanstack/react-router'
import { dateFnsLocaleMap } from '@/lib/utils'
import { copyToClipboard } from '@/lib/clipboard'
import { enUS } from 'date-fns/locale'
import { AccountModel } from '@/api/account/api'
@@ -67,7 +68,7 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
const onCopy = useCallback(async (access: boolean, token: string) => {
try {
await navigator.clipboard.writeText(token);
await copyToClipboard(token);
if (access) {
toast({
title: t('common.ok'),

View File

@@ -37,6 +37,7 @@ import { Main } from '@/components/layout/main'
import { useEdition } from '@/hooks/use-edition'
import { useCurrentUser } from '@/hooks/use-current-user'
import { useToast } from '@/hooks/use-toast'
import { copyToClipboard } from '@/lib/clipboard'
import {
get_license_status,
upload_license,
@@ -99,7 +100,7 @@ export default function LicensePage() {
const copyMachineId = async () => {
if (!data?.machine_id) return
try {
await navigator.clipboard.writeText(data.machine_id)
await copyToClipboard(data.machine_id)
toast({ title: t('license.copied') })
} catch {
toast({ title: t('license.copyFailed'), variant: 'destructive' })

View File

@@ -27,6 +27,7 @@ import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/hooks/use-toast';
import { formatBytes } from '@/lib/utils';
import { copyToClipboard } from '@/lib/clipboard';
import EmailIframe from '@/components/mail-iframe';
import {
AttachmentInfo,
@@ -264,7 +265,7 @@ export function MailMessageView({
// a user picks it up — the row is a dense one-liner, hence the icon button.
const copyEnvelopeId = async () => {
try {
await navigator.clipboard.writeText(envelope.id);
await copyToClipboard(envelope.id);
toast({ title: t('mail.copied') });
} catch {
toast({ title: t('mail.copyFailed'), variant: 'destructive' });

View File

@@ -49,6 +49,7 @@ function getGlobalCategories(t: (key: string) => string, isEnterprise: boolean)
keys: [
'system:access',
'system:root',
'system:metrics',
'user:manage',
'user:view',
'token:manage',

View File

@@ -41,6 +41,7 @@ import {
} from '@/components/ui/form'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
@@ -68,6 +69,7 @@ const getAccessTokenSchema = (t: any) => z.object({
.int(t('apiTokens.form.errorInt'))
.positive(t('apiTokens.form.errorPositive'))
.optional(),
metrics_only: z.boolean(),
})
export type AccessTokenForm = z.infer<ReturnType<typeof getAccessTokenSchema>>
@@ -82,6 +84,7 @@ interface Props {
const defaultValues: AccessTokenForm = {
name: '',
expire_in: undefined,
metrics_only: false,
}
export function TokensActionDialog({
@@ -100,6 +103,7 @@ export function TokensActionDialog({
? {
name: currentRow.name ?? '',
expire_in: undefined,
metrics_only: currentRow.metrics_only ?? false,
}
: defaultValues,
})
@@ -165,6 +169,7 @@ export function TokensActionDialog({
user_id: userId,
name: values.name?.trim() || undefined,
expire_in: values.expire_in || undefined,
metrics_only: values.metrics_only,
}
isEdit
@@ -194,7 +199,7 @@ export function TokensActionDialog({
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[14rem] w-full pr-4 -mr-4">
<ScrollArea className="h-[23rem] w-full pr-4 -mr-4">
<Form {...form}>
<form
id="token-form"
@@ -262,6 +267,27 @@ export function TokensActionDialog({
</FormItem>
)}
/>
{/* metrics_only */}
<FormField
control={form.control}
name="metrics_only"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>{t('apiTokens.form.metricsOnlyLabel')}</FormLabel>
<FormDescription>
{t('apiTokens.form.metricsOnlyDescription')}
</FormDescription>
</div>
</FormItem>
)}
/>
</form>
</Form>

View File

@@ -28,11 +28,19 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { TokenCardList } from './token-list'
import { TokensActionDialog } from './access-token-action'
import { useTranslation } from 'react-i18next'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export function APITokens() {
const { t } = useTranslation()
const { data: user, isLoading, error } = useCurrentUser()
const [addOpen, setAddOpen] = useState(false)
const [filter, setFilter] = useState<'all' | 'metrics' | 'regular'>('all')
const { data: tokens = [], isLoading: tokensLoading } = useQuery({
queryKey: ['user-tokens', user?.id!],
@@ -40,6 +48,12 @@ export function APITokens() {
enabled: !!user?.id,
})
const filteredTokens = tokens.filter((token) => {
if (filter === 'metrics') return !!token.metrics_only
if (filter === 'regular') return !token.metrics_only
return true
})
if (isLoading) {
return (
<div className="flex justify-center items-center h-64">
@@ -84,16 +98,41 @@ export function APITokens() {
</div>
) : (
<>
<div className="flex justify-end mb-4">
<div className="flex items-center justify-between gap-3 mb-4">
<Select
value={filter}
onValueChange={(value) =>
setFilter(value as 'all' | 'metrics' | 'regular')
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t('apiTokens.filters.label')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('apiTokens.filters.all')}</SelectItem>
<SelectItem value="metrics">
{t('apiTokens.filters.metricsOnly')}
</SelectItem>
<SelectItem value="regular">
{t('apiTokens.filters.regular')}
</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => setAddOpen(true)}>
<span>{t('apiTokens.page.addBtn')}</span>
<Plus size={18} className="ml-2" />
</Button>
</div>
<ScrollArea className="h-[calc(100vh-16rem)] w-full pr-4 -mr-4 py-1">
<TokenCardList tokens={tokens} userId={user.id} />
</ScrollArea>
{filteredTokens.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t('apiTokens.list.filterEmpty')}
</p>
) : (
<ScrollArea className="h-[calc(100vh-16rem)] w-full pr-4 -mr-4 py-1">
<TokenCardList tokens={filteredTokens} userId={user.id} />
</ScrollArea>
)}
</>
)}

View File

@@ -41,6 +41,7 @@ import { OP_TOKEN_REVOKE } from "@/api/approvals/api";
import { useGatedOp } from "@/features/approvals/use-gated-op";
import { useTranslation } from "react-i18next";
import { toast } from "@/hooks/use-toast";
import { copyToClipboard } from "@/lib/clipboard";
interface Props {
tokens: AccessToken[];
@@ -81,11 +82,18 @@ export const TokenCardList: React.FC<Props> = ({ tokens, userId }) => {
}
});
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
toast({
description: t('apiTokens.notifications.copied'),
});
const handleCopy = async (text: string) => {
try {
await copyToClipboard(text);
toast({
description: t('apiTokens.notifications.copied'),
});
} catch {
toast({
variant: "destructive",
description: t('settings.failedToCopyText'),
});
}
};
return (
@@ -118,6 +126,11 @@ export const TokenCardList: React.FC<Props> = ({ tokens, userId }) => {
>
{expired ? t('apiTokens.list.statusExpired') : t('apiTokens.list.statusActive')}
</Badge>
{token.metrics_only && (
<Badge variant="outline" className="text-xs">
{t('apiTokens.list.metricsOnlyBadge')}
</Badge>
)}
</div>
</div>

View File

@@ -35,6 +35,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { toast } from '@/hooks/use-toast'
import { useEdition } from '@/hooks/use-edition'
import { copyToClipboard } from '@/lib/clipboard'
import { mfaConfirm, mfaDisable, mfaEnroll, mfaStatus } from '@/api/users/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Info } from 'lucide-react'
@@ -137,7 +138,7 @@ export function MfaSettings() {
const copyText = useCallback(
async (text: string) => {
try {
await navigator.clipboard.writeText(text)
await copyToClipboard(text)
toast({ title: t('settings.mfa.copied', 'Copied to clipboard') })
} catch {
toast({

View File

@@ -45,6 +45,7 @@ import {
import { useCurrentUser } from '@/hooks/use-current-user'
import { useEdition } from '@/hooks/use-edition'
import { toast } from '@/hooks/use-toast'
import { copyToClipboard } from '@/lib/clipboard'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
@@ -152,7 +153,7 @@ function ProofPanel({ proof }: { proof: EmailProof }) {
const copyToken = async () => {
if (!proof.tsa_token_b64) return
await navigator.clipboard.writeText(proof.tsa_token_b64)
await copyToClipboard(proof.tsa_token_b64)
toast({ title: t('timestampAnchor.tokenCopied', 'Token copied to clipboard') })
}

View File

@@ -55,7 +55,7 @@ export const getColumns = (t: (key: string) => string): ColumnDef<AccessToken>[]
/>
),
cell: ({ row }) => (
<div className="flex justify-center">
<div className="flex items-center justify-center gap-1">
<Badge
variant={row.original.token_type === 'Api' ? 'default' : 'secondary'}
className="font-normal shadow-none"
@@ -64,10 +64,29 @@ export const getColumns = (t: (key: string) => string): ColumnDef<AccessToken>[]
? t('users.api_tokens.table.api_key')
: t('users.api_tokens.table.web_ui')}
</Badge>
{row.original.metrics_only && (
<Badge variant="outline" className="font-normal shadow-none">
{t('apiTokens.list.metricsOnlyBadge')}
</Badge>
)}
</div>
),
meta: { className: 'text-center' },
},
{
// Virtual column: not rendered, used by the toolbar's metrics-only
// filter (hidden via the table's initial column visibility).
id: 'metrics_only',
accessorFn: (row) => (row.metrics_only ? 'metrics' : 'regular'),
filterFn: (row, _id, value) => {
if (!value || value === 'all') return true
return value === 'metrics' ? !!row.original.metrics_only : !row.original.metrics_only
},
enableSorting: false,
enableHiding: false,
header: () => null,
cell: () => null,
},
{
accessorKey: 'last_access_at',
header: ({ column }) => (

View File

@@ -20,6 +20,13 @@
import { Table } from '@tanstack/react-table'
import { Input } from '@/components/ui/input'
import { useTranslation } from 'react-i18next'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
// import { useTranslation } from 'react-i18next'
interface DataTableToolbarProps<TData> {
@@ -30,6 +37,8 @@ export function DataTableToolbar<TData>({
table,
}: DataTableToolbarProps<TData>) {
const { t } = useTranslation()
const metricsColumn = table.getColumn('metrics_only')
const metricsFilter = (metricsColumn?.getFilterValue() as string) ?? 'all'
return (
<div className='flex items-center justify-between'>
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
@@ -41,6 +50,25 @@ export function DataTableToolbar<TData>({
}}
className='h-8 w-80'
/>
<Select
value={metricsFilter}
onValueChange={(value) =>
metricsColumn?.setFilterValue(value === 'all' ? undefined : value)
}
>
<SelectTrigger className='h-8 w-[180px]'>
<SelectValue placeholder={t('apiTokens.filters.label')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>{t('apiTokens.filters.all')}</SelectItem>
<SelectItem value='metrics'>
{t('apiTokens.filters.metricsOnly')}
</SelectItem>
<SelectItem value='regular'>
{t('apiTokens.filters.regular')}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)

View File

@@ -62,7 +62,10 @@ interface DataTableProps {
export function ApiTokensTable({ columns, data }: DataTableProps) {
const { t } = useTranslation()
const [rowSelection, setRowSelection] = useState({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
// Filtering-only column, never displayed.
metrics_only: false,
})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [sorting, setSorting] = useState<SortingState>([])

View File

@@ -61,7 +61,7 @@ function getCategoryMap(isEnterprise: boolean): Record<'Global' | 'Account', { t
const global: { titleKey: string; keys: string[] }[] = [
{
titleKey: 'roles.categories.identity',
keys: ['system:access', 'system:root', 'user:manage', 'user:view', 'token:manage', 'account:create'],
keys: ['system:access', 'system:root', 'system:metrics', 'user:manage', 'user:view', 'token:manage', 'account:create'],
},
{
titleKey: 'roles.categories.global_data',

View File

@@ -40,6 +40,7 @@ import {
} from '@/components/ui/form'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
@@ -67,6 +68,7 @@ const createSchema = (t: any) => z.object({
.int(t('users.tokens.schema.expire_int'))
.positive(t('users.tokens.schema.expire_positive'))
.optional(),
metrics_only: z.boolean(),
})
export type AccessTokenForm = z.infer<ReturnType<typeof createSchema>>
@@ -81,6 +83,7 @@ interface Props {
const defaultValues: AccessTokenForm = {
name: '',
expire_in: undefined,
metrics_only: false,
}
export function TokensActionDialog({
@@ -99,6 +102,7 @@ export function TokensActionDialog({
? {
name: currentRow.name ?? '',
expire_in: undefined,
metrics_only: currentRow.metrics_only ?? false,
}
: defaultValues,
})
@@ -164,6 +168,7 @@ export function TokensActionDialog({
user_id: userId,
name: values.name?.trim() || undefined,
expire_in: values.expire_in || undefined,
metrics_only: values.metrics_only,
}
isEdit
@@ -193,7 +198,7 @@ export function TokensActionDialog({
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[14rem] w-full pr-4 -mr-4">
<ScrollArea className="h-[23rem] w-full pr-4 -mr-4">
<Form {...form}>
<form
id="token-form"
@@ -259,6 +264,26 @@ export function TokensActionDialog({
</FormItem>
)}
/>
<FormField
control={form.control}
name="metrics_only"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>{t('apiTokens.form.metricsOnlyLabel')}</FormLabel>
<FormDescription>
{t('apiTokens.form.metricsOnlyDescription')}
</FormDescription>
</div>
</FormItem>
)}
/>
</form>
</Form>

View File

@@ -29,6 +29,13 @@ import { TokensActionDialog } from './access-token-action'
import { Plus } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useTranslation } from 'react-i18next'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
interface Props {
currentRow?: User
@@ -39,6 +46,7 @@ interface Props {
export function UserApiTokensDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const [addOpen, setAddOpen] = useState(false)
const [filter, setFilter] = useState<'all' | 'metrics' | 'regular'>('all')
const { data: tokens = [], isLoading: tokensLoading } = useQuery({
queryKey: ['user-tokens', currentRow?.id!],
@@ -46,6 +54,12 @@ export function UserApiTokensDialog({ currentRow, open, onOpenChange }: Props) {
enabled: !!currentRow?.id,
})
const filteredTokens = tokens.filter((token) => {
if (filter === 'metrics') return !!token.metrics_only
if (filter === 'regular') return !token.metrics_only
return true
})
return (
<Dialog
open={open}
@@ -84,14 +98,39 @@ export function UserApiTokensDialog({ currentRow, open, onOpenChange }: Props) {
</div>
) : (
<>
<div className="flex justify-end mb-4">
<div className="flex items-center justify-between gap-3 mb-4">
<Select
value={filter}
onValueChange={(value) =>
setFilter(value as 'all' | 'metrics' | 'regular')
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t('apiTokens.filters.label')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('apiTokens.filters.all')}</SelectItem>
<SelectItem value="metrics">
{t('apiTokens.filters.metricsOnly')}
</SelectItem>
<SelectItem value="regular">
{t('apiTokens.filters.regular')}
</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => setAddOpen(true)}>
<span>{t('users.tokens_action.buttons.add')}</span> <Plus size={18} />
</Button>
</div>
<ScrollArea className='h-[32rem] w-full pr-4 -mr-4 py-1'>
<TokenCardList tokens={tokens} userId={currentRow?.id!} />
</ScrollArea>
{filteredTokens.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t('apiTokens.list.filterEmpty')}
</p>
) : (
<ScrollArea className='h-[32rem] w-full pr-4 -mr-4 py-1'>
<TokenCardList tokens={filteredTokens} userId={currentRow?.id!} />
</ScrollArea>
)}
</>
)}

View File

@@ -42,6 +42,7 @@ import { OP_TOKEN_REVOKE } from "@/api/approvals/api";
import { useGatedOp } from "@/features/approvals/use-gated-op";
import { useTranslation } from "react-i18next";
import { toast } from "@/hooks/use-toast";
import { copyToClipboard } from "@/lib/clipboard";
interface Props {
tokens: AccessToken[];
@@ -103,6 +104,11 @@ export const TokenCardList: React.FC<Props> = ({ tokens, userId }) => {
>
{expired ? t('users.tokens_action.list.status_expired') : t('users.tokens_action.list.status_active')}
</Badge>
{token.metrics_only && (
<Badge variant="outline" className="text-xs">
{t('apiTokens.list.metricsOnlyBadge')}
</Badge>
)}
</div>
</div>
@@ -133,7 +139,12 @@ export const TokenCardList: React.FC<Props> = ({ tokens, userId }) => {
variant="ghost"
className="h-7 w-7"
onClick={() =>
navigator.clipboard.writeText(token.token)
copyToClipboard(token.token).catch(() =>
toast({
title: t('settings.failedToCopyText'),
variant: 'destructive',
})
)
}
>
<Copy className="h-4 w-4" />

View File

@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { copyToClipboard } from '../clipboard'
const setClipboard = (value: unknown) => {
Object.defineProperty(navigator, 'clipboard', {
value,
configurable: true,
})
}
const setExecCommand = (value: unknown) => {
Object.defineProperty(document, 'execCommand', {
value,
configurable: true,
})
}
describe('copyToClipboard', () => {
afterEach(() => {
setClipboard(undefined)
setExecCommand(undefined)
})
it('uses the async Clipboard API when it is available', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
setClipboard({ writeText })
await copyToClipboard('hello')
expect(writeText).toHaveBeenCalledWith('hello')
})
it('falls back to execCommand when navigator.clipboard is undefined', async () => {
// The Clipboard API is missing on insecure origins (plain HTTP / LAN IP),
// which is exactly what issue #384 reported.
setClipboard(undefined)
const execCommand = vi.fn().mockReturnValue(true)
setExecCommand(execCommand)
await copyToClipboard('hello')
expect(execCommand).toHaveBeenCalledWith('copy')
expect(document.querySelector('textarea')).toBeNull()
})
it('throws when neither clipboard path is available', async () => {
setClipboard(undefined)
setExecCommand(undefined)
await expect(copyToClipboard('hello')).rejects.toThrow(
'Clipboard is not available'
)
})
})

76
web/src/lib/clipboard.ts Normal file
View File

@@ -0,0 +1,76 @@
//
// Copyright (c) 2025-2026 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/>.
/**
* Copy text to the clipboard.
*
* The async Clipboard API only exists in secure contexts (HTTPS or
* localhost). Bichon is often self-hosted over plain HTTP or reached through
* a LAN IP, where `navigator.clipboard` is undefined and calling
* `navigator.clipboard.writeText` throws
* "Cannot read properties of undefined (reading 'writeText')". Fall back to
* the legacy execCommand path in that case.
*/
export async function copyToClipboard(text: string): Promise<void> {
const clipboard =
typeof navigator === 'undefined' ? undefined : navigator.clipboard
if (clipboard && typeof clipboard.writeText === 'function') {
try {
await clipboard.writeText(text)
return
} catch {
// Permission denied or a document that is not focused: try the fallback.
}
}
if (!copyWithExecCommand(text)) {
throw new Error('Clipboard is not available')
}
}
/** Legacy fallback for insecure contexts: select a hidden textarea and copy. */
function copyWithExecCommand(text: string): boolean {
if (typeof document === 'undefined') return false
const textarea = document.createElement('textarea')
textarea.value = text
// Keep it out of view without scrolling the page.
textarea.style.position = 'fixed'
textarea.style.top = '0'
textarea.style.left = '-9999px'
textarea.setAttribute('readonly', '')
document.body.appendChild(textarea)
const selection = document.getSelection()
const previousRange =
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null
try {
textarea.select()
textarea.setSelectionRange(0, text.length)
return document.execCommand('copy')
} catch {
return false
} finally {
document.body.removeChild(textarea)
if (previousRange && selection) {
selection.removeAllRanges()
selection.addRange(previousRange)
}
}
}

View File

@@ -605,6 +605,12 @@
"save": "Save",
"saveChanges": "Save Changes"
},
"filters": {
"all": "All tokens",
"label": "Filter",
"metricsOnly": "Metrics only",
"regular": "Regular tokens"
},
"form": {
"1day": "1 day",
"30days": "30 days",
@@ -616,6 +622,8 @@
"expirationDescription": "Tokens will automatically expire after the selected time.",
"expirationLabel": "Expiration",
"expirationPlaceholder": "Select expiration",
"metricsOnlyDescription": "The token can only call GET /api/v1/metrics and is rejected on every other endpoint. The owner must hold the system:metrics permission.",
"metricsOnlyLabel": "Metrics endpoint only",
"nameDescription": "Optional · Max 32 characters",
"nameLabel": "Name",
"namePlaceholder": "Give this access token a short name",
@@ -626,7 +634,9 @@
"deleteBtn": "Delete token",
"expiresOnLabel": "Expires on",
"expiresOnShort": "Expires on {{date}}",
"filterEmpty": "No tokens match this filter.",
"lastUsedLabel": "Last used",
"metricsOnlyBadge": "Metrics only",
"neverExpires": "Never expires",
"neverLabel": "Never",
"neverUsed": "Never",
@@ -1935,6 +1945,7 @@
},
"system": {
"access": "Basic System Access",
"metrics": "Read Server Metrics (Prometheus endpoint)",
"root": "Root System Control"
},
"timestamp": {