diff --git a/scripts/TEST.html b/scripts/TEST.html
index 2070094d09..8ecf37a3c8 100644
--- a/scripts/TEST.html
+++ b/scripts/TEST.html
@@ -99,6 +99,7 @@
// let ws = new WebSocket(`ws://localhost:8095/ws`);
+
ws.onopen = () => {
output.textContent = "✅ WebSocket connected.";
};
@@ -122,6 +123,7 @@
} catch (e) {
output.textContent += "\n\n⚠️ Invalid JSON:\n" + e.message;
}
+
}
function place(event) { textarea.value = (event || window.event).target.getAttribute("data"); sendMessage(); }
diff --git a/scripts/TEST_HTTP_API.sh b/scripts/TEST_HTTP_API.sh
index 563d21de2f..b04a1fa74f 100755
--- a/scripts/TEST_HTTP_API.sh
+++ b/scripts/TEST_HTTP_API.sh
@@ -11,6 +11,19 @@ TOKEN=$(./token.sh claims.json)
ZP="00000000-0000-0000-0000-000000000001/TESTS"
+echo "--------- authorization_test ----------"
+TOKEN=""
+ put "00000000-0000-0000-0000-000000000001/TESTS" "Value_1" "HULY-TTL: 2"
+TOKEN=$(./token.sh claims_system.json)
+ put "00000000-0000-0000-0000-000000000001/TESTS" "Value_1" "HULY-TTL: 2"
+TOKEN=$(./token.sh claims_wrong_ws.json)
+ put "00000000-0000-0000-0000-000000000001/TESTS" "Value_1" "HULY-TTL: 2"
+TOKEN=$(./token.sh claims.json)
+ put "00000000-0000-0000-0000-000000000002/TESTS" "Value_1" "HULY-TTL: 2"
+exit
+
+
+
echo "--------- if-match ----------"
put "00000000-0000-0000-0000-000000000001/TESTS" "Value_1" "HULY-TTL: 2"
@@ -23,106 +36,6 @@ echo "--------- if-match ----------"
get "00000000-0000-0000-0000-000000000001/TESTS/3/secret$/"
-exit
-Key
-
-
-
-
-
-Key is a string that consists of one or multiple segments separated by ‘/’. Example: foo/bar/baz.
-
-
-
-
-
-Segment may not contain special characters (‘$’, ‘*’, ‘?’)
-
-
-
-Key may not end with ‘/’
-
-
-
-Segment may not be empty
-
-
-
-Key segment may be private (prefixed with ‘$’)
-
-
-
-Query
-
-
-
-
-
-May not contain special characters (‘*’, ‘?’)
-
-
-
-It is possible to use prefix, for listings / subscriptions (prefix ends with segment separator ‘/’)
-
-
-
-
-
-GET/SUBSCRIBE/.. a/b → single key
-
-
-
-GET/SUBSCRIBE/.. a/b/c/ → multiple
-
-
-
-If multiple
-
-
-
-
-
-select all keys starting with prefix
-
-
-
-skip keys, containing private segments to the right from the prefix
-
-
-
-example
-
-
-
-
-
- 1. /a/b/$c/$d, 2. /a/b/c, 3. /a/b/$c, 4. /a/b/$c/$d/e
-
-
-
-/ → [2]
-
-
-
-/a/b/ → [2]
-
-
-
-/a/b/$c/ → [3]
-
-
-
-/a/b/$c/$d/ → [4]
-
-
-
-/a/b/$c/$d → (1)
-
-
-
-
-
-
exit
diff --git a/src/handlers_http.rs b/src/handlers_http.rs
index 38463dc934..16ff8ace0e 100644
--- a/src/handlers_http.rs
+++ b/src/handlers_http.rs
@@ -1,55 +1,61 @@
-use redis::aio::MultiplexedConnection;
-use std::sync::Arc;
-use std::collections::HashMap;
-use tokio::sync::Mutex;
+//
+// Copyright © 2025 Hardcore Engineering Inc.
+//
+// Licensed under the Eclipse Public License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License. You may
+// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+use crate::workspace_owner::workspace_check;
use anyhow::anyhow;
+use redis::aio::MultiplexedConnection;
+use std::collections::HashMap;
+use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
+use tokio::sync::Mutex;
use tracing::{error, trace};
use uuid::Uuid;
-use crate::workspace_owner::workspace_check;
use crate::redis_lib::{
- Ttl, SaveMode,
- RedisArray,
- redis_save,
- redis_read,
- redis_delete,
- redis_list,
+ RedisArray, SaveMode, Ttl, redis_delete, redis_list, redis_read, redis_save,
};
use actix_web::{
- HttpRequest, HttpResponse, error, Error,
+ Error, HttpRequest, HttpResponse, error,
web::{self, Data, Json, Query},
};
-
pub fn map_handler_error(err: impl std::fmt::Display) -> Error {
-
let msg = err.to_string();
if let Some(detail) = msg.split(" - ExtensionError: ").nth(1) {
if let Some((code, text)) = detail.split_once(": ") {
- let text = format!("{} {}", code, text);
+ let text = format!("{} {}", code, text);
return match code {
"400" => actix_web::error::ErrorBadRequest(text),
"404" => actix_web::error::ErrorNotFound(text),
"412" => actix_web::error::ErrorPreconditionFailed(text),
"500" => actix_web::error::ErrorInternalServerError(text),
- _ => actix_web::error::ErrorInternalServerError("unexpected error"),
+ _ => actix_web::error::ErrorInternalServerError("unexpected error"),
};
}
}
actix_web::error::ErrorInternalServerError("internal error")
}
-
/// list
pub async fn list(
req: HttpRequest,
path: web::Path,
redis: web::Data>>,
) -> Result {
-
workspace_check(&req)?; // Check workspace
let key = path.into_inner();
@@ -57,24 +63,22 @@ pub async fn list(
trace!(key, "list request");
async move || -> anyhow::Result {
-
let mut conn = redis.lock().await;
- let entries = redis_list(&mut *conn, &key).await?;
+ let entries = redis_list(&mut *conn, &key).await?;
Ok(HttpResponse::Ok().json(entries))
-
- }().await.map_err(map_handler_error)
+ }()
+ .await
+ .map_err(map_handler_error)
}
-
/// get
pub async fn get(
req: HttpRequest,
path: web::Path,
redis: web::Data>>,
) -> Result {
-
workspace_check(&req)?; // Check workspace
let key = path.into_inner();
@@ -82,21 +86,21 @@ pub async fn get(
// trace!(key, "get request");
async move || -> anyhow::Result {
-
let mut conn = redis.lock().await;
- Ok(
- redis_read(&mut *conn, &key).await?
- .map(|entry| HttpResponse::Ok()
+ Ok(redis_read(&mut *conn, &key)
+ .await?
+ .map(|entry| {
+ HttpResponse::Ok()
.insert_header(("ETag", &*entry.etag))
- .json(entry))
- .unwrap_or_else(|| HttpResponse::NotFound().body("empty"))
- )
-
- }().await.map_err(map_handler_error)
+ .json(entry)
+ })
+ .unwrap_or_else(|| HttpResponse::NotFound().body("empty")))
+ }()
+ .await
+ .map_err(map_handler_error)
}
-
/// put
pub async fn put(
req: HttpRequest,
@@ -104,54 +108,72 @@ pub async fn put(
body: web::Bytes,
redis: web::Data>>,
) -> Result {
-
workspace_check(&req)?; // Check workspace
let key: String = path.into_inner();
async move || -> anyhow::Result {
-
- if !req.query_string().is_empty() { return Err(anyhow!("Query parameters are not allowed")); }
+ if !req.query_string().is_empty() {
+ return Err(anyhow!("Query parameters are not allowed"));
+ }
let mut conn = redis.lock().await;
- // TTL logic
- let mut ttl = None;
- if let Some(x) = req.headers().get("HULY-TTL") {
- let s = x.to_str().map_err(|_| anyhow!("Invalid HULY-TTL header"))?;
- let secs = s.parse::().map_err(|_| anyhow!("Invalid TTL value in HULY-TTL header"))?;
- ttl = Some(Ttl::Sec(secs));
- } else if let Some(x) = req.headers().get("HULY-EXPIRE-AT") {
- let s = x.to_str().map_err(|_| anyhow!("Invalid HULY-EXPIRE-AT header"))?;
- let ts = s.parse::().map_err(|_| anyhow!("Invalid EXPIRE-AT value in HULY-EXPIRE-AT header"))?;
- ttl = Some(Ttl::At(ts));
- }
+ // TTL logic
+ let mut ttl = None;
+ if let Some(x) = req.headers().get("HULY-TTL") {
+ let s = x.to_str().map_err(|_| anyhow!("Invalid HULY-TTL header"))?;
+ let secs = s
+ .parse::()
+ .map_err(|_| anyhow!("Invalid TTL value in HULY-TTL header"))?;
+ ttl = Some(Ttl::Sec(secs));
+ } else if let Some(x) = req.headers().get("HULY-EXPIRE-AT") {
+ let s = x
+ .to_str()
+ .map_err(|_| anyhow!("Invalid HULY-EXPIRE-AT header"))?;
+ let ts = s
+ .parse::()
+ .map_err(|_| anyhow!("Invalid EXPIRE-AT value in HULY-EXPIRE-AT header"))?;
+ ttl = Some(Ttl::At(ts));
+ }
- // MODE logic
- let mut mode = Some(SaveMode::Upsert);
- if let Some(h) = req.headers().get("If-Match") { // `If-Match: *` - update only if the key exists
- let s = h.to_str().map_err(|_| anyhow!("Invalid If-Match header"))?;
- if s == "*" { mode = Some(SaveMode::Update); } // `If-Match: *` — update only if exist
- else { mode = Some(SaveMode::Equal(s.to_string())); } // `If-Match: ` — update only if current
- } else if let Some(h) = req.headers().get("If-None-Match") { // `If-None-Match: *` — insert only if does not exist
- let s = h.to_str().map_err(|_| anyhow!("Invalid If-None-Match header"))?;
- if s == "*" { mode = Some(SaveMode::Insert); } else { return Err(anyhow!("If-None-Match must be '*'")); }
- }
+ // MODE logic
+ let mut mode = Some(SaveMode::Upsert);
+ if let Some(h) = req.headers().get("If-Match") {
+ // `If-Match: *` - update only if the key exists
+ let s = h.to_str().map_err(|_| anyhow!("Invalid If-Match header"))?;
+ if s == "*" {
+ mode = Some(SaveMode::Update);
+ }
+ // `If-Match: *` — update only if exist
+ else {
+ mode = Some(SaveMode::Equal(s.to_string()));
+ } // `If-Match: ` — update only if current
+ } else if let Some(h) = req.headers().get("If-None-Match") {
+ // `If-None-Match: *` — insert only if does not exist
+ let s = h
+ .to_str()
+ .map_err(|_| anyhow!("Invalid If-None-Match header"))?;
+ if s == "*" {
+ mode = Some(SaveMode::Insert);
+ } else {
+ return Err(anyhow!("If-None-Match must be '*'"));
+ }
+ }
redis_save(&mut *conn, &key, &body[..], ttl, mode).await?;
- return Ok(HttpResponse::Ok().body("DONE"));
-
- }().await.map_err(map_handler_error)
+ return Ok(HttpResponse::Ok().body("DONE"));
+ }()
+ .await
+ .map_err(map_handler_error)
}
-
/// delete
pub async fn delete(
req: HttpRequest,
path: web::Path,
redis: web::Data>>,
) -> Result {
-
workspace_check(&req)?; // Check workspace
let key: String = path.into_inner();
@@ -161,7 +183,21 @@ pub async fn delete(
async move || -> anyhow::Result {
let mut conn = redis.lock().await;
- let deleted = redis_delete(&mut *conn, &key).await?;
+ // MODE logic
+ let mut mode = Some(SaveMode::Upsert);
+ if let Some(h) = req.headers().get("If-Match") {
+ // `If-Match: *` - delete only if the key exists
+ let s = h.to_str().map_err(|_| anyhow!("Invalid If-Match header"))?;
+ if s == "*" {
+ mode = Some(SaveMode::Update);
+ }
+ // `If-Match: *` — return error if not exist
+ else {
+ mode = Some(SaveMode::Equal(s.to_string()));
+ } // `If-Match: ` — delete only if current
+ }
+
+ let deleted = redis_delete(&mut *conn, &key, mode).await?;
let response = match deleted {
true => HttpResponse::NoContent().finish(),
@@ -169,6 +205,7 @@ pub async fn delete(
};
Ok(response)
- }().await.map_err(map_handler_error)
+ }()
+ .await
+ .map_err(map_handler_error)
}
-
diff --git a/src/handlers_ws.rs b/src/handlers_ws.rs
index 88dccb7516..bbadf6513e 100644
--- a/src/handlers_ws.rs
+++ b/src/handlers_ws.rs
@@ -1,43 +1,70 @@
+//
+// Copyright © 2025 Hardcore Engineering Inc.
+//
+// Licensed under the Eclipse Public License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License. You may
+// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+use actix::prelude::*;
use uuid::Uuid;
-use actix::{prelude::*};
use crate::ws_hub::{
- WsHub, ServerMessage, SessionId,
- Connect, Disconnect,
- Subscribe, Unsubscribe, UnsubscribeAll,
- SubscribeList,
+ Connect, Disconnect, ServerMessage, SessionId, Subscribe, SubscribeList, Unsubscribe,
+ UnsubscribeAll, WsHub,
};
+use actix::{
+ Actor, ActorContext, ActorFutureExt, AsyncContext, Handler, StreamHandler, WrapFuture, fut,
+};
+use actix_web::{Error, HttpRequest, HttpResponse, web};
+use actix_web_actors::ws;
use redis::aio::MultiplexedConnection;
+use serde::Deserialize;
+use serde_json::{Map, Value, json};
+use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Mutex;
-use serde_json::{Value, Map, json};
-use actix::{
- Actor,
- StreamHandler,
- AsyncContext,
- ActorContext,
- fut,
- ActorFutureExt,
- Handler, WrapFuture
-};
-use actix_web::{web, HttpRequest, HttpResponse, Error};
-use actix_web_actors::ws;
-use serde::Deserialize;
-use std::collections::HashSet;
use crate::redis_lib::{
- Ttl, SaveMode,
- RedisArray,
+ RedisArray, SaveMode, Ttl, deprecated_symbol, error, redis_delete, redis_list, redis_read,
redis_save,
- redis_read,
- redis_delete,
- redis_list,
- error,
- deprecated_symbol,
};
-type JsonMap = Map;
+use serde::Serialize;
+
+#[derive(Serialize, Default)]
+struct ReturnBase<'a> {
+ action: &'a str,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ key: Option<&'a str>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ data: Option<&'a str>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ correlation: Option<&'a str>,
+
+ #[serde(rename = "TTL", skip_serializing_if = "Option::is_none")]
+ ttl: Option,
+
+ #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
+ expires_at: Option,
+
+ #[serde(rename = "ifMatch", skip_serializing_if = "Option::is_none")]
+ if_match: Option<&'a str>,
+
+ #[serde(rename = "ifNoneMatch", skip_serializing_if = "Option::is_none")]
+ if_none_match: Option<&'a str>,
+}
/// WsCommand - commands enum (put, delete, sub, unsub)
#[derive(Deserialize, Debug)]
@@ -66,6 +93,16 @@ pub enum WsCommand {
if_none_match: Option,
},
+ Delete {
+ #[serde(default)]
+ correlation: Option,
+ key: String,
+
+ #[serde(rename = "ifMatch")]
+ #[serde(default)]
+ if_match: Option,
+ },
+
Get {
#[serde(default)]
correlation: Option,
@@ -78,16 +115,6 @@ pub enum WsCommand {
key: String,
},
- Delete {
- #[serde(default)]
- correlation: Option,
- key: String,
-
- #[serde(rename = "ifMatch")]
- #[serde(default)]
- if_match: Option,
- },
-
Sub {
#[serde(default)]
correlation: Option,
@@ -117,7 +144,6 @@ pub struct WsSession {
pub claims: Option,
}
-
/// Actor External trait: must be in separate impl block
impl Actor for WsSession {
type Context = ws::WebsocketContext;
@@ -131,33 +157,35 @@ impl Actor for WsSession {
self.hub
.send(Connect { addr: recipient })
.into_actor(self)
- .map(|res, act, _ctx| {
- match res {
- Ok(id) => {
- act.id = id;
- tracing::info!("WebSocket connected: {id}");
- }
- Err(e) => {
- tracing::error!("WebSocket failed connect to hub: {e}");
- _ctx.stop();
- }
+ .map(|res, act, _ctx| match res {
+ Ok(id) => {
+ act.id = id;
+ tracing::info!("WebSocket connected: {id}");
+ }
+ Err(e) => {
+ tracing::error!("WebSocket failed connect to hub: {e}");
+ _ctx.stop();
}
})
.wait(ctx); // waiting for ID
}
fn stopped(&mut self, _ctx: &mut Self::Context) {
- if self.id != 0 { self.hub.do_send(Disconnect { session_id: self.id }); }
- tracing::info!("WebSocket disconnected: {:?}",&self.id);
+ if self.id != 0 {
+ self.hub.do_send(Disconnect {
+ session_id: self.id,
+ });
+ }
+ tracing::info!("WebSocket disconnected: {:?}", &self.id);
}
-
}
impl actix::Handler for WsSession {
type Result = ();
fn handle(&mut self, msg: ServerMessage, ctx: &mut Self::Context) {
- let json = serde_json::to_string(&msg).unwrap_or_else(|_| "{\"error\":\"serialization\"}".into());
+ let json =
+ serde_json::to_string(&msg).unwrap_or_else(|_| "{\"error\":\"serialization\"}".into());
ctx.text(json);
}
}
@@ -166,16 +194,12 @@ impl actix::Handler for WsSession {
impl StreamHandler> for WsSession {
fn handle(&mut self, msg: Result, ctx: &mut Self::Context) {
match msg {
- Ok(ws::Message::Text(text)) => {
- // println!("Message: {}", text);
- match serde_json::from_str::(&text) {
- Ok(cmd) => self.handle_command(cmd, ctx),
- Err(err) => ctx.text(format!("Invalid JSON: {}", err)),
- }
- }
+ Ok(ws::Message::Text(text)) => match serde_json::from_str::(&text) {
+ Ok(cmd) => self.handle_command(cmd, ctx),
+ Err(err) => ctx.text(format!("Invalid JSON: {}", err)),
+ },
Ok(ws::Message::Ping(msg)) => ctx.pong(&msg),
Ok(ws::Message::Close(reason)) => {
- // println!("Closing WS: {:?}", reason);
ctx.close(reason);
ctx.stop();
}
@@ -184,287 +208,352 @@ impl StreamHandler> for WsSession {
}
}
+use crate::workspace_owner::check_workspace_core;
+
/// All logic in one impl
impl WsSession {
-
fn ws_error(&self, ctx: &mut ws::WebsocketContext, msg: &str) {
ctx.text(format!(r#"{{"error":"{}"}}"#, msg));
}
fn workspace_check_ws(&self, key: &str) -> Result<(), &'static str> {
let claims = self.claims.as_ref().ok_or("Missing auth claims")?;
- if claims.is_system() { return Ok(()); }
- let jwt_workspace = claims.workspace.as_ref().ok_or("Missing workspace in token")?;
- let path_ws = key.split('/').next().ok_or("Invalid key: missing workspace")?;
- if path_ws.is_empty() { return Err("Invalid key: missing workspace"); }
- let path_ws_uuid = Uuid::parse_str(path_ws).map_err(|_| "Invalid workspace UUID in key")?;
- if jwt_workspace != &path_ws_uuid { return Err("Workspace mismatch"); }
- Ok(())
+ check_workspace_core(claims, key)
}
- fn wait_and_send(
+ fn fut_send(
&mut self,
ctx: &mut ws::WebsocketContext,
- fut: F,
- mut base: JsonMap,
- )
- where
- F: std::future::Future