Merge branch 'mail': SMTP mail, access-request notifications, request throttle
This commit is contained in:
Generated
+63
@@ -902,6 +902,7 @@ dependencies = [
|
|||||||
"hmac",
|
"hmac",
|
||||||
"image",
|
"image",
|
||||||
"jiff",
|
"jiff",
|
||||||
|
"lettre",
|
||||||
"libc",
|
"libc",
|
||||||
"password-auth",
|
"password-auth",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
@@ -1133,6 +1134,22 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email-encoding"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.23.1",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email_address"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -1700,6 +1717,17 @@ dependencies = [
|
|||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hostname"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html-escape"
|
name = "html-escape"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -2226,6 +2254,34 @@ version = "0.5.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lettre"
|
||||||
|
version = "0.11.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64 0.23.1",
|
||||||
|
"email-encoding",
|
||||||
|
"email_address",
|
||||||
|
"fastrand",
|
||||||
|
"futures-io",
|
||||||
|
"futures-util",
|
||||||
|
"hostname",
|
||||||
|
"httpdate",
|
||||||
|
"idna",
|
||||||
|
"mime",
|
||||||
|
"nom",
|
||||||
|
"percent-encoding",
|
||||||
|
"quoted_printable",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"url",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.189"
|
version = "0.2.189"
|
||||||
@@ -2967,6 +3023,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quoted_printable"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "5.3.0"
|
version = "5.3.0"
|
||||||
@@ -3318,6 +3380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ hex = "0.4.3"
|
|||||||
hmac = "0.13.0"
|
hmac = "0.13.0"
|
||||||
image = "0.25.10"
|
image = "0.25.10"
|
||||||
jiff = { version = "0.2.35", features = ["serde"] }
|
jiff = { version = "0.2.35", features = ["serde"] }
|
||||||
|
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "hostname"] }
|
||||||
libc = "0.2.189"
|
libc = "0.2.189"
|
||||||
password-auth = "1.0.0"
|
password-auth = "1.0.0"
|
||||||
rand = "0.10.2"
|
rand = "0.10.2"
|
||||||
|
|||||||
@@ -214,6 +214,10 @@ are case-insensitive and passwords must be 12–1024 characters. Bootstrap with
|
|||||||
`disable`/`enable`, `list`, and `logout` for later administration. Password
|
`disable`/`enable`, `list`, and `logout` for later administration. Password
|
||||||
changes and disabling a user revoke that user's sessions. `/dashboard/users`
|
changes and disabling a user revoke that user's sessions. `/dashboard/users`
|
||||||
is a read-only view of roles, status, login times, and open sessions.
|
is a read-only view of roles, status, login times, and open sessions.
|
||||||
|
When `[mail]` is active and `mail.notify_to` is set, each stored request queues
|
||||||
|
a plain-text SMTP notification with a direct dashboard review link; SMTP runs
|
||||||
|
in the background and never delays the visitor's response. Mail settings are
|
||||||
|
loaded at server startup, so changes require a service restart.
|
||||||
|
|
||||||
The Jobs page starts only the fixed job catalogue as
|
The Jobs page starts only the fixed job catalogue as
|
||||||
`daily-epub-job@<name>.service`; the web server never runs the pipeline inside
|
`daily-epub-job@<name>.service`; the web server never runs the pipeline inside
|
||||||
@@ -263,8 +267,8 @@ articles do not yet have embeddings to compare.
|
|||||||
| `GET /issues/{date}/articles/{id}`, `/world`, `/behind` | User or admin | Private article, World Briefing, and Behind the paper chapters. |
|
| `GET /issues/{date}/articles/{id}`, `/world`, `/behind` | User or admin | Private article, World Briefing, and Behind the paper chapters. |
|
||||||
| `GET /issues/{date}/read` | User or admin | Open the Standard edition in BookOrbit's web reader when the integration is enabled. |
|
| `GET /issues/{date}/read` | User or admin | Open the Standard edition in BookOrbit's web reader when the integration is enabled. |
|
||||||
| `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded CSS, JavaScript, and favicon. |
|
| `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded CSS, JavaScript, and favicon. |
|
||||||
| `GET/POST /login`, `POST /logout` | Public/session | Sign in and out; login attempts are throttled per client IP. |
|
| `GET/POST /login`, `POST /logout` | Public/session | Sign in and out; login POSTs share a per-client-IP throttle budget with access requests. |
|
||||||
| `GET/POST /request-access` | Public | Request a reader account; requests are reviewed by an admin and fulfilled with the CLI. |
|
| `GET/POST /request-access` | Public | Request a reader account; POSTs share the login throttle, while GET stays unlimited. Requests are reviewed by an admin and fulfilled with the CLI. |
|
||||||
| `GET /account`, `POST /account/password`, `/account/logout-all` | User or admin | Change the current password or revoke sessions. |
|
| `GET /account`, `POST /account/password`, `/account/logout-all` | User or admin | Change the current password or revoke sessions. |
|
||||||
| `POST /rate` | Admin | Append an attributed dashboard rating event. |
|
| `POST /rate` | Admin | Append an attributed dashboard rating event. |
|
||||||
| `GET /dashboard` | Admin | Run, budget, rating, job, and config overview. |
|
| `GET /dashboard` | Admin | Run, budget, rating, job, and config overview. |
|
||||||
@@ -391,6 +395,14 @@ prints what resolved.
|
|||||||
| `bookorbit.api_url` | `http://127.0.0.1:3498` | Server-facing BookOrbit base URL used for OPDS lookups. |
|
| `bookorbit.api_url` | `http://127.0.0.1:3498` | Server-facing BookOrbit base URL used for OPDS lookups. |
|
||||||
| `bookorbit.opds_user` | unset | Dedicated OPDS user created in BookOrbit's Settings → OPDS. |
|
| `bookorbit.opds_user` | unset | Dedicated OPDS user created in BookOrbit's Settings → OPDS. |
|
||||||
| `bookorbit.opds_pass` | — | **`DAILY_EPUB_BOOKORBIT__OPDS_PASS`**, environment only. |
|
| `bookorbit.opds_pass` | — | **`DAILY_EPUB_BOOKORBIT__OPDS_PASS`**, environment only. |
|
||||||
|
| `mail.enabled` | `false` | Enable outbound SMTP when the relay, sender, username, and password are configured. Mail settings require a server restart. |
|
||||||
|
| `mail.smtp_host` | `""` | SMTP relay hostname, such as `email-smtp.us-east-1.amazonaws.com`. |
|
||||||
|
| `mail.smtp_port` | `587` | SMTP relay port. Use 587 with STARTTLS or commonly 465 with implicit TLS. |
|
||||||
|
| `mail.smtp_starttls` | `true` | `true` uses STARTTLS; `false` uses implicit TLS. |
|
||||||
|
| `mail.smtp_user` | unset | SMTP username. It may be supplied as `DAILY_EPUB_MAIL__SMTP_USER`. |
|
||||||
|
| `mail.smtp_pass` | — | **`DAILY_EPUB_MAIL__SMTP_PASS`**, environment only. |
|
||||||
|
| `mail.from` | `""` | Sender mailbox, either a bare address or `Name <address>`. |
|
||||||
|
| `mail.notify_to` | unset | Recipient for access-request notifications. |
|
||||||
| `xtc.enabled` | `true` | Set `false` to skip the converter entirely. |
|
| `xtc.enabled` | `true` | Set `false` to skip the converter entirely. |
|
||||||
| `xtc.command` | `node` | Converter executable. |
|
| `xtc.command` | `node` | Converter executable. |
|
||||||
| `xtc.args` | `["/opt/epub-to-xtc-converter/cli/index.js", "convert"]` | Prefix; the code appends `<input.epub> -o <output> -f <format>` (plus `-c <settings>`). |
|
| `xtc.args` | `["/opt/epub-to-xtc-converter/cli/index.js", "convert"]` | Prefix; the code appends `<input.epub> -o <output> -f <format>` (plus `-c <settings>`). |
|
||||||
@@ -401,7 +413,7 @@ prints what resolved.
|
|||||||
| `server.hmac_secret` | — | **`DAILY_EPUB_SERVER__HMAC_SECRET`** (or `DAILY_EPUB_SECRET`). Without it, generated links are rejected with 403. |
|
| `server.hmac_secret` | — | **`DAILY_EPUB_SERVER__HMAC_SECRET`** (or `DAILY_EPUB_SECRET`). Without it, generated links are rejected with 403. |
|
||||||
| `server.basic_auth_user` / `_pass` | unset | Optional Basic auth for `/opds/*` and `/files/*`; signed-in web users may download from `/files/*` without Basic auth. |
|
| `server.basic_auth_user` / `_pass` | unset | Optional Basic auth for `/opds/*` and `/files/*`; signed-in web users may download from `/files/*` without Basic auth. |
|
||||||
| `server.session_days` | `30` | Sliding lifetime for dashboard login sessions. |
|
| `server.session_days` | `30` | Sliding lifetime for dashboard login sessions. |
|
||||||
| `server.login_attempts` | `10` | Login attempts allowed per IP in one throttle window. |
|
| `server.login_attempts` | `10` | Shared login and access-request POSTs allowed per IP in one throttle window. |
|
||||||
| `server.login_window_minutes` | `15` | Length of the login throttle window. |
|
| `server.login_window_minutes` | `15` | Length of the login throttle window. |
|
||||||
| `server.jobs_enabled` | `true` | Allow the dashboard to start the fixed systemd job catalogue. |
|
| `server.jobs_enabled` | `true` | Allow the dashboard to start the fixed systemd job catalogue. |
|
||||||
| `server.journal_lines` | `300` | Journal lines shown on a dashboard job page (10–5000). |
|
| `server.journal_lines` | `300` | Journal lines shown on a dashboard job page (10–5000). |
|
||||||
@@ -456,6 +468,8 @@ DAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY=…
|
|||||||
# DAILY_EPUB_PROVIDERS__GEMINI__API_KEY=… # only if a role names "gemini"
|
# DAILY_EPUB_PROVIDERS__GEMINI__API_KEY=… # only if a role names "gemini"
|
||||||
DAILY_EPUB_VOYAGE__API_KEY=…
|
DAILY_EPUB_VOYAGE__API_KEY=…
|
||||||
DAILY_EPUB_SERVER__HMAC_SECRET=$(openssl rand -hex 32)
|
DAILY_EPUB_SERVER__HMAC_SECRET=$(openssl rand -hex 32)
|
||||||
|
DAILY_EPUB_MAIL__SMTP_USER=…
|
||||||
|
DAILY_EPUB_MAIL__SMTP_PASS=…
|
||||||
EOF
|
EOF
|
||||||
sudo chown daily-epub:daily-epub /etc/daily-epub/env && sudo chmod 0600 /etc/daily-epub/env
|
sudo chown daily-epub:daily-epub /etc/daily-epub/env && sudo chmod 0600 /etc/daily-epub/env
|
||||||
# One key per [providers.<name>] entry a role uses, named after the table
|
# One key per [providers.<name>] entry a role uses, named after the table
|
||||||
|
|||||||
+11
-1
@@ -211,7 +211,7 @@ settings = "/etc/daily-epub/xtc-settings.json"
|
|||||||
bind = "127.0.0.1:3499" # listen address; keep loopback when trusting proxy IP headers
|
bind = "127.0.0.1:3499" # listen address; keep loopback when trusting proxy IP headers
|
||||||
public_url = "https://daily.hallada.net" # base URL for EPUB rating links and same-origin checks
|
public_url = "https://daily.hallada.net" # base URL for EPUB rating links and same-origin checks
|
||||||
session_days = 30 # sliding lifetime for web login sessions
|
session_days = 30 # sliding lifetime for web login sessions
|
||||||
login_attempts = 10 # login POSTs allowed per client IP in one throttle window
|
login_attempts = 10 # shared login/access-request POSTs per client IP per window
|
||||||
login_window_minutes = 15 # length of the login throttle window
|
login_window_minutes = 15 # length of the login throttle window
|
||||||
jobs_enabled = true # let the dashboard start daily-epub-job@<name>.service
|
jobs_enabled = true # let the dashboard start daily-epub-job@<name>.service
|
||||||
journal_lines = 300 # job-page journal tail; valid range 10..=5000
|
journal_lines = 300 # job-page journal tail; valid range 10..=5000
|
||||||
@@ -227,3 +227,13 @@ public_url = "https://bookorbit.hallada.net" # what the browser opens
|
|||||||
api_url = "http://127.0.0.1:3498" # where the server talks OPDS; same host
|
api_url = "http://127.0.0.1:3498" # where the server talks OPDS; same host
|
||||||
opds_user = "" # an OPDS user from BookOrbit → Settings → OPDS
|
opds_user = "" # an OPDS user from BookOrbit → Settings → OPDS
|
||||||
# opds_pass: environment only (DAILY_EPUB_BOOKORBIT__OPDS_PASS)
|
# opds_pass: environment only (DAILY_EPUB_BOOKORBIT__OPDS_PASS)
|
||||||
|
|
||||||
|
[mail]
|
||||||
|
enabled = false
|
||||||
|
smtp_host = "" # e.g. email-smtp.us-east-1.amazonaws.com
|
||||||
|
smtp_port = 587 # 587 with STARTTLS; commonly 465 with implicit TLS
|
||||||
|
smtp_starttls = true # false selects implicit TLS
|
||||||
|
smtp_user = ""
|
||||||
|
# smtp_pass: environment only (DAILY_EPUB_MAIL__SMTP_PASS)
|
||||||
|
from = "" # e.g. The Daily EPUB <daily@hallada.net>
|
||||||
|
# notify_to = "operator@example.com" # recipient for access-request notifications
|
||||||
|
|||||||
+123
-1
@@ -83,6 +83,7 @@ pub struct Config {
|
|||||||
pub xtc: XtcConfig,
|
pub xtc: XtcConfig,
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
pub bookorbit: BookorbitConfig,
|
pub bookorbit: BookorbitConfig,
|
||||||
|
pub mail: MailConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
@@ -108,6 +109,7 @@ impl Default for Config {
|
|||||||
xtc: XtcConfig::default(),
|
xtc: XtcConfig::default(),
|
||||||
server: ServerConfig::default(),
|
server: ServerConfig::default(),
|
||||||
bookorbit: BookorbitConfig::default(),
|
bookorbit: BookorbitConfig::default(),
|
||||||
|
mail: MailConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -704,7 +706,7 @@ pub struct ServerConfig {
|
|||||||
pub basic_auth_pass: Option<String>,
|
pub basic_auth_pass: Option<String>,
|
||||||
/// Sliding web-session lifetime in days.
|
/// Sliding web-session lifetime in days.
|
||||||
pub session_days: u32,
|
pub session_days: u32,
|
||||||
/// Login attempts allowed per IP during the configured window.
|
/// Login and access-request POSTs allowed per IP during the configured window.
|
||||||
pub login_attempts: u32,
|
pub login_attempts: u32,
|
||||||
pub login_window_minutes: u32,
|
pub login_window_minutes: u32,
|
||||||
/// Whether the operator dashboard may start systemd jobs.
|
/// Whether the operator dashboard may start systemd jobs.
|
||||||
@@ -784,6 +786,60 @@ impl BookorbitConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `[mail]` — optional outbound SMTP delivery.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields, default)]
|
||||||
|
pub struct MailConfig {
|
||||||
|
/// Whether outbound mail is enabled.
|
||||||
|
pub enabled: bool,
|
||||||
|
/// SMTP relay hostname.
|
||||||
|
pub smtp_host: String,
|
||||||
|
/// SMTP relay port.
|
||||||
|
pub smtp_port: u16,
|
||||||
|
/// Upgrade the connection with STARTTLS; false uses implicit TLS.
|
||||||
|
pub smtp_starttls: bool,
|
||||||
|
/// SMTP username.
|
||||||
|
pub smtp_user: Option<String>,
|
||||||
|
/// SMTP password; supply via `DAILY_EPUB_MAIL__SMTP_PASS`.
|
||||||
|
pub smtp_pass: Option<String>,
|
||||||
|
/// Sender mailbox, either an address or `Name <address>`.
|
||||||
|
pub from: String,
|
||||||
|
/// Recipient for access-request notifications.
|
||||||
|
pub notify_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MailConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
smtp_host: String::new(),
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_starttls: true,
|
||||||
|
smtp_user: None,
|
||||||
|
smtp_pass: None,
|
||||||
|
from: String::new(),
|
||||||
|
notify_to: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MailConfig {
|
||||||
|
/// Whether mail is enabled with all fields required for SMTP delivery.
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.enabled
|
||||||
|
&& !self.smtp_host.trim().is_empty()
|
||||||
|
&& !self.from.trim().is_empty()
|
||||||
|
&& self
|
||||||
|
.smtp_user
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| !value.trim().is_empty())
|
||||||
|
&& self
|
||||||
|
.smtp_pass
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| !value.trim().is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Config keys that moved from `[deepseek]` to `[llm]`; anywhere else they are
|
/// Config keys that moved from `[deepseek]` to `[llm]`; anywhere else they are
|
||||||
/// a stale-configuration error.
|
/// a stale-configuration error.
|
||||||
const LLM_ROLE_KEYS: &[&str] = &[
|
const LLM_ROLE_KEYS: &[&str] = &[
|
||||||
@@ -1001,6 +1057,16 @@ impl Config {
|
|||||||
|
|
||||||
/// Cheap sanity checks so misconfiguration fails at startup, not mid-run.
|
/// Cheap sanity checks so misconfiguration fails at startup, not mid-run.
|
||||||
pub fn validate(&self) -> Result<(), ConfigError> {
|
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||||
|
if self.mail.enabled && self.mail.smtp_host.trim().is_empty() {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"mail.smtp_host must not be empty when mail.enabled is true".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.mail.enabled && self.mail.from.trim().is_empty() {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"mail.from must not be empty when mail.enabled is true".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if self.server.session_days == 0 {
|
if self.server.session_days == 0 {
|
||||||
return Err(ConfigError::Invalid(
|
return Err(ConfigError::Invalid(
|
||||||
"server.session_days must be >= 1".into(),
|
"server.session_days must be >= 1".into(),
|
||||||
@@ -1328,6 +1394,14 @@ mod tests {
|
|||||||
assert_eq!(c.bookorbit.api_url, "http://127.0.0.1:3498");
|
assert_eq!(c.bookorbit.api_url, "http://127.0.0.1:3498");
|
||||||
assert!(c.bookorbit.opds_user.is_none());
|
assert!(c.bookorbit.opds_user.is_none());
|
||||||
assert!(c.bookorbit.opds_pass.is_none());
|
assert!(c.bookorbit.opds_pass.is_none());
|
||||||
|
assert!(!c.mail.enabled);
|
||||||
|
assert!(c.mail.smtp_host.is_empty());
|
||||||
|
assert_eq!(c.mail.smtp_port, 587);
|
||||||
|
assert!(c.mail.smtp_starttls);
|
||||||
|
assert!(c.mail.smtp_user.is_none());
|
||||||
|
assert!(c.mail.smtp_pass.is_none());
|
||||||
|
assert!(c.mail.from.is_empty());
|
||||||
|
assert!(c.mail.notify_to.is_none());
|
||||||
c.validate().unwrap();
|
c.validate().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1358,6 +1432,7 @@ mod tests {
|
|||||||
jail.set_env("DAILY_EPUB_TARGET_ARTICLE_COUNT", "12");
|
jail.set_env("DAILY_EPUB_TARGET_ARTICLE_COUNT", "12");
|
||||||
jail.set_env("DAILY_EPUB_SERVER__HMAC_SECRET", "hunter2");
|
jail.set_env("DAILY_EPUB_SERVER__HMAC_SECRET", "hunter2");
|
||||||
jail.set_env("DAILY_EPUB_BOOKORBIT__OPDS_PASS", "orbit-secret");
|
jail.set_env("DAILY_EPUB_BOOKORBIT__OPDS_PASS", "orbit-secret");
|
||||||
|
jail.set_env("DAILY_EPUB_MAIL__SMTP_PASS", "smtp-secret");
|
||||||
jail.set_env("DAILY_EPUB_VOYAGE__API_KEY", "voyage-key");
|
jail.set_env("DAILY_EPUB_VOYAGE__API_KEY", "voyage-key");
|
||||||
jail.set_env("DAILY_EPUB_VOYAGE__ENABLED", "false");
|
jail.set_env("DAILY_EPUB_VOYAGE__ENABLED", "false");
|
||||||
jail.set_env("DAILY_EPUB_PROVIDERS__GEMINI__API_KEY", "gemini-key");
|
jail.set_env("DAILY_EPUB_PROVIDERS__GEMINI__API_KEY", "gemini-key");
|
||||||
@@ -1387,6 +1462,7 @@ mod tests {
|
|||||||
assert_eq!(c.target_article_count, 12);
|
assert_eq!(c.target_article_count, 12);
|
||||||
assert_eq!(c.server.hmac_secret.as_deref(), Some("hunter2"));
|
assert_eq!(c.server.hmac_secret.as_deref(), Some("hunter2"));
|
||||||
assert_eq!(c.bookorbit.opds_pass.as_deref(), Some("orbit-secret"));
|
assert_eq!(c.bookorbit.opds_pass.as_deref(), Some("orbit-secret"));
|
||||||
|
assert_eq!(c.mail.smtp_pass.as_deref(), Some("smtp-secret"));
|
||||||
// untouched default
|
// untouched default
|
||||||
assert_eq!(c.retention_days, 21);
|
assert_eq!(c.retention_days, 21);
|
||||||
assert_eq!(c.timezone, "America/New_York");
|
assert_eq!(c.timezone, "America/New_York");
|
||||||
@@ -1411,6 +1487,52 @@ mod tests {
|
|||||||
assert!(!bookorbit.is_active());
|
assert!(!bookorbit.is_active());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mail_defaults_activation_and_validation() {
|
||||||
|
let mut mail = MailConfig::default();
|
||||||
|
assert!(!mail.enabled);
|
||||||
|
assert_eq!(mail.smtp_port, 587);
|
||||||
|
assert!(mail.smtp_starttls);
|
||||||
|
assert!(!mail.is_active());
|
||||||
|
|
||||||
|
mail.enabled = true;
|
||||||
|
assert!(
|
||||||
|
Config {
|
||||||
|
mail: mail.clone(),
|
||||||
|
..Config::default()
|
||||||
|
}
|
||||||
|
.validate()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
mail.smtp_host = "email-smtp.us-east-1.amazonaws.com".into();
|
||||||
|
assert!(
|
||||||
|
Config {
|
||||||
|
mail: mail.clone(),
|
||||||
|
..Config::default()
|
||||||
|
}
|
||||||
|
.validate()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
mail.from = "The Daily EPUB <daily@example.com>".into();
|
||||||
|
assert!(
|
||||||
|
Config {
|
||||||
|
mail: mail.clone(),
|
||||||
|
..Config::default()
|
||||||
|
}
|
||||||
|
.validate()
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(!mail.is_active());
|
||||||
|
|
||||||
|
mail.smtp_user = Some("smtp-user".into());
|
||||||
|
mail.smtp_pass = Some("smtp-pass".into());
|
||||||
|
assert!(mail.is_active());
|
||||||
|
mail.smtp_pass = Some(" ".into());
|
||||||
|
assert!(!mail.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_missing_path_is_an_error() {
|
fn explicit_missing_path_is_an_error() {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub mod images;
|
|||||||
pub mod imports;
|
pub mod imports;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod lock;
|
pub mod lock;
|
||||||
|
pub mod mail;
|
||||||
pub mod miniflux;
|
pub mod miniflux;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
pub mod publish;
|
pub mod publish;
|
||||||
|
|||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
//! Minimal plain-text SMTP delivery.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use lettre::message::{Mailbox, header::ContentType};
|
||||||
|
use lettre::transport::smtp::authentication::Credentials;
|
||||||
|
use lettre::{AsyncSmtpTransport, AsyncTransport as _, Tokio1Executor};
|
||||||
|
|
||||||
|
use crate::config::MailConfig;
|
||||||
|
|
||||||
|
/// A plain-text outbound message.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Message {
|
||||||
|
/// Recipient mailbox, either an address or `Name <address>`.
|
||||||
|
pub to: String,
|
||||||
|
/// Message subject.
|
||||||
|
pub subject: String,
|
||||||
|
/// Plain-text message body.
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum Transport {
|
||||||
|
Smtp(AsyncSmtpTransport<Tokio1Executor>),
|
||||||
|
#[cfg(test)]
|
||||||
|
Recording(Arc<std::sync::Mutex<Vec<Message>>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reusable SMTP sender built from the startup configuration.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Mailer {
|
||||||
|
from: Mailbox,
|
||||||
|
transport: Transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mailer {
|
||||||
|
/// Build an SMTP sender, or return `None` when mail is not fully active.
|
||||||
|
pub fn from_config(config: &MailConfig) -> anyhow::Result<Option<Self>> {
|
||||||
|
if !config.is_active() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let from = parse_mailbox(&config.from, "mail.from")?;
|
||||||
|
let user = config
|
||||||
|
.smtp_user
|
||||||
|
.as_deref()
|
||||||
|
.expect("active mail config has an SMTP username");
|
||||||
|
let pass = config
|
||||||
|
.smtp_pass
|
||||||
|
.as_deref()
|
||||||
|
.expect("active mail config has an SMTP password");
|
||||||
|
let builder = if config.smtp_starttls {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp_host)
|
||||||
|
} else {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp_host)
|
||||||
|
}
|
||||||
|
.with_context(|| format!("invalid SMTP host {:?}", config.smtp_host))?;
|
||||||
|
let transport = builder
|
||||||
|
.port(config.smtp_port)
|
||||||
|
.credentials(Credentials::new(user.to_string(), pass.to_string()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Ok(Some(Self {
|
||||||
|
from,
|
||||||
|
transport: Transport::Smtp(transport),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send one plain-text message.
|
||||||
|
pub async fn send(&self, message: Message) -> anyhow::Result<()> {
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Transport::Recording(messages) = &self.transport {
|
||||||
|
messages
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.push(message);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let to = parse_mailbox(&message.to, "message recipient")?;
|
||||||
|
let email = lettre::Message::builder()
|
||||||
|
.from(self.from.clone())
|
||||||
|
.to(to)
|
||||||
|
.subject(message.subject)
|
||||||
|
.header(ContentType::TEXT_PLAIN)
|
||||||
|
.body(message.body)
|
||||||
|
.context("building plain-text email")?;
|
||||||
|
match &self.transport {
|
||||||
|
Transport::Smtp(transport) => {
|
||||||
|
transport
|
||||||
|
.send(email)
|
||||||
|
.await
|
||||||
|
.context("sending email through SMTP")?;
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
Transport::Recording(_) => unreachable!("recording transport returned above"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn recording() -> (Self, Arc<std::sync::Mutex<Vec<Message>>>) {
|
||||||
|
let messages = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
from: "daily@example.com".parse().expect("valid test mailbox"),
|
||||||
|
transport: Transport::Recording(Arc::clone(&messages)),
|
||||||
|
},
|
||||||
|
messages,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_mailbox(value: &str, field: &str) -> anyhow::Result<Mailbox> {
|
||||||
|
value
|
||||||
|
.parse::<Mailbox>()
|
||||||
|
.with_context(|| format!("{field} is not a valid email mailbox"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_named_and_bare_mailboxes() {
|
||||||
|
assert!(parse_mailbox("The Daily EPUB <daily@example.com>", "from").is_ok());
|
||||||
|
assert!(parse_mailbox("operator@example.com", "to").is_ok());
|
||||||
|
assert!(parse_mailbox("not an address", "to").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inactive_config_builds_no_mailer() {
|
||||||
|
assert!(
|
||||||
|
Mailer::from_config(&MailConfig::default())
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut config = MailConfig {
|
||||||
|
enabled: true,
|
||||||
|
smtp_host: "smtp.example.com".into(),
|
||||||
|
from: "daily@example.com".into(),
|
||||||
|
smtp_user: Some("user".into()),
|
||||||
|
smtp_pass: Some(" ".into()),
|
||||||
|
..MailConfig::default()
|
||||||
|
};
|
||||||
|
assert!(Mailer::from_config(&config).unwrap().is_none());
|
||||||
|
|
||||||
|
config.smtp_pass = Some("secret".into());
|
||||||
|
config.from = "not an address".into();
|
||||||
|
assert!(Mailer::from_config(&config).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -70,6 +70,8 @@ pub enum ServerError {
|
|||||||
},
|
},
|
||||||
#[error("io error: {0}")]
|
#[error("io error: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("could not configure outbound mail: {0}")]
|
||||||
|
Mail(#[source] anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared axum state.
|
/// Shared axum state.
|
||||||
@@ -79,6 +81,8 @@ pub struct AppState {
|
|||||||
pub config: Arc<RwLock<Arc<Config>>>,
|
pub config: Arc<RwLock<Arc<Config>>>,
|
||||||
pub config_path: Option<PathBuf>,
|
pub config_path: Option<PathBuf>,
|
||||||
pub web: Arc<crate::web::WebState>,
|
pub web: Arc<crate::web::WebState>,
|
||||||
|
/// Startup-built SMTP sender. Mail configuration changes require a restart.
|
||||||
|
pub mailer: Option<crate::mail::Mailer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for AppState {
|
impl std::fmt::Debug for AppState {
|
||||||
@@ -102,6 +106,7 @@ impl AppState {
|
|||||||
started_at: Timestamp::now(),
|
started_at: Timestamp::now(),
|
||||||
config_mtime: std::sync::Mutex::new(None),
|
config_mtime: std::sync::Mutex::new(None),
|
||||||
}),
|
}),
|
||||||
|
mailer: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +206,7 @@ pub async fn serve(
|
|||||||
// Not fatal for the OPDS routes, but every rating link would 500.
|
// Not fatal for the OPDS routes, but every rating link would 500.
|
||||||
tracing::warn!("server.hmac_secret is unset — rating links will be rejected");
|
tracing::warn!("server.hmac_secret is unset — rating links will be rejected");
|
||||||
}
|
}
|
||||||
|
let mailer = crate::mail::Mailer::from_config(&config.mail).map_err(ServerError::Mail)?;
|
||||||
let addr = config.server.bind.clone();
|
let addr = config.server.bind.clone();
|
||||||
let listener = tokio::net::TcpListener::bind(&addr)
|
let listener = tokio::net::TcpListener::bind(&addr)
|
||||||
.await
|
.await
|
||||||
@@ -234,7 +240,9 @@ pub async fn serve(
|
|||||||
tracing::info!("server.jobs_enabled is false; the Jobs page cannot start units");
|
tracing::info!("server.jobs_enabled is false; the Jobs page cannot start units");
|
||||||
Arc::new(crate::web::DisabledRunner)
|
Arc::new(crate::web::DisabledRunner)
|
||||||
};
|
};
|
||||||
let app = router(AppState::with_jobs(db, config, config_path, jobs));
|
let mut state = AppState::with_jobs(db, config, config_path, jobs);
|
||||||
|
state.mailer = mailer;
|
||||||
|
let app = router(state);
|
||||||
axum::serve(
|
axum::serve(
|
||||||
listener,
|
listener,
|
||||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
|
|||||||
+101
-2
@@ -71,6 +71,7 @@ pub(super) async fn submit(
|
|||||||
return Ok(Html(view).into_response());
|
return Ok(Html(view).into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let requested_at = crate::db::fmt_ts(jiff::Timestamp::now());
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO account_requests (email, reason, status, requested_at)
|
"INSERT INTO account_requests (email, reason, status, requested_at)
|
||||||
VALUES (?, ?, 'open', ?)
|
VALUES (?, ?, 'open', ?)
|
||||||
@@ -81,11 +82,40 @@ pub(super) async fn submit(
|
|||||||
)
|
)
|
||||||
.bind(email)
|
.bind(email)
|
||||||
.bind((!reason.is_empty()).then_some(reason))
|
.bind((!reason.is_empty()).then_some(reason))
|
||||||
.bind(crate::db::fmt_ts(jiff::Timestamp::now()))
|
.bind(&requested_at)
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|error| WebError::Db(error.into()))?;
|
.map_err(|error| WebError::Db(error.into()))?;
|
||||||
|
|
||||||
|
let config = state.config();
|
||||||
|
if let (Some(mailer), Some(to)) = (
|
||||||
|
state.mailer.clone(),
|
||||||
|
config
|
||||||
|
.mail
|
||||||
|
.notify_to
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty()),
|
||||||
|
) {
|
||||||
|
let reason = if reason.is_empty() {
|
||||||
|
"(no reason given)"
|
||||||
|
} else {
|
||||||
|
reason
|
||||||
|
};
|
||||||
|
let message = crate::mail::Message {
|
||||||
|
to: to.to_string(),
|
||||||
|
subject: format!("Access request from {email}"),
|
||||||
|
body: format!(
|
||||||
|
"Email: {email}\nReason: {reason}\nRequested at: {requested_at}\nReview: {}/dashboard/users\n",
|
||||||
|
config.server.public_url.trim_end_matches('/')
|
||||||
|
),
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(error) = mailer.send(message).await {
|
||||||
|
tracing::warn!(%error, "could not send access-request notification");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let mut view = template(viewer);
|
let mut view = template(viewer);
|
||||||
view.submitted = true;
|
view.submitted = true;
|
||||||
Ok(Html(view).into_response())
|
Ok(Html(view).into_response())
|
||||||
@@ -117,11 +147,15 @@ mod tests {
|
|||||||
use crate::server::{AppState, router};
|
use crate::server::{AppState, router};
|
||||||
|
|
||||||
async fn app() -> (tempfile::TempDir, Db, axum::Router) {
|
async fn app() -> (tempfile::TempDir, Db, axum::Router) {
|
||||||
|
app_with_config(Config::default()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn app_with_config(config: Config) -> (tempfile::TempDir, Db, axum::Router) {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let app = router(AppState::new(db.clone(), Config::default(), None));
|
let app = router(AppState::new(db.clone(), config, None));
|
||||||
(dir, db, app)
|
(dir, db, app)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +190,7 @@ mod tests {
|
|||||||
.uri("/request-access")
|
.uri("/request-access")
|
||||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||||
.header("sec-fetch-site", "same-origin")
|
.header("sec-fetch-site", "same-origin")
|
||||||
|
.header("x-forwarded-for", "192.0.2.30")
|
||||||
.body(Body::from(body.to_string()))
|
.body(Body::from(body.to_string()))
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
@@ -209,6 +244,70 @@ mod tests {
|
|||||||
assert_eq!(row.get::<String, _>("status"), "open");
|
assert_eq!(row.get::<String, _>("status"), "open");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stored_request_queues_an_operator_notification() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.server.public_url = "https://daily.example/".into();
|
||||||
|
config.mail.notify_to = Some("Operator <operator@example.com>".into());
|
||||||
|
let (mailer, messages) = crate::mail::Mailer::recording();
|
||||||
|
let mut state = AppState::new(db, config, None);
|
||||||
|
state.mailer = Some(mailer);
|
||||||
|
let app = router(state);
|
||||||
|
|
||||||
|
let (status, _) = post(&app, "email=reader%40example.com&reason=&website=").await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
if !messages
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("notification task did not run");
|
||||||
|
|
||||||
|
let messages = messages
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
assert_eq!(messages.len(), 1);
|
||||||
|
assert_eq!(messages[0].to, "Operator <operator@example.com>");
|
||||||
|
assert_eq!(
|
||||||
|
messages[0].subject,
|
||||||
|
"Access request from reader@example.com"
|
||||||
|
);
|
||||||
|
assert!(messages[0].body.contains("Email: reader@example.com"));
|
||||||
|
assert!(messages[0].body.contains("Reason: (no reason given)"));
|
||||||
|
assert!(messages[0].body.contains("Requested at: "));
|
||||||
|
assert!(
|
||||||
|
messages[0]
|
||||||
|
.body
|
||||||
|
.contains("Review: https://daily.example/dashboard/users")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn request_access_post_is_rate_limited_per_ip() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.server.login_attempts = 3;
|
||||||
|
let (_dir, _db, app) = app_with_config(config).await;
|
||||||
|
|
||||||
|
for _ in 0..3 {
|
||||||
|
let (status, _) = post(&app, "email=reader%40example.com&reason=&website=").await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
}
|
||||||
|
let (status, _) = post(&app, "email=reader%40example.com&reason=&website=").await;
|
||||||
|
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn honeypot_pretends_success_without_storing() {
|
async fn honeypot_pretends_success_without_storing() {
|
||||||
let (_dir, db, app) = app().await;
|
let (_dir, db, app) = app().await;
|
||||||
|
|||||||
@@ -205,6 +205,14 @@ pub const RESTART_REQUIRED: &[&str] = &[
|
|||||||
"server.session_days",
|
"server.session_days",
|
||||||
"server.login_attempts",
|
"server.login_attempts",
|
||||||
"server.login_window_minutes",
|
"server.login_window_minutes",
|
||||||
|
"mail.enabled",
|
||||||
|
"mail.smtp_host",
|
||||||
|
"mail.smtp_port",
|
||||||
|
"mail.smtp_starttls",
|
||||||
|
"mail.smtp_user",
|
||||||
|
"mail.smtp_pass",
|
||||||
|
"mail.from",
|
||||||
|
"mail.notify_to",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Optional keys that `Config::default()` leaves unset (and therefore do not
|
/// Optional keys that `Config::default()` leaves unset (and therefore do not
|
||||||
@@ -219,10 +227,19 @@ const OPTIONAL_KEYS: &[(&str, FieldKind)] = &[
|
|||||||
("server.basic_auth_pass", FieldKind::Secret),
|
("server.basic_auth_pass", FieldKind::Secret),
|
||||||
("bookorbit.opds_user", FieldKind::Text),
|
("bookorbit.opds_user", FieldKind::Text),
|
||||||
("bookorbit.opds_pass", FieldKind::Secret),
|
("bookorbit.opds_pass", FieldKind::Secret),
|
||||||
|
("mail.smtp_user", FieldKind::Text),
|
||||||
|
("mail.smtp_pass", FieldKind::Secret),
|
||||||
|
("mail.notify_to", FieldKind::Text),
|
||||||
("xtc.settings", FieldKind::Path),
|
("xtc.settings", FieldKind::Path),
|
||||||
];
|
];
|
||||||
|
|
||||||
const SECRET_SUFFIXES: &[&str] = &["api_key", "hmac_secret", "basic_auth_pass", "opds_pass"];
|
const SECRET_SUFFIXES: &[&str] = &[
|
||||||
|
"api_key",
|
||||||
|
"hmac_secret",
|
||||||
|
"basic_auth_pass",
|
||||||
|
"opds_pass",
|
||||||
|
"smtp_pass",
|
||||||
|
];
|
||||||
|
|
||||||
const PATH_KEYS: &[&str] = &[
|
const PATH_KEYS: &[&str] = &[
|
||||||
"database_path",
|
"database_path",
|
||||||
@@ -259,6 +276,7 @@ const GROUP_ORDER: &[&str] = &[
|
|||||||
"server",
|
"server",
|
||||||
"miniflux",
|
"miniflux",
|
||||||
"bookorbit",
|
"bookorbit",
|
||||||
|
"mail",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Help text per key, seeded from the README configuration table and the
|
/// Help text per key, seeded from the README configuration table and the
|
||||||
@@ -375,6 +393,14 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[
|
|||||||
("bookorbit.api_url", "Base URL used by the server for BookOrbit OPDS requests; usually the loopback address."),
|
("bookorbit.api_url", "Base URL used by the server for BookOrbit OPDS requests; usually the loopback address."),
|
||||||
("bookorbit.opds_user", "Dedicated OPDS user created in BookOrbit Settings → OPDS."),
|
("bookorbit.opds_user", "Dedicated OPDS user created in BookOrbit Settings → OPDS."),
|
||||||
("bookorbit.opds_pass", "Password for bookorbit.opds_user. Environment only."),
|
("bookorbit.opds_pass", "Password for bookorbit.opds_user. Environment only."),
|
||||||
|
("mail.enabled", "Enable outbound SMTP when the relay, sender and credentials are configured. Requires a server restart."),
|
||||||
|
("mail.smtp_host", "SMTP relay hostname, such as an AWS SES SMTP endpoint. Requires a server restart."),
|
||||||
|
("mail.smtp_port", "SMTP relay port: usually 587 for STARTTLS or 465 for implicit TLS. Requires a server restart."),
|
||||||
|
("mail.smtp_starttls", "Use STARTTLS when true; false uses implicit TLS. Requires a server restart."),
|
||||||
|
("mail.smtp_user", "SMTP username. Requires a server restart."),
|
||||||
|
("mail.smtp_pass", "SMTP password. Environment only; requires a server restart."),
|
||||||
|
("mail.from", "Sender mailbox as an address or Name <address>. Requires a server restart."),
|
||||||
|
("mail.notify_to", "Recipient for new access-request notifications. Requires a server restart."),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// `DAILY_EPUB_` + the path upper-cased with `.` → `__` (§13.1 item 2).
|
/// `DAILY_EPUB_` + the path upper-cased with `.` → `__` (§13.1 item 2).
|
||||||
@@ -1664,6 +1690,9 @@ mod tests {
|
|||||||
"server.basic_auth_user",
|
"server.basic_auth_user",
|
||||||
"bookorbit.opds_user",
|
"bookorbit.opds_user",
|
||||||
"bookorbit.opds_pass",
|
"bookorbit.opds_pass",
|
||||||
|
"mail.smtp_user",
|
||||||
|
"mail.smtp_pass",
|
||||||
|
"mail.notify_to",
|
||||||
] {
|
] {
|
||||||
field(&groups, path);
|
field(&groups, path);
|
||||||
}
|
}
|
||||||
@@ -1695,6 +1724,7 @@ mod tests {
|
|||||||
"server",
|
"server",
|
||||||
"miniflux",
|
"miniflux",
|
||||||
"bookorbit",
|
"bookorbit",
|
||||||
|
"mail",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
let anthropic = groups
|
let anthropic = groups
|
||||||
@@ -1811,6 +1841,7 @@ mod tests {
|
|||||||
config.server.hmac_secret = Some("hunter2-hmac".into());
|
config.server.hmac_secret = Some("hunter2-hmac".into());
|
||||||
config.server.basic_auth_pass = Some("hunter2-basic".into());
|
config.server.basic_auth_pass = Some("hunter2-basic".into());
|
||||||
config.bookorbit.opds_pass = Some("hunter2-bookorbit".into());
|
config.bookorbit.opds_pass = Some("hunter2-bookorbit".into());
|
||||||
|
config.mail.smtp_pass = Some("hunter2-smtp".into());
|
||||||
if let Some(provider) = config.providers.get_mut("deepseek") {
|
if let Some(provider) = config.providers.get_mut("deepseek") {
|
||||||
provider.api_key = Some("hunter2-deepseek".into());
|
provider.api_key = Some("hunter2-deepseek".into());
|
||||||
}
|
}
|
||||||
@@ -1821,6 +1852,7 @@ mod tests {
|
|||||||
"server.hmac_secret",
|
"server.hmac_secret",
|
||||||
"server.basic_auth_pass",
|
"server.basic_auth_pass",
|
||||||
"bookorbit.opds_pass",
|
"bookorbit.opds_pass",
|
||||||
|
"mail.smtp_pass",
|
||||||
"providers.deepseek.api_key",
|
"providers.deepseek.api_key",
|
||||||
"providers.anthropic.api_key",
|
"providers.anthropic.api_key",
|
||||||
] {
|
] {
|
||||||
|
|||||||
+18
-2
@@ -659,7 +659,13 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
|
|||||||
.route("/login", get(session::login_page))
|
.route("/login", get(session::login_page))
|
||||||
.route(
|
.route(
|
||||||
"/login",
|
"/login",
|
||||||
post(session::login).route_layer(GovernorLayer::new(governor)),
|
post(session::login).route_layer(GovernorLayer::new(governor.clone())),
|
||||||
|
);
|
||||||
|
let access_routes = axum::Router::new()
|
||||||
|
.route("/request-access", get(access::page))
|
||||||
|
.route(
|
||||||
|
"/request-access",
|
||||||
|
post(access::submit).route_layer(GovernorLayer::new(governor)),
|
||||||
);
|
);
|
||||||
let account = axum::Router::new()
|
let account = axum::Router::new()
|
||||||
.route("/account", get(session::account))
|
.route("/account", get(session::account))
|
||||||
@@ -695,12 +701,12 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
|
|||||||
|
|
||||||
axum::Router::new()
|
axum::Router::new()
|
||||||
.route("/", get(public::latest))
|
.route("/", get(public::latest))
|
||||||
.route("/request-access", get(access::page).post(access::submit))
|
|
||||||
.route("/issues", get(public::archive))
|
.route("/issues", get(public::archive))
|
||||||
.route("/issues/{date}", get(public::show_issue))
|
.route("/issues/{date}", get(public::show_issue))
|
||||||
.route("/feed.xml", get(public::feed))
|
.route("/feed.xml", get(public::feed))
|
||||||
.route("/robots.txt", get(public::robots))
|
.route("/robots.txt", get(public::robots))
|
||||||
.route("/static/{file}", get(static_asset))
|
.route("/static/{file}", get(static_asset))
|
||||||
|
.merge(access_routes)
|
||||||
.merge(login)
|
.merge(login)
|
||||||
.merge(account)
|
.merge(account)
|
||||||
.merge(full_issues)
|
.merge(full_issues)
|
||||||
@@ -1336,6 +1342,16 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
|
assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
|
let shared = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(post(
|
||||||
|
"/request-access",
|
||||||
|
"email=reader%40example.com&reason=&website=",
|
||||||
|
"192.0.2.20",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(shared.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
let other = app
|
let other = app
|
||||||
.oneshot(post(
|
.oneshot(post(
|
||||||
"/login",
|
"/login",
|
||||||
|
|||||||
Reference in New Issue
Block a user