[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::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
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 actix_web::{http::header, Error};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures_util::stream::{once, select, StreamExt};
|
use futures_util::stream::{once, select, StreamExt};
|
||||||
@@ -673,6 +673,129 @@ pub async fn clear_cw_decode(
|
|||||||
send_command(&rig_tx, RigCommand::ResetCwDecoder).await
|
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)]
|
#[derive(serde::Serialize)]
|
||||||
struct RigListItem {
|
struct RigListItem {
|
||||||
rig_id: String,
|
rig_id: String,
|
||||||
@@ -789,6 +912,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(clear_ft8_decode)
|
.service(clear_ft8_decode)
|
||||||
.service(clear_wspr_decode)
|
.service(clear_wspr_decode)
|
||||||
.service(select_rig)
|
.service(select_rig)
|
||||||
|
// Bookmark CRUD
|
||||||
|
.service(list_bookmarks)
|
||||||
|
.service(create_bookmark)
|
||||||
|
.service(update_bookmark)
|
||||||
|
.service(delete_bookmark)
|
||||||
.service(crate::server::audio::audio_ws)
|
.service(crate::server::audio::audio_ws)
|
||||||
.service(favicon)
|
.service(favicon)
|
||||||
.service(favicon_png)
|
.service(favicon_png)
|
||||||
@@ -799,6 +927,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(ft8_js)
|
.service(ft8_js)
|
||||||
.service(wspr_js)
|
.service(wspr_js)
|
||||||
.service(cw_js)
|
.service(cw_js)
|
||||||
|
.service(bookmarks_js)
|
||||||
// Auth endpoints
|
// Auth endpoints
|
||||||
.service(crate::server::auth::login)
|
.service(crate::server::auth::login)
|
||||||
.service(crate::server::auth::logout)
|
.service(crate::server::auth::logout)
|
||||||
@@ -890,6 +1019,16 @@ async fn cw_js() -> impl Responder {
|
|||||||
.body(status::CW_JS)
|
.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(
|
async fn send_command(
|
||||||
rig_tx: &mpsc::Sender<RigRequest>,
|
rig_tx: &mpsc::Sender<RigRequest>,
|
||||||
cmd: RigCommand,
|
cmd: RigCommand,
|
||||||
|
|||||||
@@ -425,12 +425,15 @@ impl RouteAccess {
|
|||||||
|| path == "/decode"
|
|| path == "/decode"
|
||||||
|| path == "/spectrum"
|
|| path == "/spectrum"
|
||||||
|| path == "/audio"
|
|| path == "/audio"
|
||||||
|
|| path == "/bookmarks"
|
||||||
|| path.starts_with("/status?")
|
|| path.starts_with("/status?")
|
||||||
|| path.starts_with("/rigs?")
|
|| path.starts_with("/rigs?")
|
||||||
|| path.starts_with("/events?")
|
|| path.starts_with("/events?")
|
||||||
|| path.starts_with("/decode?")
|
|| path.starts_with("/decode?")
|
||||||
|| path.starts_with("/spectrum?")
|
|| path.starts_with("/spectrum?")
|
||||||
|| path.starts_with("/audio?")
|
|| path.starts_with("/audio?")
|
||||||
|
|| path.starts_with("/bookmarks?")
|
||||||
|
|| path.starts_with("/bookmarks/")
|
||||||
{
|
{
|
||||||
return Self::Read;
|
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 FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
|
||||||
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.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 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 {
|
pub fn index_html() -> String {
|
||||||
INDEX_HTML
|
INDEX_HTML
|
||||||
|
|||||||
Reference in New Issue
Block a user