Group the module crates under modules/

The service pseudo-clients and the ircd protocol link sat flat at the
repo root, mixed in with the daemon core and the SDK. Move them all
under modules/ so the tree separates concerns cleanly: the daemon in
src/, the SDK every module links against in api/, and the loadable
modules — the pseudo-clients plus the protocol link — in modules/.

Workspace members, the daemon's per-crate dependency paths, and each
module's api path are updated to match; the docs follow. No code change.
This commit is contained in:
Jean Chevronnet 2026-07-14 14:19:43 +00:00
parent 6f76f9722c
commit ad2a623120
No known key found for this signature in database
116 changed files with 69 additions and 46 deletions

View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-nickserv"
version = "0.0.1"
edition = "2021"
description = "NickServ account-registration service module for fedserv."
[dependencies]
fedserv-api = { path = "../../api" }

View file

@ -0,0 +1,63 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// A sane cap so a runaway list can't bloat an account or flood a user on identify.
const MAX_AJOIN: usize = 25;
// AJOIN [ADD <#channel> [key] | DEL <#channel> | LIST]: manage your auto-join
// list — the channels NickServ joins you to each time you identify.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You must identify to NickServ to use \x02AJOIN\x02.");
return;
};
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ADD") => {
let Some(&channel) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: AJOIN ADD <#channel> [key]");
return;
};
if !channel.starts_with('#') {
ctx.notice(me, from.uid, "That doesn't look like a channel name.");
return;
}
let key = args.get(3).copied().unwrap_or("");
if db.ajoin_list(account).len() >= MAX_AJOIN {
ctx.notice(me, from.uid, format!("Your auto-join list is full (max {MAX_AJOIN})."));
return;
}
match db.ajoin_add(account, channel, key) {
Ok(true) => ctx.notice(me, from.uid, format!("Added \x02{channel}\x02 to your auto-join list.")),
Ok(false) => ctx.notice(me, from.uid, format!("Updated the key for \x02{channel}\x02 on your auto-join list.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
Some("DEL") => {
let Some(&channel) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: AJOIN DEL <#channel>");
return;
};
match db.ajoin_del(account, channel) {
Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{channel}\x02 from your auto-join list.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{channel}\x02 isn't on your auto-join list.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
None | Some("LIST") => {
let list = db.ajoin_list(account);
if list.is_empty() {
ctx.notice(me, from.uid, "Your auto-join list is empty. Add channels with \x02AJOIN ADD <#channel>\x02.");
return;
}
ctx.notice(me, from.uid, format!("Your auto-join list ({}):", list.len()));
for e in &list {
if e.key.is_empty() {
ctx.notice(me, from.uid, format!(" \x02{}\x02", e.channel));
} else {
ctx.notice(me, from.uid, format!(" \x02{}\x02 (key: {})", e.channel, e.key));
}
}
}
Some(other) => ctx.notice(me, from.uid, format!("Unknown AJOIN command \x02{other}\x02. Use \x02ADD\x02, \x02DEL\x02 or \x02LIST\x02.")),
}
}

View file

@ -0,0 +1,27 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// ALIST: list the channels the sender's account founds or has access on.
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
return;
};
let mut rows: Vec<(String, &str)> = Vec::new();
for c in db.channels() {
if c.founder.eq_ignore_ascii_case(account) {
rows.push((c.name.clone(), "founder"));
} else if let Some(a) = c.access.iter().find(|a| a.account.eq_ignore_ascii_case(account)) {
rows.push((c.name.clone(), if a.level == "voice" { "voice" } else { "op" }));
}
}
if rows.is_empty() {
ctx.notice(me, from.uid, "You have access on no channels.");
return;
}
rows.sort_by(|a, b| a.0.cmp(&b.0));
ctx.notice(me, from.uid, format!("Channels you have access on ({}):", rows.len()));
for (chan, role) in rows {
ctx.notice(me, from.uid, format!(" \x02{chan}\x02 ({role})"));
}
}

View file

