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:
parent
6f76f9722c
commit
ad2a623120
116 changed files with 69 additions and 46 deletions
63
modules/nickserv/src/ajoin.rs
Normal file
63
modules/nickserv/src/ajoin.rs
Normal 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.")),
|
||||
}
|
||||
}
|
||||
27
modules/nickserv/src/alist.rs
Normal file
27
modules/nickserv/src/alist.rs
Normal 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})"));
|
||||
}
|
||||
}
|
||||
59
modules/nickserv/src/cert.rs
Normal file
59
modules/nickserv/src/cert.rs
Normal 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]"),
|
||||
}
|
||||
}
|
||||
26
modules/nickserv/src/confirm.rs
Normal file
26
modules/nickserv/src/confirm.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
33
modules/nickserv/src/drop.rs
Normal file
33
modules/nickserv/src/drop.rs
Normal 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(", ")));
|
||||
}
|
||||
}
|
||||
36
modules/nickserv/src/ghost.rs
Normal file
36
modules/nickserv/src/ghost.rs
Normal 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."));
|
||||
}
|
||||
17
modules/nickserv/src/glist.rs
Normal file
17
modules/nickserv/src/glist.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
23
modules/nickserv/src/group.rs
Normal file
23
modules/nickserv/src/group.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
78
modules/nickserv/src/identify.rs
Normal file
78
modules/nickserv/src/identify.rs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
40
modules/nickserv/src/info.rs
Normal file
40
modules/nickserv/src/info.rs
Normal 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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
96
modules/nickserv/src/lib.rs
Normal file
96
modules/nickserv/src/lib.rs
Normal 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 => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
modules/nickserv/src/logout.rs
Normal file
14
modules/nickserv/src/logout.rs
Normal 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));
|
||||
}
|
||||
33
modules/nickserv/src/noexpire.rs
Normal file
33
modules/nickserv/src/noexpire.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
50
modules/nickserv/src/password.rs
Normal file
50
modules/nickserv/src/password.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
21
modules/nickserv/src/register.rs
Normal file
21
modules/nickserv/src/register.rs
Normal 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(),
|
||||
});
|
||||
}
|
||||
39
modules/nickserv/src/resetpass.rs
Normal file
39
modules/nickserv/src/resetpass.rs
Normal 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>]"),
|
||||
}
|
||||
}
|
||||
50
modules/nickserv/src/set.rs
Normal file
50
modules/nickserv/src/set.rs
Normal 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]"),
|
||||
}
|
||||
}
|
||||
54
modules/nickserv/src/suspend.rs
Normal file
54
modules/nickserv/src/suspend.rs
Normal 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)
|
||||
}
|
||||
24
modules/nickserv/src/ungroup.rs
Normal file
24
modules/nickserv/src/ungroup.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue