[feat](trx-frontend-http): add bookmark CRUD REST endpoints
GET /bookmarks — list all (optional ?category= filter); rx role
POST /bookmarks — create; control role enforced in handler
PUT /bookmarks/{id} — update; control role enforced in handler
DELETE /bookmarks/{id} — remove; control role enforced in handler
Auth middleware classifies /bookmarks and /bookmarks/* as Read so rx
users can reach GET; write handlers call require_control() to reject
lower-privileged sessions with 403.
Also serves bookmarks.js via GET /bookmarks.js.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::{get, post, web, HttpResponse, Responder};
|
||||
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse, Responder};
|
||||
use actix_web::{http::header, Error};
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream::{once, select, StreamExt};
|
||||
@@ -673,6 +673,129 @@ pub async fn clear_cw_decode(
|
||||
send_command(&rig_tx, RigCommand::ResetCwDecoder).await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bookmark CRUD endpoints
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BookmarkQuery {
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BookmarkInput {
|
||||
pub name: String,
|
||||
pub freq_hz: u64,
|
||||
pub mode: String,
|
||||
pub bandwidth_hz: Option<u64>,
|
||||
pub comment: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub decoders: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn require_control(
|
||||
req: &HttpRequest,
|
||||
auth_state: &crate::server::auth::AuthState,
|
||||
) -> Result<(), Error> {
|
||||
if !auth_state.config.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
match crate::server::auth::get_session_role(req, auth_state) {
|
||||
Some(crate::server::auth::AuthRole::Control) => Ok(()),
|
||||
_ => Err(actix_web::error::ErrorForbidden("control role required")),
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_bookmark_id() -> String {
|
||||
hex::encode(rand::random::<[u8; 16]>())
|
||||
}
|
||||
|
||||
#[get("/bookmarks")]
|
||||
pub async fn list_bookmarks(
|
||||
store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
|
||||
query: web::Query<BookmarkQuery>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let mut list = store.list();
|
||||
if let Some(ref cat) = query.category {
|
||||
if !cat.is_empty() {
|
||||
let cat_lower = cat.to_lowercase();
|
||||
list.retain(|bm| bm.category.to_lowercase() == cat_lower);
|
||||
}
|
||||
}
|
||||
list.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(HttpResponse::Ok().json(list))
|
||||
}
|
||||
|
||||
#[post("/bookmarks")]
|
||||
pub async fn create_bookmark(
|
||||
req: HttpRequest,
|
||||
store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
|
||||
body: web::Json<BookmarkInput>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_control(&req, &auth_state)?;
|
||||
let bm = crate::server::bookmarks::Bookmark {
|
||||
id: gen_bookmark_id(),
|
||||
name: body.name.clone(),
|
||||
freq_hz: body.freq_hz,
|
||||
mode: body.mode.clone(),
|
||||
bandwidth_hz: body.bandwidth_hz,
|
||||
comment: body.comment.clone().unwrap_or_default(),
|
||||
category: body.category.clone().unwrap_or_default(),
|
||||
decoders: body.decoders.clone().unwrap_or_default(),
|
||||
};
|
||||
if store.insert(&bm) {
|
||||
Ok(HttpResponse::Created().json(bm))
|
||||
} else {
|
||||
Err(actix_web::error::ErrorInternalServerError(
|
||||
"failed to save bookmark",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[put("/bookmarks/{id}")]
|
||||
pub async fn update_bookmark(
|
||||
req: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
|
||||
body: web::Json<BookmarkInput>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_control(&req, &auth_state)?;
|
||||
let id = path.into_inner();
|
||||
let bm = crate::server::bookmarks::Bookmark {
|
||||
id: id.clone(),
|
||||
name: body.name.clone(),
|
||||
freq_hz: body.freq_hz,
|
||||
mode: body.mode.clone(),
|
||||
bandwidth_hz: body.bandwidth_hz,
|
||||
comment: body.comment.clone().unwrap_or_default(),
|
||||
category: body.category.clone().unwrap_or_default(),
|
||||
decoders: body.decoders.clone().unwrap_or_default(),
|
||||
};
|
||||
if store.upsert(&id, &bm) {
|
||||
Ok(HttpResponse::Ok().json(bm))
|
||||
} else {
|
||||
Err(actix_web::error::ErrorNotFound("bookmark not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("/bookmarks/{id}")]
|
||||
pub async fn delete_bookmark(
|
||||
req: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_control(&req, &auth_state)?;
|
||||
let id = path.into_inner();
|
||||
if store.remove(&id) {
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true })))
|
||||
} else {
|
||||
Err(actix_web::error::ErrorNotFound("bookmark not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct RigListItem {
|
||||
rig_id: String,
|
||||
@@ -789,6 +912,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(clear_ft8_decode)
|
||||
.service(clear_wspr_decode)
|
||||
.service(select_rig)
|
||||
// Bookmark CRUD
|
||||
.service(list_bookmarks)
|
||||
.service(create_bookmark)
|
||||
.service(update_bookmark)
|
||||
.service(delete_bookmark)
|
||||
.service(crate::server::audio::audio_ws)
|
||||
.service(favicon)
|
||||
.service(favicon_png)
|
||||
@@ -799,6 +927,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(ft8_js)
|
||||
.service(wspr_js)
|
||||
.service(cw_js)
|
||||
.service(bookmarks_js)
|
||||
// Auth endpoints
|
||||
.service(crate::server::auth::login)
|
||||
.service(crate::server::auth::logout)
|
||||
@@ -890,6 +1019,16 @@ async fn cw_js() -> impl Responder {
|
||||
.body(status::CW_JS)
|
||||
}
|
||||
|
||||
#[get("/bookmarks.js")]
|
||||
async fn bookmarks_js() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
))
|
||||
.body(status::BOOKMARKS_JS)
|
||||
}
|
||||
|
||||
async fn send_command(
|
||||
rig_tx: &mpsc::Sender<RigRequest>,
|
||||
cmd: RigCommand,
|
||||
|
||||
@@ -425,12 +425,15 @@ impl RouteAccess {
|
||||
|| path == "/decode"
|
||||
|| path == "/spectrum"
|
||||
|| path == "/audio"
|
||||
|| path == "/bookmarks"
|
||||
|| path.starts_with("/status?")
|
||||
|| path.starts_with("/rigs?")
|
||||
|| path.starts_with("/events?")
|
||||
|| path.starts_with("/decode?")
|
||||
|| path.starts_with("/spectrum?")
|
||||
|| path.starts_with("/audio?")
|
||||
|| path.starts_with("/bookmarks?")
|
||||
|| path.starts_with("/bookmarks/")
|
||||
{
|
||||
return Self::Read;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js");
|
||||
pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
|
||||
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
|
||||
pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
|
||||
pub const BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js");
|
||||
|
||||
pub fn index_html() -> String {
|
||||
INDEX_HTML
|
||||
|
||||
Reference in New Issue
Block a user