@ -0,0 +1,59 @@
use fedserv_api::{CertError, Store};
use fedserv_api::{Sender, ServiceCtx};
// CERT ADD|DEL|LIST <password> [fingerprint]: manage the TLS certificate
// fingerprints that may log in to your account via SASL EXTERNAL. Each
// subcommand is password-gated, so it needs no identified-session state.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let auth = |db: &dyn Store, password: &str| db.authenticate(from.nick, password).map(str::to_string);
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("LIST") => {
let Some(password) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: CERT LIST <password>");
return;
};
let Some(account) = auth(db, password) else {
ctx.notice(me, from.uid, "Invalid password.");
return;
};
let fps = db.certfps(&account);
if fps.is_empty() {
ctx.notice(me, from.uid, "No certificate fingerprints are registered to your account.");
} else {
ctx.notice(me, from.uid, format!("Registered fingerprints: {}", fps.join(", ")));
}
}
Some("ADD") => {
let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else {
ctx.notice(me, from.uid, "Syntax: CERT ADD <password> <fingerprint>");
return;
};
let Some(account) = auth(db, password) else {
ctx.notice(me, from.uid, "Invalid password.");
return;
};
match db.certfp_add(&account, fp) {
Ok(()) => ctx.notice(me, from.uid, format!("Added fingerprint \x02{fp}\x02. You can now log in with SASL EXTERNAL.")),
Err(CertError::InUse) => ctx.notice(me, from.uid, "That fingerprint is already registered."),
Err(CertError::Invalid) => ctx.notice(me, from.uid, "That does not look like a certificate fingerprint."),
Err(CertError::NoAccount | CertError::Internal) => ctx.notice(me, from.uid, "Could not add the fingerprint, please try again later."),
}
}
Some("DEL") | Some("REMOVE") => {
let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else {
ctx.notice(me, from.uid, "Syntax: CERT DEL <password> <fingerprint>");
return;
};
let Some(account) = auth(db, password) else {
ctx.notice(me, from.uid, "Invalid password.");
return;
};
match db.certfp_del(&account, fp) {
Ok(true) => ctx.notice(me, from.uid, format!("Removed fingerprint \x02{fp}\x02.")),
Ok(false) => ctx.notice(me, from.uid, "No such fingerprint is registered to your account."),
Err(_) => ctx.notice(me, from.uid, "Could not remove the fingerprint, please try again later."),
}
}
_ => ctx.notice(me, from.uid, "Syntax: CERT ADD|DEL|LIST <password> [fingerprint]"),
}
}

View file

@ -0,0 +1,26 @@
use fedserv_api::{CodeKind, Store};
use fedserv_api::{Sender, ServiceCtx};
// CONFIRM <code>: confirm your account's email with the code you were emailed.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(&code) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: CONFIRM <code>");
return;
};
let Some(account) = from.account.map(str::to_string).or_else(|| db.resolve_account(from.nick).map(str::to_string)) else {
ctx.notice(me, from.uid, "You don't have an account to confirm.");
return;
};
if db.is_verified(&account) {
ctx.notice(me, from.uid, format!("\x02{account}\x02 is already confirmed."));
return;
}
if !db.take_code(&account, CodeKind::Confirm, code) {
ctx.notice(me, from.uid, "Invalid or expired confirmation code.");
return;
}
match db.verify_account(&account) {
Ok(()) => ctx.notice(me, from.uid, format!("\x02{account}\x02 is now confirmed. Thanks!")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,33 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// DROP <password>: delete your account. Re-authenticates as confirmation, releases
// and drops the channels you found, and logs you out.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
return;
};
let Some(&password) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: DROP <password>");
return;
};
if db.authenticate(account, password).is_none() {
ctx.notice(me, from.uid, "Invalid password.");
return;
}
let channels = db.channels_owned_by(account);
for chan in &channels {
let _ = db.drop_channel(chan);
ctx.channel_mode("", chan, "-r"); // server-sourced: release the registered mode
}
let _ = db.drop_account(account);
for uid in net.uids_logged_into(account) {
ctx.logout(&uid);
}
ctx.notice(me, from.uid, format!("Your account \x02{account}\x02 has been dropped."));
if !channels.is_empty() {
ctx.notice(me, from.uid, format!("Channels released: {}.", channels.join(", ")));
}
}

View file

