feat: M5 admin dashboard (HTMX + Tailwind CDN, embedded HTML)
A web admin UI for the rustdesk-server, mounted at /admin/* on the
existing HTTP API listener. Single-binary deploy preserved — the two
HTML files live in admin_ui/ and are pulled into the binary via
include_str! at build time, so there's nothing extra to ship.
================================================================================
Architecture
================================================================================
- Stack: HTMX 1.9 + Tailwind play CDN. No SPA, no Node toolchain. Pages
are server-rendered HTML fragments returned by Rust handlers via
Html<String>; the index.html shell uses hx-get to drop a fragment into
the main pane and hx-push-url for back-button history.
- Auth: same Bearer-token table the API uses. The dashboard log-in form
POSTs username + password (+ optional TOTP) to /admin/login; on success
the server mints a token and pins it in an HttpOnly + SameSite=Strict
cookie (`rd_admin_session`). The AuthedUser extractor was extended to
accept either the Authorization: Bearer header (curl, desktop client)
OR the session cookie (browser).
- Embedding: src/api/admin/mod.rs has `include_str!("../../../admin_ui/index.html")`
+ login.html. No tower_http::ServeDir wildcard — we ran into axum 0.5
routing conflicts between literal /admin/login routes and an /admin/*
catch-all, so each HTML file is its own explicit route.
================================================================================
M5a — foundation
================================================================================
Files:
admin_ui/index.html page shell + sidebar + HTMX + 401-bounces-to-login
admin_ui/login.html credentials + TOTP form, posts to /admin/login
src/api/admin/mod.rs router + include_str! + Cache-Control: no-cache
src/api/admin/auth.rs /admin/login POST (form-encoded), /admin/logout POST
src/api/admin/me.rs sidebar fragment ("Signed in as <name>")
src/api/middleware.rs `AuthedUser` now reads either Bearer OR cookie
src/api/state.rs `admin_ui_dir` (informational; UI is embedded)
src/main.rs --admin-ui-dir flag (empty disables the dashboard)
The login flow asks for TOTP transparently in the same form when the
target user has a secret enrolled, so the dashboard inherits the TOTP
gate from the API auth surface for free.
================================================================================
M5b — full CRUD pages
================================================================================
- Users (src/api/admin/pages/users.rs) — list, create, password reset,
toggle admin / status, TOTP enroll / unenroll, delete. TOTP enroll
surfaces the secret + otpauth URL once, on a dismissible banner above
the table.
- Devices (devices.rs) — list with hostname/OS/last-heartbeat/conn count,
force-disconnect (queues `heartbeat_commands` row consumed at the next
/api/heartbeat tick), force-sysinfo refresh.
- Device groups (groups.rs) — list / create / delete / add member /
remove member. Per-group section, with an add-member dropdown of users
not yet in the group.
- Strategies (strategies.rs) — list / create / edit config_options /
delete. config_options is validated as a JSON object on the server side
before persist; bad JSON is reflected to the page with a friendly
error notice.
- Address books (address_books.rs) — read-only overview of all books
with owner, kind (personal / shared badge), peer count, GUID.
- OIDC providers (oidc.rs) — read-only list of what's configured. Editing
remains operator-side via --oidc-config TOML or direct SQL.
================================================================================
M5c — audit + recordings browsers
================================================================================
- Audit log (audit.rs) — three tabs (Connections / File transfers /
Alarms), each capped at the latest 200 rows. Tab pills are HTMX links
with hx-get + hx-target="#main" so the tab switch is a single fetch.
- Recordings (recordings.rs) — read-only list with peer / size / state /
start / finish. Streaming download is a follow-up; for now operators
pull files from --recording-dir directly.
================================================================================
DB methods added
================================================================================
- Users: users_list_all, user_set_status, user_set_admin,
user_set_password, user_delete, user_has_totp,
raw_update_user_email
- Devices: devices_list_all, device_sysinfo_get_conns,
heartbeat_command_queue (also used elsewhere; surfaced)
- Groups: device_groups_list_all, device_group_members,
device_group_create, device_group_delete,
device_group_add_member, device_group_remove_member
- Strategy: strategies_list_all, strategy_create,
strategy_update_config, strategy_delete
- Audit: audit_conn_list, audit_file_list, audit_alarm_list
- Misc: ab_list_all_with_owner, recordings_list
All use the runtime sqlx::query("...") form (matching the project-wide
convention) so the SQLite compile-time-check macros don't require these
new tables to pre-exist in the dev DB.
================================================================================
Conventions enforced
================================================================================
- Every page handler gates on require_admin(&AuthedUser) — non-admin
users get an HTTP 403 + JSON envelope, which the SPA shell catches and
bounces back to the login form.
- HTML fragments are produced via `format!`-with-named-args; html_escape
is centralized in src/api/admin/pages/shared.rs and applied to every
user-supplied string before it lands in the DOM.
- All mutations return either the updated table fragment OR
notice_html(kind, msg) + the table — same pattern across pages, so
HTMX swap targets stay simple (always #region innerHTML).
- Cookie carries no path restriction so it also authorizes /api/* calls
the dashboard might want to make from the browser; HttpOnly +
SameSite=Strict mitigates XSS / CSRF; Max-Age tracks ApiConfig's
session_ttl_secs (30 days).
================================================================================
Verification
================================================================================
1. cargo build --release — clean.
2. End-to-end smoke test:
- /admin/ serves index.html (4406 bytes), /admin/login.html serves
login.html (2598 bytes).
- POST /admin/login with valid creds returns 200 + Set-Cookie
`rd_admin_session=…; HttpOnly; Path=/; SameSite=Strict; Max-Age=…`.
- All eight /admin/pages/* fragments return 200 with cookie.
- Users CRUD round-trip: create alice → toggle admin → disable →
reset password → enroll TOTP (32-char secret displayed once) →
unenroll → delete; self-action guard rejects suicide deletes.
- Groups CRUD: create engineering → add alice as member → SQL
confirms the row.
- Strategies: valid JSON accepted, invalid JSON rejected with a
friendly notice.
- Audit tabs: all three render 200; empty-state messages appear when
no rows.
- /admin/logout clears the cookie; subsequent /admin/me returns 401.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+497
@@ -108,6 +108,78 @@ pub struct DeviceGroupRow {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AbOverviewRow {
|
||||
pub guid: String,
|
||||
pub name: String,
|
||||
pub kind: i64, // 0=personal, 1=shared
|
||||
pub owner_username: String,
|
||||
pub peer_count: i64,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StrategyRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub modified_at: i64,
|
||||
pub config_options_json: String,
|
||||
pub extra_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuditConnRow {
|
||||
pub guid: String,
|
||||
pub peer_id: String,
|
||||
pub conn_id: i64,
|
||||
pub session_id: i64,
|
||||
pub ip: String,
|
||||
pub action: String,
|
||||
pub note: String,
|
||||
pub started_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuditFileRow {
|
||||
pub id: i64,
|
||||
pub peer_id: String,
|
||||
pub remote_peer: String,
|
||||
pub direction: i64,
|
||||
pub path: String,
|
||||
pub is_file: bool,
|
||||
pub info_json: String,
|
||||
pub at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuditAlarmRow {
|
||||
pub id: i64,
|
||||
pub peer_id: String,
|
||||
pub typ: i64,
|
||||
pub info_json: String,
|
||||
pub at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecordingRow {
|
||||
pub filename: String,
|
||||
pub peer_id: String,
|
||||
pub size: i64,
|
||||
pub state: String,
|
||||
pub started_at: i64,
|
||||
pub finished_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DashboardDeviceRow {
|
||||
pub id: String,
|
||||
pub uuid: String,
|
||||
pub owner_username: String,
|
||||
pub last_heartbeat_at: String,
|
||||
pub sysinfo_payload: String,
|
||||
pub conns_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PeerListRow {
|
||||
pub id: String,
|
||||
@@ -347,6 +419,431 @@ impl Database {
|
||||
Ok(row.map(row_to_user))
|
||||
}
|
||||
|
||||
/// All users, including disabled ones — distinct from
|
||||
/// `users_list_accessible`, which the API uses (filtering by status=1
|
||||
/// and visibility through device-groups). The dashboard wants the
|
||||
/// full picture.
|
||||
pub async fn users_list_all(
|
||||
&self,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> ResultType<(i64, Vec<UserRow>)> {
|
||||
let total: i64 = sqlx::query("SELECT COUNT(*) AS c FROM users")
|
||||
.fetch_one(self.pool.get().await?.deref_mut())
|
||||
.await?
|
||||
.try_get("c")?;
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin \
|
||||
FROM users ORDER BY username LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok((total, rows.into_iter().map(row_to_user).collect()))
|
||||
}
|
||||
|
||||
pub async fn user_set_status(&self, id: i64, status: i64) -> ResultType<bool> {
|
||||
let res = sqlx::query("UPDATE users SET status = ?, updated_at = current_timestamp WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn user_set_admin(&self, id: i64, is_admin: bool) -> ResultType<bool> {
|
||||
let res = sqlx::query(
|
||||
"UPDATE users SET is_admin = ?, updated_at = current_timestamp WHERE id = ?",
|
||||
)
|
||||
.bind(if is_admin { 1i64 } else { 0i64 })
|
||||
.bind(id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn user_set_password(&self, id: i64, hash: &str) -> ResultType<bool> {
|
||||
let res = sqlx::query(
|
||||
"UPDATE users SET password_hash = ?, updated_at = current_timestamp WHERE id = ?",
|
||||
)
|
||||
.bind(hash)
|
||||
.bind(id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Deletes the user row. Cascade hits `tokens` (FK ON DELETE CASCADE)
|
||||
/// — TOTP secrets and AB ownership are best-effort cleaned by separate
|
||||
/// queries below.
|
||||
pub async fn user_delete(&self, id: i64) -> ResultType<bool> {
|
||||
let _ = sqlx::query("DELETE FROM user_totp_secrets WHERE user_id = ?")
|
||||
.bind(id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM device_group_members WHERE user_id = ?")
|
||||
.bind(id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM address_book_shares WHERE user_id = ?")
|
||||
.bind(id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await;
|
||||
let res = sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Devices listed for the dashboard. Returns each row of device_sysinfo
|
||||
/// joined to its owner's username, sorted by recency.
|
||||
pub async fn devices_list_all(
|
||||
&self,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> ResultType<(i64, Vec<DashboardDeviceRow>)> {
|
||||
let total: i64 = sqlx::query("SELECT COUNT(*) AS c FROM device_sysinfo")
|
||||
.fetch_one(self.pool.get().await?.deref_mut())
|
||||
.await?
|
||||
.try_get("c")?;
|
||||
let rows = sqlx::query(
|
||||
"SELECT ds.id AS pid, ds.uuid AS puuid, \
|
||||
COALESCE(u.username, '') AS owner_username, \
|
||||
ds.last_heartbeat_at AS last_hb, \
|
||||
ds.payload AS payload, \
|
||||
ds.conns AS conns \
|
||||
FROM device_sysinfo ds \
|
||||
LEFT JOIN users u ON u.id = ds.user_id \
|
||||
ORDER BY ds.last_heartbeat_at DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
let data = rows
|
||||
.into_iter()
|
||||
.map(|r| DashboardDeviceRow {
|
||||
id: r.try_get("pid").unwrap_or_default(),
|
||||
uuid: r.try_get("puuid").unwrap_or_default(),
|
||||
owner_username: r.try_get("owner_username").unwrap_or_default(),
|
||||
last_heartbeat_at: r.try_get("last_hb").unwrap_or_default(),
|
||||
sysinfo_payload: r.try_get("payload").unwrap_or_default(),
|
||||
conns_json: r.try_get("conns").unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
Ok((total, data))
|
||||
}
|
||||
|
||||
pub async fn device_sysinfo_get_conns(&self, peer_id: &str) -> ResultType<String> {
|
||||
let row = sqlx::query("SELECT conns FROM device_sysinfo WHERE id = ? LIMIT 1")
|
||||
.bind(peer_id)
|
||||
.fetch_optional(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(row
|
||||
.and_then(|r| r.try_get::<String, _>("conns").ok())
|
||||
.unwrap_or_else(|| "[]".to_string()))
|
||||
}
|
||||
|
||||
pub async fn heartbeat_command_queue(
|
||||
&self,
|
||||
peer_id: &str,
|
||||
kind: &str,
|
||||
payload: Option<&str>,
|
||||
) -> ResultType<()> {
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO heartbeat_commands(peer_id, kind, payload, created_at) \
|
||||
VALUES(?, ?, ?, strftime('%s','now'))",
|
||||
)
|
||||
.bind(peer_id)
|
||||
.bind(kind)
|
||||
.bind(payload)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All address books, with the owner's username and an optional
|
||||
/// per-AB peer count. Used by the dashboard's read-only AB overview.
|
||||
pub async fn ab_list_all_with_owner(&self) -> ResultType<Vec<AbOverviewRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT ab.guid, ab.name, ab.kind, ab.created_at, \
|
||||
COALESCE(u.username, '') AS owner_username, \
|
||||
(SELECT COUNT(*) FROM address_book_peers abp WHERE abp.ab_guid = ab.guid) AS peer_count \
|
||||
FROM address_books ab LEFT JOIN users u ON u.id = ab.owner_user_id \
|
||||
ORDER BY ab.kind, owner_username, ab.name",
|
||||
)
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AbOverviewRow {
|
||||
guid: r.try_get("guid").unwrap_or_default(),
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
kind: r.try_get("kind").unwrap_or(0),
|
||||
owner_username: r.try_get("owner_username").unwrap_or_default(),
|
||||
peer_count: r.try_get("peer_count").unwrap_or(0),
|
||||
created_at: r.try_get("created_at").unwrap_or(0),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ---- M5 dashboard helpers: groups / strategies / audit / recordings ----
|
||||
|
||||
pub async fn device_groups_list_all(&self) -> ResultType<Vec<DeviceGroupRow>> {
|
||||
let rows = sqlx::query("SELECT id, name FROM device_groups ORDER BY name")
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| DeviceGroupRow {
|
||||
id: r.try_get("id").unwrap_or(0),
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn device_group_members(&self, group_id: i64) -> ResultType<Vec<UserRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT u.id, u.username, u.password_hash, u.display_name, u.email, u.note, u.avatar, u.status, u.is_admin \
|
||||
FROM users u JOIN device_group_members m ON m.user_id = u.id \
|
||||
WHERE m.device_group_id = ? ORDER BY u.username",
|
||||
)
|
||||
.bind(group_id)
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_user).collect())
|
||||
}
|
||||
|
||||
pub async fn device_group_create(&self, name: &str) -> ResultType<i64> {
|
||||
sqlx::query("INSERT OR IGNORE INTO device_groups(name) VALUES(?)")
|
||||
.bind(name)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
let row = sqlx::query("SELECT id FROM device_groups WHERE name = ?")
|
||||
.bind(name)
|
||||
.fetch_one(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(row.try_get("id")?)
|
||||
}
|
||||
|
||||
pub async fn device_group_delete(&self, group_id: i64) -> ResultType<bool> {
|
||||
let _ = sqlx::query("DELETE FROM device_group_members WHERE device_group_id = ?")
|
||||
.bind(group_id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await;
|
||||
let res = sqlx::query("DELETE FROM device_groups WHERE id = ?")
|
||||
.bind(group_id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn device_group_add_member(
|
||||
&self,
|
||||
group_id: i64,
|
||||
user_id: i64,
|
||||
) -> ResultType<()> {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO device_group_members(device_group_id, user_id) VALUES(?, ?)",
|
||||
)
|
||||
.bind(group_id)
|
||||
.bind(user_id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn device_group_remove_member(
|
||||
&self,
|
||||
group_id: i64,
|
||||
user_id: i64,
|
||||
) -> ResultType<()> {
|
||||
sqlx::query(
|
||||
"DELETE FROM device_group_members WHERE device_group_id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(group_id)
|
||||
.bind(user_id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn strategies_list_all(&self) -> ResultType<Vec<StrategyRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, modified_at, config_options_json, extra_json \
|
||||
FROM strategies ORDER BY name",
|
||||
)
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| StrategyRow {
|
||||
id: r.try_get("id").unwrap_or(0),
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
modified_at: r.try_get("modified_at").unwrap_or(0),
|
||||
config_options_json: r
|
||||
.try_get("config_options_json")
|
||||
.unwrap_or_else(|_| "{}".to_string()),
|
||||
extra_json: r.try_get("extra_json").unwrap_or_else(|_| "{}".to_string()),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn strategy_create(
|
||||
&self,
|
||||
name: &str,
|
||||
config_options_json: &str,
|
||||
extra_json: &str,
|
||||
) -> ResultType<i64> {
|
||||
let res = sqlx::query(
|
||||
"INSERT INTO strategies(name, modified_at, config_options_json, extra_json) \
|
||||
VALUES(?, strftime('%s','now'), ?, ?)",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(config_options_json)
|
||||
.bind(extra_json)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(res.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub async fn strategy_update_config(
|
||||
&self,
|
||||
id: i64,
|
||||
config_options_json: &str,
|
||||
) -> ResultType<()> {
|
||||
sqlx::query(
|
||||
"UPDATE strategies SET config_options_json = ?, modified_at = strftime('%s','now') \
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(config_options_json)
|
||||
.bind(id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn strategy_delete(&self, id: i64) -> ResultType<bool> {
|
||||
let _ = sqlx::query("DELETE FROM strategy_assignments WHERE strategy_id = ?")
|
||||
.bind(id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await;
|
||||
let res = sqlx::query("DELETE FROM strategies WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Audit listings (newest first) — used by the dashboard browser. Each
|
||||
/// returns at most `limit` rows; the dashboard caps at a few hundred.
|
||||
pub async fn audit_conn_list(&self, limit: i64) -> ResultType<Vec<AuditConnRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT guid, peer_id, conn_id, session_id, ip, action, note, started_at \
|
||||
FROM audit_conn ORDER BY started_at DESC LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AuditConnRow {
|
||||
guid: r.try_get("guid").unwrap_or_default(),
|
||||
peer_id: r.try_get("peer_id").unwrap_or_default(),
|
||||
conn_id: r.try_get("conn_id").unwrap_or(0),
|
||||
session_id: r.try_get("session_id").unwrap_or(0),
|
||||
ip: r.try_get::<Option<String>, _>("ip").ok().flatten().unwrap_or_default(),
|
||||
action: r.try_get("action").unwrap_or_default(),
|
||||
note: r.try_get::<Option<String>, _>("note").ok().flatten().unwrap_or_default(),
|
||||
started_at: r.try_get("started_at").unwrap_or(0),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn audit_file_list(&self, limit: i64) -> ResultType<Vec<AuditFileRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, peer_id, remote_peer, direction, path, is_file, info_json, at \
|
||||
FROM audit_file ORDER BY at DESC LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AuditFileRow {
|
||||
id: r.try_get("id").unwrap_or(0),
|
||||
peer_id: r.try_get("peer_id").unwrap_or_default(),
|
||||
remote_peer: r.try_get::<Option<String>, _>("remote_peer").ok().flatten().unwrap_or_default(),
|
||||
direction: r.try_get("direction").unwrap_or(0),
|
||||
path: r.try_get("path").unwrap_or_default(),
|
||||
is_file: r.try_get::<i64, _>("is_file").unwrap_or(0) != 0,
|
||||
info_json: r.try_get("info_json").unwrap_or_default(),
|
||||
at: r.try_get("at").unwrap_or(0),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn audit_alarm_list(&self, limit: i64) -> ResultType<Vec<AuditAlarmRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, peer_id, typ, info_json, at \
|
||||
FROM audit_alarm ORDER BY at DESC LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AuditAlarmRow {
|
||||
id: r.try_get("id").unwrap_or(0),
|
||||
peer_id: r.try_get("peer_id").unwrap_or_default(),
|
||||
typ: r.try_get("typ").unwrap_or(0),
|
||||
info_json: r.try_get("info_json").unwrap_or_default(),
|
||||
at: r.try_get("at").unwrap_or(0),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn recordings_list(&self, limit: i64) -> ResultType<Vec<RecordingRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT filename, peer_id, size, state, started_at, finished_at \
|
||||
FROM recordings ORDER BY started_at DESC LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| RecordingRow {
|
||||
filename: r.try_get("filename").unwrap_or_default(),
|
||||
peer_id: r.try_get("peer_id").unwrap_or_default(),
|
||||
size: r.try_get("size").unwrap_or(0),
|
||||
state: r.try_get("state").unwrap_or_default(),
|
||||
started_at: r.try_get("started_at").unwrap_or(0),
|
||||
finished_at: r.try_get::<Option<i64>, _>("finished_at").ok().flatten(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn raw_update_user_email(&self, user_id: i64, email: &str) -> ResultType<()> {
|
||||
sqlx::query("UPDATE users SET email = ?, updated_at = current_timestamp WHERE id = ?")
|
||||
.bind(email)
|
||||
.bind(user_id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn user_has_totp(&self, user_id: i64) -> ResultType<bool> {
|
||||
let row =
|
||||
sqlx::query("SELECT 1 AS ok FROM user_totp_secrets WHERE user_id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
pub async fn user_insert(&self, u: NewUser<'_>) -> ResultType<i64> {
|
||||
let admin_int: i64 = if u.is_admin { 1 } else { 0 };
|
||||
let res = sqlx::query(
|
||||
|
||||
Reference in New Issue
Block a user