@ -0,0 +1,36 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// GHOST/RECOVER <nick> [password]: rename off a session using a nick you own,
// either by being identified to its account or giving that account's password.
// Every argument is a distinct input the guest-rename needs (the guest nick and
// its sequence counter among them), so the count is inherent.
#[allow(clippy::too_many_arguments)]
pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
let Some(&target) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: GHOST <nick> [password]");
return;
};
let Some(account) = db.resolve_account(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
return;
};
let owns = from.account == Some(account.as_str()) || args.get(2).is_some_and(|pw| db.authenticate(target, pw).is_some());
if !owns {
ctx.notice(me, from.uid, format!("Access denied. Identify to \x02{account}\x02 or give its password."));
return;
}
let Some(ghost) = net.uid_by_nick(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("Nobody is using \x02{target}\x02."));
return;
};
if ghost == from.uid {
ctx.notice(me, from.uid, "That's you.");
return;
}
let guest = format!("{guest_nick}{guest_seq}");
*guest_seq = guest_seq.wrapping_add(1);
ctx.force_nick(&ghost, &guest);
ctx.notice(me, from.uid, format!("\x02{target}\x02 has been freed."));
}

View file

@ -0,0 +1,17 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// GLIST: list the nicks grouped to your account.
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
return;
};
let mut nicks = db.grouped_nicks(account);
nicks.sort();
ctx.notice(me, from.uid, format!("Nicks grouped to \x02{account}\x02:"));
ctx.notice(me, from.uid, format!(" \x02{account}\x02 (main)"));
for n in nicks {
ctx.notice(me, from.uid, format!(" \x02{n}\x02"));
}
}

View file

@ -0,0 +1,23 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// GROUP <account> <password>: link your current nick to an existing account, so
// you can identify to it under this nick too.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let (Some(&account), Some(&password)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: GROUP <account> <password>");
return;
};
let Some(canonical) = db.authenticate(account, password).map(str::to_string) else {
ctx.notice(me, from.uid, "Invalid account or password.");
return;
};
if db.account(from.nick).is_some() {
ctx.notice(me, from.uid, format!("\x02{}\x02 is itself a registered account.", from.nick));
return;
}
match db.group_nick(from.nick, &canonical) {
Ok(()) => ctx.notice(me, from.uid, format!("Your nick \x02{}\x02 is now grouped to \x02{canonical}\x02.", from.nick)),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,78 @@
use fedserv_api::{human_time, Store};
use fedserv_api::{Sender, ServiceCtx};
// IDENTIFY [account] <password>: log in. The account defaults to the current
// nick, so both the bare-password and account+password forms work.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let (account_name, password) = match (args.get(1), args.get(2)) {
(Some(account), Some(password)) => (*account, *password),
(Some(password), None) => (from.nick, *password),
_ => {
ctx.notice(me, from.uid, "Syntax: IDENTIFY [account] <password>");
return;
}
};
// Distinguish an unregistered account from a wrong password.
if !db.exists(account_name) {
ctx.notice(me, from.uid, format!("\x02{account_name}\x02 isn't registered."));
return;
}
// A suspended account can't be logged into (checked before the password so it
// doesn't reveal whether the password was right). Tell them who, when and why,
// and when it lifts, so they know what happened and who to reach.
if let Some(acc) = db.resolve_account(account_name).map(str::to_string) {
if db.is_suspended(&acc) {
if let Some(s) = db.suspension(&acc) {
let mut msg = format!("\x02{acc}\x02 is suspended, so you can't log in to it right now. It was suspended by \x02{}\x02 on {}", s.by, human_time(s.ts));
if s.reason.trim().is_empty() {
msg.push('.');
} else {
msg.push_str(&format!(" — reason: {}", s.reason));
}
if let Some(exp) = s.expires {
msg.push_str(&format!(" The suspension is due to lift on {}.", human_time(exp)));
}
msg.push_str(" If you think this is a mistake, please contact the network staff.");
ctx.notice(me, from.uid, msg);
}
return;
}
}
// Refuse while throttled, so a password can't be brute-forced.
if let Some(secs) = db.auth_lockout(account_name) {
ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again."));
return;
}
// Take the result as owned so the account-store borrow ends before note_auth.
match db.authenticate(account_name, password).map(str::to_string) {
Some(account) => {
db.note_auth(account_name, true);
// Already identified to this account: don't re-fire the login.
if from.account == Some(account.as_str()) {
ctx.notice(me, from.uid, format!("You're already identified as \x02{}\x02.", account));
return;
}
ctx.login(from.uid, &account);
ctx.count("nickserv.identify");
ctx.notice(me, from.uid, format!("You're now identified as \x02{}\x02. Welcome back!", account));
// Apply the account's auto-join list (AJOIN).
for entry in db.ajoin_list(&account) {
ctx.force_join(from.uid, &entry.channel, &entry.key);
}
// Apply the account's vhost (HostServ), if it has one.
if let Some(v) = db.vhost(&account) {
ctx.apply_vhost(from.uid, &v.host);
}
// Let them know about waiting memos.
let unread = db.unread_memos(&account);
if unread > 0 {
ctx.notice(me, from.uid, format!("You have \x02{unread}\x02 new memo(s). Read them with \x02/msg MemoServ READ NEW\x02."));
}
}
None => {
db.note_auth(account_name, false);
ctx.count("nickserv.identify_fail");
ctx.notice(me, from.uid, "Invalid password. Please try again.");
}
}
}

View file

@ -0,0 +1,40 @@
use fedserv_api::{human_time, Priv, Store};
use fedserv_api::{Sender, ServiceCtx};
// INFO [account]: show an account's registration details. The email and other
// private fields are shown to the account's own owner, or to an oper with the
// auspex privilege.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) {
let name = args.get(1).copied().unwrap_or(from.nick);
let Some(acct) = db.account(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
ctx.notice(me, from.uid, format!("Information for \x02{}\x02:", acct.name));
ctx.notice(me, from.uid, format!(" Registered : {}", human_time(acct.ts)));
// A greet is public — the bot shows it in-channel to everyone anyway.
if !acct.greet.is_empty() {
ctx.notice(me, from.uid, format!(" Greet : {}", acct.greet));
}
let is_owner = from.account == Some(acct.name.as_str());
if is_owner || from.privs.has(Priv::Auspex) {
if let Some(s) = db.suspension(&acct.name) {
ctx.notice(me, from.uid, format!(" Suspended : by \x02{}\x02{}", s.by, s.reason));
}
match &acct.email {
Some(email) if acct.verified => ctx.notice(me, from.uid, format!(" Email : {email}")),
Some(email) => ctx.notice(me, from.uid, format!(" Email : {email} (unconfirmed)")),
None => ctx.notice(me, from.uid, " Email : (none set)"),
}
let ajoin = db.ajoin_list(&acct.name);
if !ajoin.is_empty() {
ctx.notice(me, from.uid, format!(" Auto-join : {} channel(s) — see \x02AJOIN LIST\x02", ajoin.len()));
}
}
// A staff note is for operators' eyes only, never the account's owner.
if from.privs.has(Priv::Auspex) {
if let Some(note) = db.account_note(&acct.name) {
ctx.notice(me, from.uid, format!(" Staff note : {note}"));
}
}
}

View file

@ -0,0 +1,96 @@
use fedserv_api::Store;
use fedserv_api::{Sender, Service, ServiceCtx};
use fedserv_api::NetView;
#[path = "register.rs"]
mod register;
#[path = "identify.rs"]
mod identify;
#[path = "logout.rs"]
mod logout;
#[path = "cert.rs"]
mod cert;
#[path = "info.rs"]
mod info;
#[path = "alist.rs"]
mod alist;
#[path = "set.rs"]
mod set;
#[path = "drop.rs"]
mod drop;
#[path = "group.rs"]
mod group;
#[path = "glist.rs"]
mod glist;
#[path = "ungroup.rs"]
mod ungroup;
#[path = "ghost.rs"]
mod ghost;
#[path = "resetpass.rs"]
mod resetpass;
#[path = "confirm.rs"]
mod confirm;
#[path = "ajoin.rs"]
mod ajoin;
#[path = "suspend.rs"]
mod suspend;
mod noexpire;
#[path = "password.rs"]
mod password;
pub struct NickServ {
pub uid: String,
// Nick prefix assigned on LOGOUT (default "Guest"); a per-session sequence is
// appended so successive guests don't collide within a run.
pub guest_nick: String,
pub guest_seq: u32,
}
impl Service for NickServ {
fn nick(&self) -> &str {
"NickServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Nickname Services"
}
fn manages_accounts(&self) -> bool {
true
}
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
let me = self.uid.as_str();
let cmd = args.first().map(|s| s.to_ascii_uppercase());
// When identity is owned externally (the website), IRC can log in but not
// create or change an account — those commands are refused.
if db.external_accounts() && matches!(cmd.as_deref(), Some("REGISTER" | "DROP" | "RESETPASS" | "CONFIRM" | "CERT" | "GROUP" | "UNGROUP")) {
ctx.notice(me, from.uid, "Your account is managed on the website — register or change it there. From IRC you can only \x02IDENTIFY\x02.");
return;
}
match cmd.as_deref() {
Some("REGISTER") => register::handle(me, from, args, ctx),
Some("IDENTIFY") | Some("ID") => identify::handle(me, from, args, ctx, db),
Some("LOGOUT") | Some("LOGOFF") => logout::handle(me, &self.guest_nick, &mut self.guest_seq, from, ctx),
Some("CERT") => cert::handle(me, from, args, ctx, db),
Some("INFO") => info::handle(me, from, args, ctx, db),
Some("ALIST") => alist::handle(me, from, ctx, db),
Some("SET") => set::handle(me, from, args, ctx, db),
Some("DROP") => drop::handle(me, from, args, ctx, net, db),
Some("GROUP") => group::handle(me, from, args, ctx, db),
Some("GLIST") => glist::handle(me, from, ctx, db),
Some("UNGROUP") => ungroup::handle(me, from, args, ctx, db),
Some("GHOST") | Some("RECOVER") => ghost::handle(me, &self.guest_nick, &mut self.guest_seq, from, args, ctx, net, db),
Some("RESETPASS") => resetpass::handle(me, from, args, ctx, db),
Some("CONFIRM") => confirm::handle(me, from, args, ctx, db),
Some("AJOIN") => ajoin::handle(me, from, args, ctx, db),
Some("SUSPEND") => suspend::handle(me, from, args, ctx, net, db, true),
Some("UNSUSPEND") => suspend::handle(me, from, args, ctx, net, db, false),
Some("NOEXPIRE") => noexpire::handle(me, from, args, ctx, db),
Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 <password> [email], \x02IDENTIFY\x02 [account] <password>, \x02INFO\x02 [account], \x02ALIST\x02, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 <nick> [password], \x02SET\x02 PASSWORD|EMAIL, \x02AJOIN\x02 ADD|DEL|LIST, \x02RESETPASS\x02 <account>, \x02CONFIRM\x02 <code>, \x02DROP\x02 <password>, \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST <password> [fingerprint]. Operators also have \x02SUSPEND\x02/\x02UNSUSPEND\x02 and \x02NOEXPIRE\x02 <account> {ON|OFF}."),
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
None => {}
}
}
}

View file

@ -0,0 +1,14 @@
use fedserv_api::{Sender, ServiceCtx};
// LOGOUT: log out and rename to a guest nick (prefix + a per-logout sequence).
pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, ctx: &mut ServiceCtx) {
if from.account.is_none() {
ctx.notice(me, from.uid, "You're not logged in.");
return;
}
let guest = format!("{guest_nick}{guest_seq}");
*guest_seq = guest_seq.wrapping_add(1);
ctx.logout(from.uid);
ctx.force_nick(from.uid, &guest);
ctx.notice(me, from.uid, format!("You're now logged out. Your nick is now \x02{}\x02.", guest));
}

View file

@ -0,0 +1,33 @@
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
// NOEXPIRE <account> {ON|OFF}: pin an account so inactivity-expiry never drops
// it (or lift the pin). Oper-only (Priv::Admin) — protecting a record from
// expiry is a staff decision, not self-service.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — that command is for services operators.");
return;
}
let (Some(&target), Some(on)) = (args.get(1), args.get(2).and_then(|s| parse_toggle(s))) else {
ctx.notice(me, from.uid, "Syntax: NOEXPIRE <account> {ON|OFF}");
return;
};
let Some(account) = db.resolve_account(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
return;
};
match db.set_account_noexpire(&account, on) {
Ok(true) if on => ctx.notice(me, from.uid, format!("\x02{account}\x02 will no longer expire.")),
Ok(true) => ctx.notice(me, from.uid, format!("\x02{account}\x02 can expire from inactivity again.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{account}\x02 was already set that way.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn parse_toggle(s: &str) -> Option<bool> {
match s.to_ascii_uppercase().as_str() {
"ON" | "TRUE" | "YES" => Some(true),
"OFF" | "FALSE" | "NO" => Some(false),
_ => None,
}
}

View file

@ -0,0 +1,50 @@
// Password policy for REGISTER and SET PASSWORD: a minimum length and forbidding
// the password matching the nick, plus an upper bound so a huge password can't
// be used to burn CPU in the key
// derivation. Length is measured in characters, the cap in bytes (what the
// hasher actually processes).
pub const MIN_PASSWORD_CHARS: usize = 8;
pub const MAX_PASSWORD_BYTES: usize = 300;
// Validate a new password for the account/nick it will protect. On rejection,
// returns the message to show the user.
pub fn validate_password(password: &str, owner: &str) -> Result<(), &'static str> {
if password.chars().count() < MIN_PASSWORD_CHARS {
return Err("That password is too short — please use at least 8 characters.");
}
if password.len() > MAX_PASSWORD_BYTES {
return Err("That password is too long.");
}
if password.eq_ignore_ascii_case(owner) {
return Err("Your password can't be the same as your nick. Please choose another.");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_a_reasonable_password() {
assert!(validate_password("correct horse", "alice").is_ok());
}
#[test]
fn rejects_short_password() {
assert!(validate_password("short", "alice").is_err());
assert!(validate_password("1234567", "alice").is_err()); // 7 chars
assert!(validate_password("12345678", "alice").is_ok()); // 8 chars
}
#[test]
fn rejects_password_equal_to_nick() {
assert!(validate_password("Password", "password").is_err()); // case-insensitive
}
#[test]
fn rejects_overlong_password() {
assert!(validate_password(&"x".repeat(MAX_PASSWORD_BYTES + 1), "alice").is_err());
}
}

View file

@ -0,0 +1,21 @@
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::RegReply;
// REGISTER <password> [email]: register the sender's current nick. The engine
// derives the password off-thread, commits, and answers.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx) {
let Some(password) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: REGISTER <password> [email]");
return;
};
if let Err(reason) = crate::password::validate_password(password, from.nick) {
ctx.notice(me, from.uid, reason);
return;
}
let email = args.get(2).map(|s| s.to_string());
ctx.defer_register(from.nick, *password, email, RegReply::NickServ {
agent: me.to_string(),
uid: from.uid.to_string(),
nick: from.nick.to_string(),
});
}

View file

@ -0,0 +1,39 @@
use fedserv_api::{CodeKind, Store};
use fedserv_api::{Sender, ServiceCtx};
// RESETPASS <account>: email a reset code to the address on file.
// RESETPASS <account> <code> <newpassword>: complete the reset with that code.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !db.email_enabled() {
ctx.notice(me, from.uid, "Password reset by email isn't available here.");
return;
}
match (args.get(1), args.get(2), args.get(3)) {
(Some(&name), None, None) => {
let Some(canonical) = db.resolve_account(name).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
let Some(email) = db.account(&canonical).and_then(|a| a.email.clone()) else {
ctx.notice(me, from.uid, "That account has no email on file, so it can't be reset.");
return;
};
let code = db.issue_code(&canonical, CodeKind::Reset);
let mail = fedserv_api::email::reset(db.email_brand(), db.email_accent(), db.email_logo(), &canonical, &code);
ctx.send_email(email, mail.subject, mail.text, Some(mail.html));
ctx.notice(me, from.uid, format!("A reset code has been emailed to the address on file for \x02{canonical}\x02."));
}
(Some(&name), Some(&code), Some(&newpass)) => {
let Some(canonical) = db.resolve_account(name).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
if !db.take_code(&canonical, CodeKind::Reset, code) {
ctx.notice(me, from.uid, "Invalid or expired reset code.");
return;
}
ctx.defer_password(&canonical, newpass, me, from.uid);
}
_ => ctx.notice(me, from.uid, "Syntax: RESETPASS <account> [<code> <newpassword>]"),
}
}

View file

@ -0,0 +1,50 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// SET PASSWORD <newpassword> | SET EMAIL [address]: change your account settings.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
return;
};
let sub = args.get(1).map(|s| s.to_ascii_uppercase());
// Credential/identity fields are owned by the website in external mode.
if db.external_accounts() && matches!(sub.as_deref(), Some("PASSWORD" | "PASS" | "EMAIL")) {
ctx.notice(me, from.uid, "That's managed on the website — change it there.");
return;
}
match sub.as_deref() {
Some("PASSWORD") | Some("PASS") => {
let Some(&password) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword>");
return;
};
if let Err(reason) = crate::password::validate_password(password, account) {
ctx.notice(me, from.uid, reason);
return;
}
// The link layer derives the new password off-thread, then commits it.
ctx.defer_password(account, password, me, from.uid);
}
Some("EMAIL") => {
let email = args.get(2).map(|s| s.to_string());
let cleared = email.is_none();
match db.set_email(account, email) {
Ok(()) if cleared => ctx.notice(me, from.uid, format!("Email for \x02{account}\x02 cleared.")),
Ok(()) => ctx.notice(me, from.uid, format!("Email for \x02{account}\x02 updated.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
Some("GREET") => {
// A bot shows this when you join a greet-enabled channel; no arg clears it.
let greet = if args.len() > 2 { args[2..].join(" ") } else { String::new() };
let cleared = greet.is_empty();
match db.set_greet(account, &greet) {
Ok(()) if cleared => ctx.notice(me, from.uid, "Your greet has been cleared."),
Ok(()) => ctx.notice(me, from.uid, format!("Your greet is now: {greet}")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
_ => ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword> | SET EMAIL [address] | SET GREET [message]"),
}
}

View file

@ -0,0 +1,54 @@
use fedserv_api::{parse_duration, NetView, Priv, Sender, ServiceCtx, Store};
use std::time::{SystemTime, UNIX_EPOCH};
// SUSPEND <account> [+expiry] [reason] / UNSUSPEND <account>: freeze or unfreeze
// an account. Its data is kept but it can't be logged into. Oper-only
// (Priv::Suspend). `suspending` selects SUSPEND vs UNSUSPEND.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store, suspending: bool) {
if !from.privs.has(Priv::Suspend) {
ctx.notice(me, from.uid, "Access denied — that command is for services operators.");
return;
}
let Some(&target) = args.get(1) else {
let syntax = if suspending { "Syntax: SUSPEND <account> [+expiry] [reason]" } else { "Syntax: UNSUSPEND <account>" };
ctx.notice(me, from.uid, syntax);
return;
};
let Some(account) = db.resolve_account(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
return;
};
if !suspending {
match db.unsuspend_account(&account) {
Ok(true) => ctx.notice(me, from.uid, format!("\x02{account}\x02 is no longer suspended.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{account}\x02 isn't suspended.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
return;
}
// Optional leading +expiry (e.g. +30d), then a free-text reason.
let mut rest = &args[2..];
let expires = rest.first().and_then(|t| t.strip_prefix('+')).and_then(parse_duration).map(|secs| now_unix() + secs);
if expires.is_some() {
rest = &rest[1..];
}
let reason = if rest.is_empty() { "No reason given.".to_string() } else { rest.join(" ") };
let by = from.account.unwrap_or(from.nick);
match db.suspend_account(&account, by, &reason, expires) {
Ok(()) => {
// Log out every session currently identified to the account.
for uid in net.uids_logged_into(&account) {
ctx.logout(&uid);
}
let expiry = if expires.is_some() { " (with expiry)" } else { "" };
ctx.notice(me, from.uid, format!("\x02{account}\x02 is now suspended{expiry}."));
}
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn now_unix() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}

View file

@ -0,0 +1,24 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// UNGROUP [nick]: remove a nick grouped to your account (defaults to your current nick).
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
return;
};
let nick = args.get(1).copied().unwrap_or(from.nick);
if nick.eq_ignore_ascii_case(account) {
ctx.notice(me, from.uid, "You can't ungroup your main account name.");
return;
}
if db.resolve_account(nick).is_none_or(|a| !a.eq_ignore_ascii_case(account)) {
ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't grouped to your account."));
return;
}
match db.ungroup_nick(nick) {
Ok(true) => ctx.notice(me, from.uid, format!("\x02{nick}\x02 is no longer grouped to \x02{account}\x02.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't a grouped nick.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}