chore(infra): add gRPC layer, update protobufs, remove immediate module

- Add gRPC service modules: auth, channel, channel settings, member,
  permission
- Update protobuf definitions and generated code
- Remove immediate/ real-time module (superseded by IM service)
- Update etcd discovery and registration
- Update cache, error, config, and build infrastructure
- Add ADR documentation
- Update OpenAPI spec
This commit is contained in:
zhenyi
2026-06-10 18:49:42 +08:00
parent 9eb77ab98b
commit 1000f8a80d
57 changed files with 22524 additions and 2703 deletions
+66 -22
View File
@@ -1,29 +1,73 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_prost_build::configure()
.build_client(true)
.build_server(false)
.compile_protos(&["proto/email/email.proto"], &["proto/email"])?;
use std::fs;
use std::path::{Path, PathBuf};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
let out_dir = PathBuf::from(std::env::var("OUT_DIR")?);
fs::create_dir_all(&out_dir)?;
let email_dir = manifest_dir.join("proto/email");
let email_protos = proto_files(&email_dir)?;
for proto in &email_protos {
println!("cargo:rerun-if-changed={}", proto.display());
}
tonic_prost_build::configure()
.build_client(true)
.build_server(false)
.compile_protos(
&[
"proto/git/oid.proto",
"proto/git/tagger.proto",
"proto/git/repository.proto",
"proto/git/commit.proto",
"proto/git/branch.proto",
"proto/git/tag.proto",
"proto/git/tree.proto",
"proto/git/diff.proto",
"proto/git/merge.proto",
"proto/git/blame.proto",
"proto/git/archive.proto",
"proto/git/pack.proto",
],
&["proto/git"],
)?;
.out_dir(&out_dir)
.compile_protos(&email_protos, &[email_dir])?;
let git_dir = manifest_dir.join("proto/git");
let git_protos = proto_files(&git_dir)?;
for proto in &git_protos {
println!("cargo:rerun-if-changed={}", proto.display());
}
tonic_prost_build::configure()
.build_client(true)
.build_server(false)
.type_attribute(
".",
"#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)]",
)
.extern_path(".google.protobuf.Timestamp", "crate::pb::Timestamp")
.out_dir(&out_dir)
.compile_protos(&git_protos, &[git_dir])?;
let this_dir = manifest_dir.join("proto/this");
let this_protos = proto_files(&this_dir)?;
for proto in &this_protos {
println!("cargo:rerun-if-changed={}", proto.display());
}
tonic_prost_build::configure()
.build_client(false)
.build_server(true)
.out_dir(&out_dir)
.compile_protos(&this_protos, &[this_dir])?;
let im_dir = manifest_dir.join("proto/this/im");
let im_protos = proto_files(&im_dir)?;
for proto in &im_protos {
println!("cargo:rerun-if-changed={}", proto.display());
}
tonic_prost_build::configure()
.build_client(false)
.build_server(true)
.out_dir(&out_dir)
.compile_protos(&im_protos, &[im_dir])?;
Ok(())
}
fn proto_files(proto_dir: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
let mut files = fs::read_dir(proto_dir)?
.map(|entry| entry.map(|entry| entry.path()))
.collect::<Result<Vec<_>, _>>()?;
files.retain(|path| path.extension().is_some_and(|ext| ext == "proto"));
files.sort();
if files.is_empty() {
return Err(format!("no .proto files found in {}", proto_dir.display()).into());
}
Ok(files)
}
+61 -20
View File
@@ -18,10 +18,10 @@ pub struct AppCache {
}
impl AppCache {
pub fn from_config(config: &AppConfig) -> AppResult<Self> {
pub async fn from_config(config: &AppConfig) -> AppResult<Self> {
let cap = config.lru_default_capacity()?;
let ttl = Duration::from_secs(config.lru_default_ttl_secs()?);
let l2 = AppRedis::from_config(config)?;
let l2 = AppRedis::from_config(config).await?;
let key_prefix = config.redis_key_prefix()?;
Ok(Self {
l1: LruTtlCache::new(cap, ttl),
@@ -31,17 +31,18 @@ impl AppCache {
})
}
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
if let Some(json) = self.l1.get(&key.to_string()) {
return serde_json::from_str(&json).ok();
}
let full_key = self.full_key(key);
let mut conn = self.l2.get_connection().ok()?;
let mut conn = self.l2.get_connection();
let json: String = Cmd::new()
.arg("GET")
.arg(&full_key)
.query::<Option<String>>(&mut *conn.inner_mut())
.query_async::<Option<String>>(&mut conn)
.await
.ok()??;
let value: T = serde_json::from_str(&json).ok()?;
@@ -49,46 +50,86 @@ impl AppCache {
Some(value)
}
pub fn set<T: Serialize>(&self, key: &str, value: &T, ttl: Option<Duration>) -> AppResult<()> {
pub async fn get_l2_only<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
let full_key = self.full_key(key);
let mut conn = self.l2.get_connection();
let json: String = Cmd::new()
.arg("GET")
.arg(&full_key)
.query_async::<Option<String>>(&mut conn)
.await
.ok()??;
serde_json::from_str(&json).ok()
}
pub async fn set<T: Serialize>(
&self,
key: &str,
value: &T,
ttl: Option<Duration>,
) -> AppResult<()> {
let json = serde_json::to_string(value)?;
let full_key = self.full_key(key);
let ttl_duration = ttl.unwrap_or(self.default_ttl);
let ttl_secs = ttl_duration.as_secs() as usize;
let mut conn = self.l2.get_connection()?;
let mut conn = self.l2.get_connection();
Cmd::new()
.arg("SETEX")
.arg(&full_key)
.arg(ttl_secs)
.arg(&json)
.query::<()>(&mut *conn.inner_mut())?;
.query_async::<()>(&mut conn)
.await?;
self.l1.insert_with_ttl(key.to_string(), json, ttl_duration);
Ok(())
}
pub fn delete(&self, key: &str) -> AppResult<()> {
self.l1.remove(&key.to_string());
pub async fn set_l2_only<T: Serialize>(
&self,
key: &str,
value: &T,
ttl: Option<Duration>,
) -> AppResult<()> {
let json = serde_json::to_string(value)?;
let full_key = self.full_key(key);
let mut conn = self.l2.get_connection()?;
let ttl_duration = ttl.unwrap_or(self.default_ttl);
let ttl_secs = ttl_duration.as_secs() as usize;
let mut conn = self.l2.get_connection();
Cmd::new()
.arg("DEL")
.arg("SETEX")
.arg(&full_key)
.query::<()>(&mut *conn.inner_mut())?;
.arg(ttl_secs)
.arg(&json)
.query_async::<()>(&mut conn)
.await?;
Ok(())
}
pub fn exists(&self, key: &str) -> bool {
pub async fn delete(&self, key: &str) -> AppResult<()> {
self.l1.remove(&key.to_string());
let full_key = self.full_key(key);
let mut conn = self.l2.get_connection();
Cmd::new()
.arg("DEL")
.arg(&full_key)
.query_async::<()>(&mut conn)
.await?;
Ok(())
}
pub async fn exists(&self, key: &str) -> bool {
if self.l1.get(&key.to_string()).is_some() {
return true;
}
let full_key = self.full_key(key);
if let Ok(mut conn) = self.l2.get_connection() {
return Cmd::new()
let mut conn = self.l2.get_connection();
Cmd::new()
.arg("EXISTS")
.arg(&full_key)
.query(&mut *conn.inner_mut())
.unwrap_or(false);
}
false
.query_async::<bool>(&mut conn)
.await
.unwrap_or(false)
}
fn full_key(&self, key: &str) -> String {
+45 -60
View File
@@ -1,15 +1,13 @@
use crate::config::AppConfig;
use crate::error::AppError;
use crate::error::AppResult;
use r2d2::Pool;
use crate::error::{AppError, AppResult};
use futures_util::future::BoxFuture;
use redis::cluster::ClusterClient;
use redis::{Client, ConnectionLike, RedisError};
use std::time::Duration;
use redis::{Client, FromRedisValue};
#[derive(Clone)]
enum RedisBackend {
Single(Pool<Client>),
Cluster(Pool<ClusterClient>),
Single(redis::aio::ConnectionManager),
Cluster(redis::cluster_async::ClusterConnection),
}
#[derive(Clone)]
@@ -18,100 +16,87 @@ pub struct AppRedis {
}
impl AppRedis {
pub fn from_config(config: &AppConfig) -> AppResult<Self> {
pub async fn from_config(config: &AppConfig) -> AppResult<Self> {
let backend = if config.redis_cluster_enabled()? {
let nodes = config.redis_cluster_nodes()?;
let cluster_client =
ClusterClient::new(nodes.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
let pool = Self::build_pool(config, cluster_client)?;
RedisBackend::Cluster(pool)
let conn = cluster_client.get_async_connection().await?;
RedisBackend::Cluster(conn)
} else {
let url = config
.redis_url()?
.ok_or_else(|| AppError::Config("APP_REDIS_URL is not set".into()))?;
let client = Client::open(url.as_str())?;
let pool = Self::build_pool(config, client)?;
RedisBackend::Single(pool)
let conn = client.get_connection_manager().await?;
RedisBackend::Single(conn)
};
Ok(Self { backend })
}
fn build_pool<M: r2d2::ManageConnection>(config: &AppConfig, manager: M) -> AppResult<Pool<M>> {
let max_conn = config.redis_max_connections()?;
let min_conn = config.redis_min_connections()?;
let idle_timeout = config.redis_idle_timeout()?;
let conn_timeout = config.redis_connection_timeout()?;
Ok(r2d2::Builder::new()
.max_size(max_conn)
.min_idle(Some(min_conn))
.idle_timeout(Some(Duration::from_secs(idle_timeout)))
.connection_timeout(Duration::from_secs(conn_timeout))
.build(manager)?)
}
pub fn get_connection(&self) -> Result<PooledRedisConnection, r2d2::Error> {
pub fn get_connection(&self) -> RedisConnection {
match &self.backend {
RedisBackend::Single(pool) => pool.get().map(PooledRedisConnection::Single),
RedisBackend::Cluster(pool) => pool.get().map(PooledRedisConnection::Cluster),
RedisBackend::Single(cm) => RedisConnection::Single(cm.clone()),
RedisBackend::Cluster(cc) => RedisConnection::Cluster(cc.clone()),
}
}
}
#[allow(clippy::large_enum_variant)]
pub enum PooledRedisConnection {
Single(r2d2::PooledConnection<Client>),
Cluster(r2d2::PooledConnection<ClusterClient>),
pub enum RedisConnection {
Single(redis::aio::ConnectionManager),
Cluster(redis::cluster_async::ClusterConnection),
}
impl PooledRedisConnection {
pub fn inner_mut(&mut self) -> &mut dyn ConnectionLike {
impl redis::aio::ConnectionLike for RedisConnection {
fn req_packed_command<'a>(
&'a mut self,
cmd: &'a redis::Cmd,
) -> BoxFuture<'a, redis::RedisResult<redis::Value>> {
match self {
PooledRedisConnection::Single(conn) => conn,
PooledRedisConnection::Cluster(conn) => conn,
}
}
}
impl ConnectionLike for PooledRedisConnection {
fn req_packed_command(&mut self, cmd: &[u8]) -> Result<redis::Value, RedisError> {
match self {
PooledRedisConnection::Single(conn) => conn.req_packed_command(cmd),
PooledRedisConnection::Cluster(conn) => conn.req_packed_command(cmd),
Self::Single(c) => Box::pin(c.req_packed_command(cmd)),
Self::Cluster(c) => Box::pin(c.req_packed_command(cmd)),
}
}
fn req_packed_commands(
&mut self,
cmd: &[u8],
fn req_packed_commands<'a>(
&'a mut self,
cmd: &'a redis::Pipeline,
offset: usize,
count: usize,
) -> Result<Vec<redis::Value>, RedisError> {
) -> BoxFuture<'a, redis::RedisResult<Vec<redis::Value>>> {
match self {
PooledRedisConnection::Single(conn) => conn.req_packed_commands(cmd, offset, count),
PooledRedisConnection::Cluster(conn) => conn.req_packed_commands(cmd, offset, count),
Self::Single(c) => Box::pin(c.req_packed_commands(cmd, offset, count)),
Self::Cluster(c) => Box::pin(c.req_packed_commands(cmd, offset, count)),
}
}
fn get_db(&self) -> i64 {
match self {
PooledRedisConnection::Single(conn) => conn.get_db(),
PooledRedisConnection::Cluster(conn) => conn.get_db(),
Self::Single(c) => c.get_db(),
Self::Cluster(c) => c.get_db(),
}
}
}
fn check_connection(&mut self) -> bool {
impl RedisConnection {
pub async fn query_async<T: FromRedisValue>(
&mut self,
cmd: &mut redis::Cmd,
) -> redis::RedisResult<T> {
match self {
PooledRedisConnection::Single(conn) => conn.check_connection(),
PooledRedisConnection::Cluster(conn) => conn.check_connection(),
Self::Single(c) => cmd.query_async(c).await,
Self::Cluster(c) => cmd.query_async(c).await,
}
}
fn is_open(&self) -> bool {
pub async fn query_pipeline_async<T: FromRedisValue>(
&mut self,
pipe: &mut redis::Pipeline,
) -> redis::RedisResult<T> {
match self {
PooledRedisConnection::Single(conn) => conn.is_open(),
PooledRedisConnection::Cluster(conn) => conn.is_open(),
Self::Single(c) => pipe.query_async(c).await,
Self::Cluster(c) => pipe.query_async(c).await,
}
}
}
+4
View File
@@ -27,4 +27,8 @@ impl AppConfig {
pub fn rpc_default_timeout_secs(&self) -> AppResult<u64> {
self.get_env_or("APP_RPC_DEFAULT_TIMEOUT_SECS", 10)
}
pub fn email_rpc_addr(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_EMAIL_RPC_ADDR")
}
}
+50
View File
@@ -0,0 +1,50 @@
# ADR-NNN: 标题 / Title
## 状态 / Status
**Accepted** | Superseded | Deprecated
**日期 / Date**: YYYY-MM-DD
## 背景 / Context
描述问题背景和驱动因素。
Describe the context and driving factors.
## 决策 / Decision
描述做出的决策。
Describe the decision that was made.
## 考虑的方案 / Considered Options
1. **方案 A** — 描述 / Description
2. **方案 B** — 描述 / Description
3. **方案 C** — 描述 / Description
## 后果 / Consequences
### 正面 / Positive
- 优点 1 / Advantage 1
- 优点 2 / Advantage 2
### 负面 / Negative
- 缺点 1 / Disadvantage 1
- 缺点 2 / Disadvantage 2
### 风险 / Risks
- 风险 1 / Risk 1
- 风险 2 / Risk 2
## 相关决策 / Related Decisions
- [ADR-XXX](xxx-related-decision.md)
## 参考 / References
- [链接 / Link](url)
+48
View File
@@ -0,0 +1,48 @@
# ADR-001: 选择 Actix-web 作为 Web 框架 / Choice of Actix-web as Web Framework
## 状态 / Status
**Accepted**
**日期 / Date**: 2024-01-01
## 背景 / Context
appks 是一个协作开发平台后端,需要一个高性能、可靠的 Rust Web 框架来处理 HTTP 请求、WebSocket 连接和中间件。
appks is a collaborative development platform backend that needs a high-performance, reliable Rust web framework for HTTP requests, WebSocket connections, and middleware.
## 决策 / Decision
选择 **Actix-web** 作为 Web 框架。
Chose **Actix-web** as the web framework.
## 考虑的方案 / Considered Options
1. **Actix-web** — 成熟的 actor 模型框架,性能优异
2. **Axum** — Tokio 生态新兴框架,Tower 集成
3. **Rocket** — 易用性优先的框架
## 后果 / Consequences
### 正面 / Positive
- 优异的性能表现 / Excellent performance
- 成熟的生态系统 / Mature ecosystem
- 良好的 WebSocket 支持 / Good WebSocket support
- 活跃的社区维护 / Active community maintenance
### 负面 / Negative
- 学习曲线较陡 / Steeper learning curve
- Actor 模型需要适应 / Actor model requires adaptation
### 风险 / Risks
- 框架版本升级可能带来 breaking changes / Framework upgrades may bring breaking changes
## 参考 / References
- [Actix-web 官方文档](https://actix.rs/)
- [TechEmpower Web Framework Benchmarks](https://www.techempower.com/benchmarks/)
+49
View File
@@ -0,0 +1,49 @@
# ADR-002: 两级缓存架构 / Two-Tier Caching Architecture
## 状态 / Status
**Accepted**
**日期 / Date**: 2024-01-01
## 背景 / Context
平台需要缓存机制来减少数据库负载,提高响应速度。需要在性能和一致性之间取得平衡。
The platform needs a caching mechanism to reduce database load and improve response times. A balance between performance and consistency is required.
## 决策 / Decision
采用 **两级缓存架构**L1 (内存 LRU-TTL) + L2 (Redis)。
Adopted **two-tier caching**: L1 (in-memory LRU-TTL) + L2 (Redis).
## 考虑的方案 / Considered Options
1. **纯 Redis** — 简单但网络延迟高
2. **纯内存缓存** — 快但不跨实例共享
3. **两级缓存** — 兼顾速度和共享
## 后果 / Consequences
### 正面 / Positive
- L1 提供极低延迟 / L1 provides ultra-low latency
- L2 提供跨实例共享 / L2 provides cross-instance sharing
- 减少数据库负载 / Reduces database load
### 负面 / Negative
- 缓存一致性复杂 / Cache consistency is complex
- 内存占用增加 / Increased memory usage
### 风险 / Risks
- 缓存雪崩 / Cache avalanche
- 缓存穿透 / Cache penetration
## 实现细节 / Implementation Details
- **L1**: `DashMap + Mutex<LruTracker>`, TTL 5 分钟
- **L2**: Redis via r2d2, TTL 可配置
- **策略**: L1 miss → L2 miss → 数据库查询
+45
View File
@@ -0,0 +1,45 @@
# ADR-003: 使用 NATS JetStream 作为消息队列 / NATS JetStream for Messaging
## 状态 / Status
**Accepted**
**日期 / Date**: 2024-01-01
## 背景 / Context
平台的实时 IM 系统需要可靠的消息传递机制,支持发布/订阅模式和消息持久化。
The platform's real-time IM system needs reliable messaging with pub/sub patterns and message persistence.
## 决策 / Decision
使用 **NATS JetStream** 作为消息队列。
Use **NATS JetStream** as the message queue.
## 考虑的方案 / Considered Options
1. **NATS JetStream** — 轻量级、高性能
2. **Apache Kafka** — 高吞吐但运维复杂
3. **RabbitMQ** — 功能丰富但性能较低
## 后果 / Consequences
### 正面 / Positive
- 轻量级部署 / Lightweight deployment
- 高性能消息传递 / High-performance messaging
- 内置持久化 / Built-in persistence
- 良好的 Rust 客户端支持 / Good Rust client support
### 负面 / Negative
- 生态系统不如 Kafka 成熟 / Ecosystem less mature than Kafka
- 监控工具有限 / Limited monitoring tools
## 实现细节 / Implementation Details
- Publisher: 发布事件到 JetStream
- Subscriber: 订阅并处理事件
- Stream prefix: 可配置的流前缀
+46
View File
@@ -0,0 +1,46 @@
# ADR-004: 使用 etcd 进行服务发现 / etcd for Service Discovery
## 状态 / Status
**Accepted**
**日期 / Date**: 2024-01-01
## 背景 / Context
平台依赖多个外部 gRPC 微服务(Git、Email),需要动态发现机制来连接这些服务。
The platform depends on multiple external gRPC microservices (Git, Email) and needs dynamic discovery to connect to them.
## 决策 / Decision
使用 **etcd** 进行服务发现和注册。
Use **etcd** for service discovery and registration.
## 考虑的方案 / Considered Options
1. **etcd** — Kubernetes 生态标准,强一致性
2. **Consul** — 功能丰富但较重
3. **ZooKeeper** — 经典但运维复杂
4. **静态配置** — 简单但不灵活
## 后果 / Consequences
### 正面 / Positive
- 与 Kubernetes 生态一致 / Aligned with Kubernetes ecosystem
- 强一致性保证 / Strong consistency guarantees
- 租约机制支持健康检查 / Lease mechanism supports health checks
- Watch 机制支持实时更新 / Watch mechanism supports real-time updates
### 负面 / Negative
- 额外的基础设施依赖 / Additional infrastructure dependency
- 运维复杂度增加 / Increased operational complexity
## 实现细节 / Implementation Details
- **注册**: `register.rs` — 自注册 + 租约保活
- **发现**: `discovery.rs` — Watch 动态连接
- **客户端**: tonic/prost gRPC 客户端
+68
View File
@@ -0,0 +1,68 @@
# ADR-005: 统一错误处理策略 / Unified Error Handling Strategy
## 状态 / Status
**Accepted**
**日期 / Date**: 2024-01-01
## 背景 / Context
平台需要统一的错误处理机制,确保错误信息对用户友好,同时保留足够的调试信息。
The platform needs a unified error handling mechanism that provides user-friendly error messages while retaining sufficient debugging information.
## 决策 / Decision
使用 **AppError 枚举 + AppResult 类型别名** 作为统一错误处理策略。
Use **AppError enum + AppResult type alias** as the unified error handling strategy.
## 考虑的方案 / Considered Options
1. **自定义枚举 (AppError)** — 类型安全、可扩展
2. **anyhow** — 简单但类型信息丢失
3. **thiserror + 手动实现** — 灵活但工作量大
## 后果 / Consequences
### 正面 / Positive
- 类型安全的错误处理 / Type-safe error handling
- 统一的错误响应格式 / Unified error response format
- 便于错误分类和监控 / Easy error classification and monitoring
- 与 actix-web 集成良好 / Good integration with actix-web
### 负面 / Negative
- 需要维护错误枚举 / Need to maintain error enum
- 新增错误类型需要更新枚举 / New error types require enum updates
## 实现细节 / Implementation Details
```rust
// AppError 定义
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("User not found")]
UserNotFound,
#[error("Invalid password")]
InvalidPassword,
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
// ...
}
// AppResult 类型别名
pub type AppResult<T> = Result<T, AppError>;
```
## 错误码映射 / Error Code Mapping
| Postgres Code | 含义 | HTTP Status |
|---|---|---|
| 23505 | 唯一约束违反 | 409 Conflict |
| 23503 | 外键约束违反 | 400 Bad Request |
| 23514 | 检查约束违反 | 400 Bad Request |
| 23502 | 非空约束违反 | 400 Bad Request |
| 23P01 | 排他约束违反 | 409 Conflict |
+24 -3
View File
@@ -14,9 +14,6 @@ pub enum AppError {
#[error("redis error: {0}")]
Redis(#[from] redis::RedisError),
#[error("r2d2 error: {0}")]
R2d2(#[from] r2d2::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
@@ -131,6 +128,7 @@ impl actix_web::ResponseError for AppError {
| AppError::InvalidEmailCode
| AppError::RsaDecodeError
| AppError::RsaGenerationError => StatusCode::BAD_REQUEST,
AppError::Database(e) => db_error_status_code(e),
AppError::PasswordHashError(_) => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
@@ -139,6 +137,7 @@ impl actix_web::ResponseError for AppError {
fn error_response(&self) -> HttpResponse {
let status = self.status_code();
let message = if status == actix_web::http::StatusCode::INTERNAL_SERVER_ERROR {
tracing::error!(?self, "internal server error");
"internal server error".to_string()
} else {
self.to_string()
@@ -146,3 +145,25 @@ impl actix_web::ResponseError for AppError {
HttpResponse::build(status).json(serde_json::json!({ "error": message }))
}
}
fn db_error_status_code(e: &sqlx::Error) -> actix_web::http::StatusCode {
use actix_web::http::StatusCode;
match e {
sqlx::Error::Database(db_err) => {
match db_err.code().as_ref().map(|c| c.as_ref()) {
// unique_violation
Some("23505") => StatusCode::CONFLICT,
// foreign_key_violation
Some("23503") => StatusCode::CONFLICT,
// check_violation
Some("23514") => StatusCode::BAD_REQUEST,
// not_null_violation
Some("23502") => StatusCode::BAD_REQUEST,
// exclusion_violation
Some("23P01") => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
+147 -22
View File
@@ -5,18 +5,151 @@ use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::pb::{EmailClient, RepoClient};
use super::types::ServiceInstance;
use super::{EtcdRegistry, EtcdRegistryInner};
use super::types::{GitksPeerInfo, ServiceInstance};
use super::{EtcdRegistry, EtcdRegistryInner, storage_name_to_uuid};
/// etcd prefix where gitks nodes register themselves (gitks::cluster::ClusterManager).
const GITKS_NODES_PREFIX: &str = "/gitks/nodes/";
impl EtcdRegistry {
pub async fn start_discovery(&self) -> AppResult<()> {
self.load_initial("git").await?;
// Discover gitks nodes from gitks's own etcd prefix.
self.load_gitks_nodes().await?;
self.spawn_gitks_watch();
// Discover mail services from appks's service prefix.
if self.email_client.is_none() {
self.load_initial("mail").await?;
self.spawn_watch("git");
self.spawn_watch("mail");
}
Ok(())
}
// Section: gitks node discovery (from /gitks/nodes/)
async fn load_gitks_nodes(&self) -> AppResult<()> {
let resp = {
let mut client = self.inner.client.lock().await;
client
.get(GITKS_NODES_PREFIX, Some(GetOptions::new().with_prefix()))
.await
.map_err(|e| {
AppError::Config(format!("etcd get {GITKS_NODES_PREFIX} failed: {e}"))
})?
};
for kv in resp.kvs() {
let key = kv.key_str().unwrap_or_default();
let value = kv.value_str().unwrap_or_default();
if let Ok(peer) = serde_json::from_str::<GitksPeerInfo>(value) {
Self::upsert_gitks_node(&self.inner, key, &peer);
} else {
tracing::warn!(key = key, "failed to parse gitks peer info from etcd");
}
}
tracing::info!(
prefix = GITKS_NODES_PREFIX,
count = self.inner.git_nodes.len(),
"gitks node discovery complete"
);
Ok(())
}
fn spawn_gitks_watch(&self) {
let inner = self.inner.clone();
tokio::spawn(async move {
loop {
match Self::gitks_watch_loop(&inner).await {
Ok(()) => break,
Err(e) => {
tracing::warn!(error = %e, "gitks etcd watch disconnected, retrying in 3s");
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
}
}
});
}
async fn gitks_watch_loop(inner: &EtcdRegistryInner) -> AppResult<()> {
let mut stream = {
let mut client = inner.client.lock().await;
client
.watch(GITKS_NODES_PREFIX, Some(WatchOptions::new().with_prefix()))
.await
.map_err(|e| {
AppError::Config(format!("etcd watch {GITKS_NODES_PREFIX} failed: {e}"))
})?
};
while let Some(resp) = stream.next().await {
let resp =
resp.map_err(|e| AppError::Config(format!("gitks watch stream error: {e}")))?;
for event in resp.events() {
let Some(kv) = event.kv() else { continue };
let key = kv.key_str().unwrap_or_default();
match event.event_type() {
etcd_client::EventType::Put => {
let value = kv.value_str().unwrap_or_default();
if let Ok(peer) = serde_json::from_str::<GitksPeerInfo>(value) {
Self::upsert_gitks_node(inner, key, &peer);
tracing::info!(
storage_name = %peer.storage_name,
grpc_addr = %peer.grpc_addr,
"gitks node upserted"
);
}
}
etcd_client::EventType::Delete => {
let storage_name = key.strip_prefix(GITKS_NODES_PREFIX).unwrap_or(&key);
let node_id = storage_name_to_uuid(storage_name);
inner.git_nodes.remove(&node_id);
tracing::info!(storage_name = storage_name, "gitks node removed");
}
}
}
}
Ok(())
}
fn upsert_gitks_node(inner: &EtcdRegistryInner, key: &str, peer: &GitksPeerInfo) {
let node_id = storage_name_to_uuid(&peer.storage_name);
if peer.grpc_addr.is_empty() {
tracing::warn!(
storage_name = %peer.storage_name,
key = key,
"gitks peer has empty grpc_addr, skipping"
);
return;
}
match RepoClient::lazy_connect(&peer.grpc_addr) {
Ok(client) => {
inner.git_nodes.insert(node_id, client);
tracing::debug!(
storage_name = %peer.storage_name,
node_id = %node_id,
grpc_addr = %peer.grpc_addr,
"gitks node connected"
);
}
Err(e) => {
tracing::error!(
storage_name = %peer.storage_name,
grpc_addr = %peer.grpc_addr,
error = %e,
"gitks node connect failed"
);
}
}
}
// Section: mail service discovery (from appks's own etcd prefix)
async fn load_initial(&self, service: &str) -> AppResult<()> {
let prefix = self.service_prefix(service);
let resp = {
@@ -38,7 +171,7 @@ impl EtcdRegistry {
tracing::info!(
service = service,
prefix = prefix.as_str(),
"etcd initial discovery complete"
"etcd mail discovery complete"
);
Ok(())
}
@@ -66,7 +199,7 @@ impl EtcdRegistry {
}
async fn watch_loop(inner: &EtcdRegistryInner, prefix: &str, service: &str) -> AppResult<()> {
let (mut watcher, mut stream) = {
let mut stream = {
let mut client = inner.client.lock().await;
client
.watch(prefix, Some(WatchOptions::new().with_prefix()))
@@ -74,8 +207,6 @@ impl EtcdRegistry {
.map_err(|e| AppError::Config(format!("etcd watch {prefix} failed: {e}")))?
};
let _keep = &mut watcher;
while let Some(resp) = stream.next().await {
let resp =
resp.map_err(|e| AppError::Config(format!("etcd watch stream error: {e}")))?;
@@ -89,12 +220,12 @@ impl EtcdRegistry {
let value = kv.value_str().unwrap_or_default();
if let Ok(instance) = serde_json::from_str::<ServiceInstance>(value) {
Self::upsert_instance(inner, service, key, &instance);
tracing::info!(service = service, key = key, "etcd service upserted");
tracing::info!(service = service, key = key, "mail service upserted");
}
}
etcd_client::EventType::Delete => {
Self::remove_instance(inner, service, key);
tracing::info!(service = service, key = key, "etcd service removed");
tracing::info!(service = service, key = key, "mail service removed");
}
}
}
@@ -123,20 +254,17 @@ impl EtcdRegistry {
let addr = instance.addr.clone();
match service {
"git" => match RepoClient::lazy_connect(&addr) {
Ok(client) => {
inner.git_nodes.insert(node_id, client);
}
Err(e) => {
tracing::error!(key = key, addr = addr.as_str(), error = %e, "git client connect failed");
}
},
"mail" => match EmailClient::lazy_connect(&addr) {
Ok(client) => {
inner.mail_nodes.insert(node_id, client);
}
Err(e) => {
tracing::error!(key = key, addr = addr.as_str(), error = %e, "mail client connect failed");
tracing::error!(
key = key,
addr = addr.as_str(),
error = %e,
"mail client connect failed"
);
}
},
_ => {}
@@ -148,9 +276,6 @@ impl EtcdRegistry {
return;
};
match service {
"git" => {
inner.git_nodes.remove(&node_id);
}
"mail" => {
inner.mail_nodes.remove(&node_id);
}
+40 -2
View File
@@ -2,7 +2,7 @@ mod discovery;
mod register;
mod types;
pub use types::ServiceInstance;
pub use types::{GitksPeerInfo, ServiceInstance};
use std::sync::Arc;
use std::sync::atomic::AtomicI64;
@@ -19,6 +19,7 @@ use crate::pb::{EmailClient, RepoClient};
#[derive(Clone)]
pub struct EtcdRegistry {
pub(crate) inner: Arc<EtcdRegistryInner>,
email_client: Option<EmailClient>,
}
pub(crate) struct EtcdRegistryInner {
@@ -38,7 +39,7 @@ impl EtcdRegistry {
let opts = etcd_client::ConnectOptions::new()
.with_connect_timeout(std::time::Duration::from_secs(timeout));
let client = Client::connect(&endpoints, Some(opts))
let client = Client::connect(endpoints, Some(opts))
.await
.map_err(|e| AppError::Config(format!("etcd connect failed: {e}")))?;
@@ -54,6 +55,25 @@ impl EtcdRegistry {
let key_prefix = config.etcd_key_prefix()?;
let email_client = match config.email_rpc_addr()? {
Some(addr) if !addr.is_empty() => match EmailClient::lazy_connect(&addr) {
Ok(client) => {
tracing::info!(addr = %addr, "email client connected via APP_EMAIL_RPC_ADDR");
Some(client)
}
Err(e) => {
tracing::error!(addr = %addr, error = %e, "email client connect via APP_EMAIL_RPC_ADDR failed");
None
}
},
_ => {
tracing::info!(
"APP_EMAIL_RPC_ADDR not set, will fall back to etcd discovery for email"
);
None
}
};
Ok(Self {
inner: Arc::new(EtcdRegistryInner {
client: Mutex::new(client),
@@ -63,6 +83,7 @@ impl EtcdRegistry {
mail_nodes: DashMap::new(),
lease_id: AtomicI64::new(0),
}),
email_client,
})
}
@@ -75,6 +96,9 @@ impl EtcdRegistry {
}
pub fn get_email_client(&self) -> Option<EmailClient> {
if let Some(ref client) = self.email_client {
return Some(client.clone());
}
self.inner
.mail_nodes
.iter()
@@ -85,4 +109,18 @@ impl EtcdRegistry {
pub fn has_git_nodes(&self) -> bool {
!self.inner.git_nodes.is_empty()
}
/// Sort available gitks node UUIDs for deterministic selection.
pub fn git_node_ids_sorted(&self) -> Vec<Uuid> {
let mut ids: Vec<Uuid> = self.git_node_ids();
ids.sort();
ids
}
}
/// Derive a deterministic UUID from a gitks storage_name.
/// Uses UUID v5 with DNS namespace so the same storage_name always
/// maps to the same UUID across all appks instances.
pub fn storage_name_to_uuid(storage_name: &str) -> Uuid {
Uuid::new_v5(&Uuid::NAMESPACE_DNS, storage_name.as_bytes())
}
+16
View File
@@ -8,3 +8,19 @@ pub struct ServiceInstance {
#[serde(default)]
pub metadata: HashMap<String, String>,
}
/// Information about a gitks peer node, registered in etcd under /gitks/nodes/.
/// Mirrors gitks::cluster::types::PeerInfo.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitksPeerInfo {
/// Logical storage name (e.g. "node-a", "default")
pub storage_name: String,
/// ractor_cluster TCP address (e.g. "10.0.1.4:4697")
#[serde(default)]
pub cluster_addr: String,
/// gRPC service address (e.g. "http://10.0.1.4:50051")
pub grpc_addr: String,
/// Software version
#[serde(default)]
pub version: String,
}
+4 -1
View File
@@ -2,10 +2,13 @@ use appks::api::openapi::OpenApiDoc;
use utoipa::OpenApi;
fn main() {
println!("Generating OpenAPI documentation...");
let json = OpenApiDoc::openapi().to_pretty_json();
if let Ok(json) = json {
if let Err(e) = std::fs::write("openapi.json", json) {
eprintln!("{}", e);
println!("Failed to write OpenAPI documentation. {}", e);
} else {
println!("OpenAPI documentation generated successfully.");
}
}
}
+53
View File
@@ -0,0 +1,53 @@
use tonic::{Request, Response, Status};
use crate::pb::im::internal_auth_service_server::InternalAuthService as InternalAuthServiceTrait;
use crate::pb::im::{AuthenticateRequest, AuthenticateResponse};
use crate::service::internal_auth::InternalAuthService;
pub struct InternalAuthGrpcService {
service: InternalAuthService,
}
impl InternalAuthGrpcService {
pub fn new(service: InternalAuthService) -> Self {
Self { service }
}
}
#[tonic::async_trait]
impl InternalAuthServiceTrait for InternalAuthGrpcService {
async fn authenticate(
&self,
request: Request<AuthenticateRequest>,
) -> Result<Response<AuthenticateResponse>, Status> {
let req = request.into_inner();
if req.api_key.is_empty() {
return Ok(Response::new(AuthenticateResponse {
authenticated: false,
service_name: String::new(),
service_id: String::new(),
scopes: vec![],
expires_at: 0,
}));
}
match self.service.verify_api_key(&req.api_key).await {
Ok(Some(identity)) => Ok(Response::new(AuthenticateResponse {
authenticated: true,
service_name: identity.service_name,
service_id: identity.service_id,
scopes: identity.scopes,
expires_at: identity.expires_at,
})),
Ok(None) => Ok(Response::new(AuthenticateResponse {
authenticated: false,
service_name: String::new(),
service_id: String::new(),
scopes: vec![],
expires_at: 0,
})),
Err(e) => Err(Status::internal(format!("auth verification failed: {e}"))),
}
}
}
+443
View File
@@ -0,0 +1,443 @@
use tonic::{Request, Response, Status};
use uuid::Uuid;
use crate::models::channels::ChannelStats;
use crate::models::common::{ChannelKind, ChannelType, Visibility};
use crate::models::workspaces::Workspace;
use crate::pb::im::channel_service_server::ChannelService;
use crate::pb::im::{
CreateCategoryRequest, CreateCategoryResponse, CreateChannelRequest, CreateChannelResponse,
DeleteCategoryRequest, DeleteCategoryResponse, DeleteChannelRequest, DeleteChannelResponse,
GetChannelRequest, GetChannelResponse, GetChannelStatsRequest, GetChannelStatsResponse,
ListCategoriesRequest, ListCategoriesResponse, ListChannelsRequest, ListChannelsResponse,
UpdateCategoryRequest, UpdateCategoryResponse, UpdateChannelRequest, UpdateChannelResponse,
};
use crate::service::im::categories::{CreateCategoryParams, UpdateCategoryParams};
use crate::service::im::channels::{ChannelListFilters, CreateChannelParams, UpdateChannelParams};
use crate::service::im::session::ImSession;
use crate::service::AppService;
pub struct ChannelGrpcService {
service: AppService,
}
impl ChannelGrpcService {
pub fn new(service: AppService) -> Self {
Self { service }
}
fn system_session() -> ImSession {
ImSession::new(Uuid::nil())
}
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, Status> {
Uuid::parse_str(s).map_err(|e| Status::invalid_argument(format!("{field}: {e}")))
}
async fn resolve_workspace_name(&self, workspace_id: Uuid) -> Result<String, Status> {
Workspace::find_by_id(self.service.ctx.db.reader(), workspace_id)
.await
.map_err(|e| Status::internal(e.to_string()))?
.map(|w| w.name)
.ok_or_else(|| Status::not_found("workspace not found"))
}
fn to_proto_timestamp(dt: chrono::DateTime<chrono::Utc>) -> prost_types::Timestamp {
prost_types::Timestamp {
seconds: dt.timestamp(),
nanos: dt.timestamp_subsec_nanos() as i32,
}
}
fn model_channel_to_proto(c: crate::models::channels::Channel) -> crate::pb::im::Channel {
let channel_type = match c.channel_type {
ChannelType::Public => crate::pb::im::ChannelType::Public,
ChannelType::Private => crate::pb::im::ChannelType::Private,
ChannelType::Direct => crate::pb::im::ChannelType::Direct,
ChannelType::Group => crate::pb::im::ChannelType::Group,
ChannelType::Repo => crate::pb::im::ChannelType::Repo,
ChannelType::System => crate::pb::im::ChannelType::System,
ChannelType::Unknown => crate::pb::im::ChannelType::Unspecified,
};
let channel_kind = match c.channel_kind {
ChannelKind::Text => crate::pb::im::ChannelKind::Text,
ChannelKind::Voice => crate::pb::im::ChannelKind::Voice,
ChannelKind::Stage => crate::pb::im::ChannelKind::Stage,
ChannelKind::Forum => crate::pb::im::ChannelKind::Forum,
ChannelKind::Announcement => crate::pb::im::ChannelKind::Announcement,
ChannelKind::Unknown => crate::pb::im::ChannelKind::Unspecified,
};
let visibility = match c.visibility {
Visibility::Public => crate::pb::im::Visibility::Public,
Visibility::Private => crate::pb::im::Visibility::Private,
Visibility::Internal => crate::pb::im::Visibility::Internal,
Visibility::Workspace => crate::pb::im::Visibility::Workspace,
Visibility::Protected => crate::pb::im::Visibility::Protected,
Visibility::Hidden => crate::pb::im::Visibility::Hidden,
Visibility::Secret => crate::pb::im::Visibility::Secret,
Visibility::Unknown => crate::pb::im::Visibility::Unspecified,
};
crate::pb::im::Channel {
id: c.id.to_string(),
workspace_id: c.workspace_id.to_string(),
category_id: c.category_id.map(|id| id.to_string()),
parent_channel_id: c.parent_channel_id.map(|id| id.to_string()),
name: c.name,
topic: c.topic,
description: c.description,
channel_type: channel_type.into(),
channel_kind: channel_kind.into(),
visibility: visibility.into(),
position: c.position.unwrap_or(0),
nsfw: c.nsfw,
read_only: c.read_only,
archived: c.archived,
created_by: Some(c.created_by.to_string()),
rate_limit_per_user: c.rate_limit_per_user,
archived_at: c.archived_at.map(Self::to_proto_timestamp),
last_message_id: c.last_message_id.map(|id| id.to_string()),
last_message_at: c.last_message_at.map(Self::to_proto_timestamp),
created_at: Some(Self::to_proto_timestamp(c.created_at)),
updated_at: Some(Self::to_proto_timestamp(c.updated_at)),
}
}
fn model_category_to_proto(
c: crate::models::channels::ChannelCategory,
) -> crate::pb::im::ChannelCategory {
crate::pb::im::ChannelCategory {
id: c.id.to_string(),
workspace_id: c.workspace_id.to_string(),
name: c.name,
position: c.position,
collapsed: c.collapsed,
created_at: Some(Self::to_proto_timestamp(c.created_at)),
updated_at: Some(Self::to_proto_timestamp(c.updated_at)),
}
}
fn model_stats_to_proto(s: ChannelStats) -> crate::pb::im::ChannelStats {
crate::pb::im::ChannelStats {
channel_id: s.channel_id.to_string(),
members_count: s.members_count as i32,
messages_count: s.messages_count as i32,
threads_count: s.threads_count as i32,
reactions_count: s.reactions_count as i32,
mentions_count: s.mentions_count as i32,
files_count: s.files_count as i32,
last_activity_at: s.last_activity_at.map(Self::to_proto_timestamp),
updated_at: Some(Self::to_proto_timestamp(s.updated_at)),
}
}
async fn resolve_category_workspace(&self, category_id: Uuid) -> Result<String, Status> {
let workspace_id: Uuid = sqlx::query_scalar(
"SELECT workspace_id FROM channel_category WHERE id = $1",
)
.bind(category_id)
.fetch_optional(self.service.ctx.db.reader())
.await
.map_err(|e| Status::internal(e.to_string()))?
.ok_or_else(|| Status::not_found("category not found"))?;
self.resolve_workspace_name(workspace_id).await
}
}
#[tonic::async_trait]
impl ChannelService for ChannelGrpcService {
async fn get_channel(
&self,
request: Request<GetChannelRequest>,
) -> Result<Response<GetChannelResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let session = Self::system_session();
let channel = self
.service
.im
.resolve_channel(channel_id)
.await
.map_err(|e| Status::internal(e.to_string()))?;
let wk_name = self.resolve_workspace_name(channel.workspace_id).await?;
let channel = self
.service
.im
.channel_get(&session, &wk_name, channel_id)
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(GetChannelResponse {
channel: Some(Self::model_channel_to_proto(channel)),
}))
}
async fn list_channels(
&self,
request: Request<ListChannelsRequest>,
) -> Result<Response<ListChannelsResponse>, Status> {
let req = request.into_inner();
let session = Self::system_session();
let channel_type = req.channel_type()
.as_str_name()
.strip_prefix("CHANNEL_TYPE_")
.map(|s| s.to_lowercase())
.filter(|s| s != "unspecified");
let channel_kind = req.channel_kind()
.as_str_name()
.strip_prefix("CHANNEL_KIND_")
.map(|s| s.to_lowercase())
.filter(|s| s != "unspecified");
let filters = ChannelListFilters {
channel_type,
channel_kind,
category_id: req.category_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()),
archived: None,
};
let channels = self
.service
.im
.channel_list(
&session,
&req.workspace_name,
filters,
req.limit as i64,
req.offset as i64,
)
.await
.map_err(|e| Status::internal(e.to_string()))?;
let total = channels.len() as i32;
let proto_channels: Vec<_> = channels
.into_iter()
.map(Self::model_channel_to_proto)
.collect();
Ok(Response::new(ListChannelsResponse {
channels: proto_channels,
total,
}))
}
async fn create_channel(
&self,
request: Request<CreateChannelRequest>,
) -> Result<Response<CreateChannelResponse>, Status> {
let req = request.into_inner();
let session = Self::system_session();
let params = CreateChannelParams {
name: req.name,
topic: req.topic,
description: req.description,
channel_type: req.channel_type,
channel_kind: req.channel_kind,
visibility: req.visibility,
category_id: req.category_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()),
parent_channel_id: req.parent_channel_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()),
nsfw: None,
rate_limit_per_user: req.rate_limit_per_user,
};
let channel = self
.service
.im
.channel_create(&session, &req.workspace_name, params, Uuid::nil())
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(CreateChannelResponse {
channel: Some(Self::model_channel_to_proto(channel)),
}))
}
async fn update_channel(
&self,
request: Request<UpdateChannelRequest>,
) -> Result<Response<UpdateChannelResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let session = Self::system_session();
let existing = self
.service
.im
.resolve_channel(channel_id)
.await
.map_err(|e| Status::internal(e.to_string()))?;
let wk_name = self.resolve_workspace_name(existing.workspace_id).await?;
let params = UpdateChannelParams {
name: req.name,
topic: req.topic,
description: req.description,
visibility: req.visibility,
category_id: req.category_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()),
position: req.position,
nsfw: req.nsfw,
rate_limit_per_user: req.rate_limit_per_user,
archived: req.archived,
read_only: req.read_only,
};
let channel = self
.service
.im
.channel_update(&session, &wk_name, channel_id, params, Uuid::nil())
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(UpdateChannelResponse {
channel: Some(Self::model_channel_to_proto(channel)),
}))
}
async fn delete_channel(
&self,
request: Request<DeleteChannelRequest>,
) -> Result<Response<DeleteChannelResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let session = Self::system_session();
let existing = self
.service
.im
.resolve_channel(channel_id)
.await
.map_err(|e| Status::internal(e.to_string()))?;
let wk_name = self.resolve_workspace_name(existing.workspace_id).await?;
self.service
.im
.channel_delete(&session, &wk_name, channel_id, Uuid::nil())
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(DeleteChannelResponse {}))
}
async fn get_channel_stats(
&self,
request: Request<GetChannelStatsRequest>,
) -> Result<Response<GetChannelStatsResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let stats = sqlx::query_as::<_, ChannelStats>(
"SELECT * FROM channel_stats WHERE channel_id = $1",
)
.bind(channel_id)
.fetch_optional(self.service.ctx.db.reader())
.await
.map_err(|e| Status::internal(e.to_string()))?;
match stats {
Some(s) => Ok(Response::new(GetChannelStatsResponse {
stats: Some(Self::model_stats_to_proto(s)),
})),
None => Err(Status::not_found("Channel stats not found")),
}
}
async fn list_categories(
&self,
request: Request<ListCategoriesRequest>,
) -> Result<Response<ListCategoriesResponse>, Status> {
let req = request.into_inner();
let session = Self::system_session();
let categories = self
.service
.im
.category_list(&session, &req.workspace_name)
.await
.map_err(|e| Status::internal(e.to_string()))?;
let proto_categories: Vec<_> = categories
.into_iter()
.map(Self::model_category_to_proto)
.collect();
Ok(Response::new(ListCategoriesResponse {
categories: proto_categories,
}))
}
async fn create_category(
&self,
request: Request<CreateCategoryRequest>,
) -> Result<Response<CreateCategoryResponse>, Status> {
let req = request.into_inner();
let session = Self::system_session();
let params = CreateCategoryParams {
name: req.name,
position: req.position,
};
let category = self
.service
.im
.category_create(&session, &req.workspace_name, params)
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(CreateCategoryResponse {
category: Some(Self::model_category_to_proto(category)),
}))
}
async fn update_category(
&self,
request: Request<UpdateCategoryRequest>,
) -> Result<Response<UpdateCategoryResponse>, Status> {
let req = request.into_inner();
let category_id = Self::parse_uuid(&req.category_id, "category_id")?;
let session = Self::system_session();
let wk_name = self.resolve_category_workspace(category_id).await?;
let params = UpdateCategoryParams {
name: req.name,
position: req.position,
collapsed: req.collapsed,
};
let category = self
.service
.im
.category_update(&session, &wk_name, category_id, params)
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(UpdateCategoryResponse {
category: Some(Self::model_category_to_proto(category)),
}))
}
async fn delete_category(
&self,
request: Request<DeleteCategoryRequest>,
) -> Result<Response<DeleteCategoryResponse>, Status> {
let req = request.into_inner();
let category_id = Self::parse_uuid(&req.category_id, "category_id")?;
let session = Self::system_session();
let wk_name = self.resolve_category_workspace(category_id).await?;
self.service
.im
.category_delete(&session, &wk_name, category_id)
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(DeleteCategoryResponse {}))
}
}
File diff suppressed because it is too large Load Diff
+243
View File
@@ -0,0 +1,243 @@
use tonic::{Request, Response, Status};
use uuid::Uuid;
use crate::models::channels::ChannelMember;
use crate::pb::im::member_service_server::MemberService;
use crate::pb::im::{
ChannelMember as PbChannelMember, InviteMemberRequest, InviteMemberResponse,
IsMemberRequest, IsMemberResponse, JoinChannelRequest, JoinChannelResponse,
KickMemberRequest, KickMemberResponse, LeaveChannelRequest, LeaveChannelResponse,
ListMembersRequest, ListMembersResponse, UpdateMemberRequest, UpdateMemberResponse,
};
use crate::service::im::session::ImSession;
use crate::service::im::members::{InviteMemberParams, UpdateMemberParams};
use crate::service::AppService;
pub struct MemberGrpcService {
service: AppService,
}
impl MemberGrpcService {
pub fn new(service: AppService) -> Self {
Self { service }
}
async fn resolve_workspace_name(&self, channel_id: Uuid) -> Result<String, Status> {
let channel = self
.service
.im
.resolve_channel(channel_id)
.await
.map_err(|e| Status::internal(e.to_string()))?;
let ws_name: String = sqlx::query_scalar("SELECT name FROM workspace WHERE id = $1")
.bind(channel.workspace_id)
.fetch_optional(self.service.ctx.db.reader())
.await
.map_err(|e| Status::internal(e.to_string()))?
.ok_or_else(|| Status::not_found("workspace not found"))?;
Ok(ws_name)
}
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, Status> {
Uuid::parse_str(s).map_err(|_| Status::invalid_argument(format!("invalid {}", field)))
}
fn to_timestamp(dt: chrono::DateTime<chrono::Utc>) -> prost_types::Timestamp {
prost_types::Timestamp {
seconds: dt.timestamp(),
nanos: dt.timestamp_subsec_nanos() as i32,
}
}
fn to_pb_member(m: ChannelMember) -> PbChannelMember {
PbChannelMember {
id: m.id.to_string(),
channel_id: m.channel_id.to_string(),
user_id: m.user_id.to_string(),
role: m.role.to_string(),
status: m.status.to_string(),
muted: m.muted,
pinned: m.pinned,
last_read_message_id: m.last_read_message_id.map(|id| id.to_string()),
last_read_at: m.last_read_at.map(Self::to_timestamp),
joined_at: m.joined_at.map(Self::to_timestamp),
left_at: m.left_at.map(Self::to_timestamp),
created_at: Some(Self::to_timestamp(m.created_at)),
updated_at: Some(Self::to_timestamp(m.updated_at)),
}
}
}
#[tonic::async_trait]
impl MemberService for MemberGrpcService {
async fn list_members(
&self,
request: Request<ListMembersRequest>,
) -> Result<Response<ListMembersResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let wk_name = self.resolve_workspace_name(channel_id).await?;
let session = ImSession::new(Uuid::nil());
let members = self
.service
.im
.member_list(&session, &wk_name, channel_id, req.limit as i64, req.offset as i64)
.await
.map_err(|e| Status::internal(e.to_string()))?;
let pb_members: Vec<PbChannelMember> = members.into_iter().map(Self::to_pb_member).collect();
let total = pb_members.len() as i32;
Ok(Response::new(ListMembersResponse {
members: pb_members,
total,
}))
}
async fn invite_member(
&self,
request: Request<InviteMemberRequest>,
) -> Result<Response<InviteMemberResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
let wk_name = self.resolve_workspace_name(channel_id).await?;
let params = InviteMemberParams {
user_id,
role: req.role,
};
let session = ImSession::new(Uuid::nil());
let member = self
.service
.im
.member_invite(&session, &wk_name, channel_id, params)
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(InviteMemberResponse {
member: Some(Self::to_pb_member(member)),
}))
}
async fn update_member(
&self,
request: Request<UpdateMemberRequest>,
) -> Result<Response<UpdateMemberResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
let wk_name = self.resolve_workspace_name(channel_id).await?;
let params = UpdateMemberParams {
role: req.role,
muted: req.muted,
pinned: req.pinned,
};
let session = ImSession::new(Uuid::nil());
let member = self
.service
.im
.member_update(&session, &wk_name, channel_id, user_id, params)
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(UpdateMemberResponse {
member: Some(Self::to_pb_member(member)),
}))
}
async fn kick_member(
&self,
request: Request<KickMemberRequest>,
) -> Result<Response<KickMemberResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
let wk_name = self.resolve_workspace_name(channel_id).await?;
let session = ImSession::new(Uuid::nil());
self.service
.im
.member_kick(&session, &wk_name, channel_id, user_id)
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(KickMemberResponse {}))
}
async fn join_channel(
&self,
request: Request<JoinChannelRequest>,
) -> Result<Response<JoinChannelResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
let wk_name = self.resolve_workspace_name(channel_id).await?;
let session = ImSession::new(user_id);
let member = self
.service
.im
.member_join(&session, &wk_name, channel_id)
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(JoinChannelResponse {
member: Some(Self::to_pb_member(member)),
}))
}
async fn leave_channel(
&self,
request: Request<LeaveChannelRequest>,
) -> Result<Response<LeaveChannelResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
let wk_name = self.resolve_workspace_name(channel_id).await?;
let session = ImSession::new(user_id);
self.service
.im
.member_leave(&session, &wk_name, channel_id)
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(LeaveChannelResponse {}))
}
async fn is_member(
&self,
request: Request<IsMemberRequest>,
) -> Result<Response<IsMemberResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
let is_member = self
.service
.im
.is_channel_member(channel_id, user_id)
.await
.map_err(|e| Status::internal(e.to_string()))?;
let role = if is_member {
self.service
.im
.channel_member_role(channel_id, user_id)
.await
.map_err(|e| Status::internal(e.to_string()))?
.to_string()
} else {
String::new()
};
Ok(Response::new(IsMemberResponse { is_member, role }))
}
}
+60
View File
@@ -0,0 +1,60 @@
pub mod auth;
pub mod channel;
pub mod channel_settings;
pub mod member;
pub mod permission;
use std::net::SocketAddr;
use crate::pb::im::channel_audit_service_server::ChannelAuditServiceServer;
use crate::pb::im::channel_invitation_service_server::ChannelInvitationServiceServer;
use crate::pb::im::channel_repo_link_service_server::ChannelRepoLinkServiceServer;
use crate::pb::im::channel_role_service_server::ChannelRoleServiceServer;
use crate::pb::im::channel_service_server::ChannelServiceServer;
use crate::pb::im::channel_slash_command_service_server::ChannelSlashCommandServiceServer;
use crate::pb::im::channel_webhook_service_server::ChannelWebhookServiceServer;
use crate::pb::im::custom_emoji_service_server::CustomEmojiServiceServer;
use crate::pb::im::forum_tag_service_server::ForumTagServiceServer;
use crate::pb::im::im_integration_service_server::ImIntegrationServiceServer;
use crate::pb::im::internal_auth_service_server::InternalAuthServiceServer;
use crate::pb::im::member_service_server::MemberServiceServer;
use crate::pb::im::permission_service_server::PermissionServiceServer;
use crate::pb::im::stage_service_server::StageServiceServer;
use crate::pb::im::voice_service_server::VoiceServiceServer;
use crate::service::AppService;
pub async fn start_grpc_server(
addr: SocketAddr,
service: AppService,
) -> Result<(), Box<dyn std::error::Error>> {
let auth_service = service.internal_auth.clone();
let channel_svc = channel::ChannelGrpcService::new(service.clone());
let member_svc = member::MemberGrpcService::new(service.clone());
let permission_svc = permission::PermissionGrpcService::new(service.clone());
let internal_auth_svc = auth::InternalAuthGrpcService::new(auth_service);
let cs = channel_settings::ChannelSettingsServices::new(service);
tracing::info!(%addr, "gRPC server listening");
tonic::transport::Server::builder()
.add_service(InternalAuthServiceServer::new(internal_auth_svc))
.add_service(ChannelServiceServer::new(channel_svc))
.add_service(MemberServiceServer::new(member_svc))
.add_service(PermissionServiceServer::new(permission_svc))
.add_service(ChannelRoleServiceServer::new(cs.channel_role))
.add_service(ChannelInvitationServiceServer::new(cs.channel_invitation))
.add_service(ChannelWebhookServiceServer::new(cs.channel_webhook))
.add_service(ChannelSlashCommandServiceServer::new(cs.channel_slash_command))
.add_service(ChannelRepoLinkServiceServer::new(cs.channel_repo_link))
.add_service(ImIntegrationServiceServer::new(cs.im_integration))
.add_service(CustomEmojiServiceServer::new(cs.custom_emoji))
.add_service(ForumTagServiceServer::new(cs.forum_tag))
.add_service(VoiceServiceServer::new(cs.voice))
.add_service(StageServiceServer::new(cs.stage))
.add_service(ChannelAuditServiceServer::new(cs.channel_audit))
.serve(addr)
.await?;
Ok(())
}
+360
View File
@@ -0,0 +1,360 @@
use tonic::{Request, Response, Status};
use uuid::Uuid;
use crate::models::channels::ChannelPermissionOverwrite;
use crate::models::common::{OverwriteTarget, Role};
use crate::pb::im::permission_service_server::PermissionService;
use crate::pb::im::{
CheckPermissionRequest, CheckPermissionResponse, DeletePermissionOverwriteRequest,
DeletePermissionOverwriteResponse, EnsureReadableRequest, EnsureReadableResponse,
GetPermissionOverwritesRequest, GetPermissionOverwritesResponse, GetPermissionsRequest,
GetPermissionsResponse, PermissionOverwrite, ResolveChannelRequest, ResolveChannelResponse,
SetPermissionOverwriteRequest, SetPermissionOverwriteResponse,
};
use crate::service::util::role_level;
use crate::service::AppService;
pub struct PermissionGrpcService {
service: AppService,
}
impl PermissionGrpcService {
pub fn new(service: AppService) -> Self {
Self { service }
}
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, Status> {
Uuid::parse_str(s).map_err(|e| Status::invalid_argument(format!("{field}: {e}")))
}
fn im_permission_to_str(v: i32) -> &'static str {
match v {
1 => "READ_CHANNEL",
2 => "SEND_MESSAGE",
3 => "MANAGE_THREADS",
4 => "MANAGE_REACTIONS",
5 => "MANAGE_PINS",
6 => "INVITE_MEMBERS",
7 => "KICK_MEMBERS",
8 => "MANAGE_CHANNEL",
9 => "MANAGE_ROLES",
10 => "MANAGE_WEBHOOKS",
11 => "MANAGE_EMOJIS",
12 => "VIEW_AUDIT_LOG",
13 => "MANAGE_INTEGRATIONS",
14 => "SEND_TTS",
15 => "USE_SLASH_COMMANDS",
16 => "ATTACH_FILES",
17 => "MENTION_EVERYONE",
18 => "MANAGE_MESSAGES",
19 => "ADMIN",
_ => "UNSPECIFIED",
}
}
fn str_to_im_permission(s: &str) -> i32 {
match s {
"READ_CHANNEL" => 1,
"SEND_MESSAGE" => 2,
"MANAGE_THREADS" => 3,
"MANAGE_REACTIONS" => 4,
"MANAGE_PINS" => 5,
"INVITE_MEMBERS" => 6,
"KICK_MEMBERS" => 7,
"MANAGE_CHANNEL" => 8,
"MANAGE_ROLES" => 9,
"MANAGE_WEBHOOKS" => 10,
"MANAGE_EMOJIS" => 11,
"VIEW_AUDIT_LOG" => 12,
"MANAGE_INTEGRATIONS" => 13,
"SEND_TTS" => 14,
"USE_SLASH_COMMANDS" => 15,
"ATTACH_FILES" => 16,
"MENTION_EVERYONE" => 17,
"MANAGE_MESSAGES" => 18,
"ADMIN" => 19,
_ => 0,
}
}
fn permission_requires_role(p: i32) -> Role {
match p {
1 => Role::Viewer,
2 => Role::Member,
3 => Role::Member,
4 => Role::Member,
5 => Role::Moderator,
6 => Role::Moderator,
7 => Role::Moderator,
8 => Role::Admin,
9 => Role::Admin,
10 => Role::Admin,
11 => Role::Admin,
12 => Role::Moderator,
13 => Role::Admin,
14 => Role::Member,
15 => Role::Member,
16 => Role::Member,
17 => Role::Moderator,
18 => Role::Moderator,
19 => Role::Admin,
_ => Role::Owner,
}
}
fn role_to_permissions(role: Role) -> Vec<i32> {
let level = role_level(role);
let mut perms = Vec::new();
if level >= role_level(Role::Viewer) {
perms.push(1);
}
if level >= role_level(Role::Member) {
perms.extend_from_slice(&[2, 3, 4, 14, 15, 16]);
}
if level >= role_level(Role::Moderator) {
perms.extend_from_slice(&[5, 6, 7, 12, 17, 18]);
}
if level >= role_level(Role::Admin) {
perms.extend_from_slice(&[8, 9, 10, 11, 13, 19]);
}
perms.sort();
perms.dedup();
perms
}
fn overwrite_to_proto(o: ChannelPermissionOverwrite) -> PermissionOverwrite {
PermissionOverwrite {
id: o.id.to_string(),
channel_id: o.channel_id.to_string(),
target_type: o.target_type.as_str().to_string(),
target_id: o.target_id.to_string(),
allow: o
.allow
.iter()
.map(|p| Self::str_to_im_permission(p))
.collect(),
deny: o
.deny
.iter()
.map(|p| Self::str_to_im_permission(p))
.collect(),
created_at: o.created_at.to_rfc3339(),
updated_at: o.updated_at.to_rfc3339(),
}
}
}
#[tonic::async_trait]
impl PermissionService for PermissionGrpcService {
async fn check_permission(
&self,
request: Request<CheckPermissionRequest>,
) -> Result<Response<CheckPermissionResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let user_uid = Self::parse_uuid(&req.user_id, "user_id")?;
let channel = self
.service
.im
.resolve_channel(channel_id)
.await
.map_err(|e| Status::not_found(e.to_string()))?;
let role = self
.service
.im
.channel_member_role(channel.id, user_uid)
.await
.map_err(|e| Status::internal(e.to_string()))?;
let required_role = Self::permission_requires_role(req.permission);
let allowed = role_level(role) >= role_level(required_role);
Ok(Response::new(CheckPermissionResponse {
allowed,
role: role.as_str().to_string(),
}))
}
async fn get_permissions(
&self,
request: Request<GetPermissionsRequest>,
) -> Result<Response<GetPermissionsResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let user_uid = Self::parse_uuid(&req.user_id, "user_id")?;
let channel = self
.service
.im
.resolve_channel(channel_id)
.await
.map_err(|e| Status::not_found(e.to_string()))?;
let role = self
.service
.im
.channel_member_role(channel.id, user_uid)
.await
.map_err(|e| Status::internal(e.to_string()))?;
let permissions = Self::role_to_permissions(role);
Ok(Response::new(GetPermissionsResponse {
permissions,
role: role.as_str().to_string(),
}))
}
async fn set_permission_overwrite(
&self,
request: Request<SetPermissionOverwriteRequest>,
) -> Result<Response<SetPermissionOverwriteResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let target_id = Self::parse_uuid(&req.target_id, "target_id")?;
let target_type: OverwriteTarget = req.target_type.parse().unwrap_or(OverwriteTarget::Unknown);
let allow: Vec<String> = req
.allow
.into_iter()
.map(|v| Self::im_permission_to_str(v).to_string())
.collect();
let deny: Vec<String> = req
.deny
.into_iter()
.map(|v| Self::im_permission_to_str(v).to_string())
.collect();
let now = chrono::Utc::now();
let id = Uuid::new_v4();
let overwrite = sqlx::query_as::<_, ChannelPermissionOverwrite>(
"INSERT INTO channel_permission_overwrite \
(id, channel_id, target_type, target_id, allow, deny, created_by, created_at, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
ON CONFLICT (channel_id, target_type, target_id) \
DO UPDATE SET allow = $5, deny = $6, updated_at = $9 \
RETURNING id, channel_id, target_type, target_id, allow, deny, created_by, created_at, updated_at",
)
.bind(id)
.bind(channel_id)
.bind(target_type)
.bind(target_id)
.bind(&allow)
.bind(&deny)
.bind(Uuid::nil())
.bind(now)
.bind(now)
.fetch_one(self.service.ctx.db.writer())
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(SetPermissionOverwriteResponse {
overwrite: Some(Self::overwrite_to_proto(overwrite)),
}))
}
async fn get_permission_overwrites(
&self,
request: Request<GetPermissionOverwritesRequest>,
) -> Result<Response<GetPermissionOverwritesResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let overwrites = sqlx::query_as::<_, ChannelPermissionOverwrite>(
"SELECT id, channel_id, target_type, target_id, allow, deny, created_by, created_at, updated_at \
FROM channel_permission_overwrite WHERE channel_id = $1",
)
.bind(channel_id)
.fetch_all(self.service.ctx.db.reader())
.await
.map_err(|e| Status::internal(e.to_string()))?;
let proto_overwrites: Vec<_> = overwrites
.into_iter()
.map(Self::overwrite_to_proto)
.collect();
Ok(Response::new(GetPermissionOverwritesResponse {
overwrites: proto_overwrites,
}))
}
async fn delete_permission_overwrite(
&self,
request: Request<DeletePermissionOverwriteRequest>,
) -> Result<Response<DeletePermissionOverwriteResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let target_id = Self::parse_uuid(&req.target_id, "target_id")?;
let target_type: OverwriteTarget = req.target_type.parse().unwrap_or(OverwriteTarget::Unknown);
sqlx::query(
"DELETE FROM channel_permission_overwrite \
WHERE channel_id = $1 AND target_type = $2 AND target_id = $3",
)
.bind(channel_id)
.bind(target_type)
.bind(target_id)
.execute(self.service.ctx.db.writer())
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(DeletePermissionOverwriteResponse {}))
}
async fn resolve_channel(
&self,
request: Request<ResolveChannelRequest>,
) -> Result<Response<ResolveChannelResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let channel = self
.service
.im
.resolve_channel(channel_id)
.await
.map_err(|e| Status::not_found(e.to_string()))?;
Ok(Response::new(ResolveChannelResponse {
channel_id: channel.id.to_string(),
workspace_id: channel.workspace_id.to_string(),
name: channel.name,
visibility: channel.visibility.as_str().to_string(),
channel_type: channel.channel_type.as_str().to_string(),
read_only: channel.read_only,
archived: channel.archived,
created_by: Some(channel.created_by.to_string()),
}))
}
async fn ensure_readable(
&self,
request: Request<EnsureReadableRequest>,
) -> Result<Response<EnsureReadableResponse>, Status> {
let req = request.into_inner();
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
let user_uid = Self::parse_uuid(&req.user_id, "user_id")?;
let channel = self
.service
.im
.resolve_channel(channel_id)
.await
.map_err(|e| Status::not_found(e.to_string()))?;
let allowed = self
.service
.im
.ensure_channel_readable(user_uid, &channel)
.await
.is_ok();
Ok(Response::new(EnsureReadableResponse { allowed }))
}
}
-200
View File
@@ -1,200 +0,0 @@
use std::sync::Arc;
use futures_util::StreamExt;
use uuid::Uuid;
use crate::queue::NatsQueue;
use super::{
ArticleEvent, CategoryEvent, DraftEvent, FollowEvent, MemberEvent, MessageEvent, PollEvent,
PresenceEvent, ReactionEvent, ThreadEvent, TypingEvent, WsOutbound, WsSessionManager,
WsSinkManager,
};
#[derive(Clone)]
pub struct NatsWsBridge {
queue: Arc<NatsQueue>,
sessions: Arc<WsSessionManager>,
sinks: Arc<WsSinkManager>,
}
impl NatsWsBridge {
pub fn new(
queue: Arc<NatsQueue>,
sessions: Arc<WsSessionManager>,
sinks: Arc<WsSinkManager>,
) -> Self {
Self {
queue,
sessions,
sinks,
}
}
pub async fn run_ephemeral(self, subject: &str) {
let Ok(mut sub) = self.queue.subscribe_ephemeral(subject.to_string()).await else {
tracing::warn!(subject, "nats ws bridge subscribe failed");
return;
};
while let Some(msg) = sub.next().await {
self.dispatch(msg.subject.as_str(), msg.payload.as_ref(), request_id(&msg))
.await;
}
}
async fn dispatch(&self, subject: &str, payload: &[u8], request_id: Uuid) {
if subject.starts_with("im.message.") {
self.channel_event(payload, |data| WsOutbound::Message { request_id, data });
} else if subject.starts_with("im.thread.") {
self.channel_event(payload, |data| WsOutbound::Thread { request_id, data });
} else if subject.starts_with("im.member.") {
self.channel_event(payload, |data| WsOutbound::Member { request_id, data });
} else if subject.starts_with("im.reaction.") {
self.channel_event(payload, |data| WsOutbound::Reaction { request_id, data });
} else if subject.starts_with("im.poll.") {
self.channel_event(payload, |data| WsOutbound::Poll { request_id, data });
} else if subject.starts_with("im.article.") {
self.channel_event(payload, |data| WsOutbound::Article { request_id, data });
} else if subject.starts_with("im.typing.") {
self.channel_event(payload, |data| WsOutbound::Typing { request_id, data });
} else if subject.starts_with("im.presence.") {
self.presence_event(payload, request_id);
} else if subject.starts_with("im.channel.") {
self.channel_meta_event(subject, payload, request_id);
} else if subject.starts_with("im.category.") {
self.category_event(payload, request_id);
} else if subject.starts_with("im.draft.") {
self.draft_event(payload, request_id);
} else if subject.starts_with("im.follow.") {
self.channel_event(payload, |data| WsOutbound::Follow { request_id, data });
}
}
fn channel_event<T, F>(&self, payload: &[u8], build: F)
where
T: serde::de::DeserializeOwned + ChannelScoped,
F: Fn(T) -> WsOutbound,
{
let Ok(data) = serde_json::from_slice::<T>(payload) else {
tracing::warn!("nats ws bridge decode channel event failed");
return;
};
let channel_id = data.channel_id();
let subscribers = self.sessions.subscribers(channel_id);
let delivered = self.sinks.send_many(subscribers, build(data));
tracing::debug!(%channel_id, delivered, "nats event forwarded to ws subscribers");
}
fn presence_event(&self, payload: &[u8], request_id: Uuid) {
let Ok(data) = serde_json::from_slice::<PresenceEvent>(payload) else {
tracing::warn!("nats ws bridge decode presence event failed");
return;
};
let ids = self.sessions.user_connections(data.user_id);
let delivered = self
.sinks
.send_many(ids, WsOutbound::Presence { request_id, data });
tracing::debug!(delivered, "nats presence forwarded to ws subscribers");
}
fn category_event(&self, payload: &[u8], request_id: Uuid) {
let Ok(data) = serde_json::from_slice::<CategoryEvent>(payload) else {
tracing::warn!("nats ws bridge decode category event failed");
return;
};
let targets = self.sessions.workspace_connections(&data.workspace_name);
let delivered = self
.sinks
.send_many(targets, WsOutbound::Category { request_id, data });
tracing::debug!(delivered, "nats category event forwarded to ws subscribers");
}
fn draft_event(&self, payload: &[u8], request_id: Uuid) {
let Ok(data) = serde_json::from_slice::<DraftEvent>(payload) else {
tracing::warn!("nats ws bridge decode draft event failed");
return;
};
let targets = self.sessions.user_connections(data.user_id);
let delivered = self
.sinks
.send_many(targets, WsOutbound::Draft { request_id, data });
tracing::debug!(delivered, "nats draft event forwarded to ws subscribers");
}
fn channel_meta_event(&self, subject: &str, payload: &[u8], request_id: Uuid) {
let Ok(data) = serde_json::from_slice::<super::ChannelEvent>(payload) else {
tracing::warn!("nats ws bridge decode channel event failed");
return;
};
let mut targets = data
.workspace_name
.as_deref()
.map(|workspace| self.sessions.workspace_connections(workspace))
.unwrap_or_else(|| self.sessions.subscribers(data.channel_id));
if targets.is_empty()
&& let Some(id) = subject
.rsplit('.')
.next()
.and_then(|v| v.parse::<Uuid>().ok())
{
targets = self.sessions.subscribers(id);
}
let delivered = self
.sinks
.send_many(targets, WsOutbound::Channel { request_id, data });
tracing::debug!(delivered, "nats channel event forwarded to ws subscribers");
}
}
pub trait ChannelScoped {
fn channel_id(&self) -> Uuid;
}
impl ChannelScoped for MessageEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for ThreadEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for MemberEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for ReactionEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for PollEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for ArticleEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for TypingEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
impl ChannelScoped for FollowEvent {
fn channel_id(&self) -> Uuid {
self.channel_id
}
}
fn request_id(msg: &async_nats::Message) -> Uuid {
msg.headers
.as_ref()
.and_then(|h| h.get("X-Request-Id"))
.and_then(|v| v.as_str().parse().ok())
.unwrap_or_else(Uuid::nil)
}
-58
View File
@@ -1,58 +0,0 @@
use uuid::Uuid;
use crate::cache::redis::AppRedis;
use crate::error::AppResult;
use ::redis::Cmd;
use super::redis_keys::*;
pub struct DedupManager {
redis: AppRedis,
window_secs: u64,
}
impl DedupManager {
pub fn new(redis: AppRedis) -> Self {
Self {
redis,
window_secs: WS_DEDUP_WINDOW_SECS,
}
}
pub fn check_and_mark(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<bool> {
let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}");
let mut conn = self.redis.get_connection()?;
let result: Option<String> = Cmd::new()
.arg("SET")
.arg(&key)
.arg("1")
.arg("NX")
.arg("EX")
.arg(self.window_secs)
.query(&mut *conn.inner_mut())
.map_err(crate::error::AppError::Redis)?;
Ok(result.is_some())
}
pub fn is_duplicate(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<bool> {
let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}");
let mut conn = self.redis.get_connection()?;
let exists: bool = Cmd::new()
.arg("EXISTS")
.arg(&key)
.query(&mut *conn.inner_mut())
.map_err(crate::error::AppError::Redis)?;
Ok(exists)
}
pub fn clear(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<()> {
let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}");
let mut conn = self.redis.get_connection()?;
Cmd::new()
.arg("DEL")
.arg(&key)
.query::<()>(&mut *conn.inner_mut())
.map_err(crate::error::AppError::Redis)?;
Ok(())
}
}
-39
View File
@@ -1,39 +0,0 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransportEnvelope<T> {
#[serde(default = "Uuid::now_v7")]
pub message_id: Uuid,
pub request_id: Uuid,
pub user_id: Uuid,
pub payload: T,
#[serde(default = "default_timestamp")]
pub created_at: chrono::DateTime<chrono::Utc>,
#[serde(default)]
pub attempt: u8,
}
fn default_timestamp() -> chrono::DateTime<chrono::Utc> {
chrono::Utc::now()
}
impl<T> TransportEnvelope<T> {
pub fn new(request_id: Uuid, user_id: Uuid, payload: T) -> Self {
Self {
message_id: Uuid::now_v7(),
request_id,
user_id,
payload,
created_at: chrono::Utc::now(),
attempt: 1,
}
}
pub fn retry(self) -> Self {
Self {
attempt: self.attempt + 1,
..self
}
}
}
-447
View File
@@ -1,447 +0,0 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::immediate::dedup::DedupManager;
use crate::immediate::limiter::HandlerLimiter;
use crate::immediate::nats::ImNats;
use crate::immediate::outbound::*;
use crate::immediate::rate_limit::{LocalRateLimiter, RateLimiter};
use crate::immediate::reconnect::ReconnectManager;
use crate::immediate::session::{WsSession, WsSessionManager};
use crate::service::ImService;
use crate::service::im::messages::EditMessageParams;
use crate::service::im::messages::SendMessageParams;
use crate::service::im::presence::UpdatePresenceParams;
use crate::service::im::session::ImSession;
use super::inbound::WsInbound;
use super::redis_keys::*;
use super::sink::WsSinkManager;
#[allow(dead_code)]
#[derive(Clone)]
pub struct WsHandler {
nats: Arc<ImNats>,
manager: Arc<WsSessionManager>,
sinks: Arc<WsSinkManager>,
service: ImService,
dedup: Arc<DedupManager>,
rate_limiter: Arc<RateLimiter>,
local_limiter: Arc<LocalRateLimiter>,
handler_limiter: Arc<HandlerLimiter>,
reconnect: Arc<ReconnectManager>,
session: Option<WsSession>,
}
#[allow(dead_code)]
impl WsHandler {
pub fn new(
manager: Arc<WsSessionManager>,
sinks: Arc<WsSinkManager>,
service: ImService,
nats: Arc<ImNats>,
dedup: Arc<DedupManager>,
rate_limiter: Arc<RateLimiter>,
reconnect: Arc<ReconnectManager>,
) -> Self {
Self {
nats,
manager,
sinks,
service,
dedup,
rate_limiter,
local_limiter: Arc::new(LocalRateLimiter::new(WS_MAX_MESSAGES_PER_SEC)),
handler_limiter: Arc::new(HandlerLimiter::new(1024)),
reconnect,
session: None,
}
}
pub fn session(&self) -> Option<&WsSession> {
self.session.as_ref()
}
pub fn is_authenticated(&self) -> bool {
self.session.is_some()
}
pub fn handle_disconnect(&self) {
if let Some(s) = &self.session
&& let Err(e) = self.manager.unregister_connection(s)
{
tracing::warn!(conn = %s.connection_id, error = %e, "unregister failed");
}
}
pub async fn handle(&mut self, msg: WsInbound) -> Vec<WsOutbound> {
match msg {
WsInbound::Auth { request_id, token } => self.handle_auth(request_id, token).await,
m => {
let Some(s) = &self.session else {
return vec![WsOutbound::Error {
request_id: request_id_of(&m),
code: "not_authenticated".into(),
message: "authenticate first".into(),
}];
};
if !self.manager.is_deliverable(s.connection_id) {
return vec![WsOutbound::Error {
request_id: request_id_of(&m),
code: "session_not_active".into(),
message: "session is not active".into(),
}];
}
let Ok(_permit) = self.handler_limiter.try_acquire() else {
return vec![WsOutbound::Error {
request_id: request_id_of(&m),
code: "overloaded".into(),
message: "too many inflight messages".into(),
}];
};
if !self.local_limiter.check() {
return vec![WsOutbound::Error {
request_id: request_id_of(&m),
code: "rate_limit_exceeded".into(),
message: "too many messages".into(),
}];
}
match self.rate_limiter.check(s.connection_id) {
Ok(true) => {}
Ok(false) => {
return vec![WsOutbound::Error {
request_id: request_id_of(&m),
code: "rate_limit_exceeded".into(),
message: "too many messages".into(),
}];
}
Err(e) => tracing::warn!(error = %e, "rate limit check failed"),
}
self.dispatch(s, m).await
}
}
}
async fn dispatch(&self, session: &WsSession, msg: WsInbound) -> Vec<WsOutbound> {
match msg {
WsInbound::Heartbeat { request_id } => {
if let Err(e) = self.manager.heartbeat(session) {
tracing::warn!(user = %session.user_id, error = %e, "heartbeat failed");
}
vec![WsOutbound::HeartbeatAck {
request_id,
timestamp_ms: chrono::Utc::now().timestamp_millis(),
}]
}
WsInbound::JoinChannel {
request_id,
channel_id,
} => match self.service.resolve_channel(channel_id).await {
Ok(channel) => match self
.service
.ensure_channel_readable(session.user_id, &channel)
.await
{
Ok(()) => {
self.manager
.subscribe_channel(session.connection_id, channel_id);
vec![]
}
Err(e) => vec![WsOutbound::Error {
request_id,
code: "join_channel_failed".into(),
message: e.to_string(),
}],
},
Err(e) => vec![WsOutbound::Error {
request_id,
code: "join_channel_failed".into(),
message: e.to_string(),
}],
},
WsInbound::LeaveChannel {
request_id: _,
channel_id,
} => {
self.manager
.unsubscribe_channel(session.connection_id, channel_id);
vec![]
}
WsInbound::TypingStart {
request_id,
channel_id,
thread_id,
} => {
let _ = self
.manager
.set_typing(channel_id, thread_id, session.user_id);
self.nats
.emit(
&ImNats::typing_subject(channel_id),
request_id,
&TypingEvent {
channel_id,
thread_id,
user_id: session.user_id,
},
)
.await;
vec![]
}
WsInbound::TypingStop {
request_id: _,
channel_id,
thread_id,
} => {
let _ = self
.manager
.clear_typing(channel_id, thread_id, session.user_id);
vec![]
}
WsInbound::MessageSend {
request_id,
channel_id,
body,
thread_id,
reply_to,
message_type,
} => {
if body.len() > WS_MAX_MESSAGE_BYTES {
return vec![WsOutbound::Error {
request_id,
code: "message_too_large".into(),
message: "message body too large".into(),
}];
}
match self.dedup.check_and_mark(request_id, channel_id) {
Ok(true) => {}
Ok(false) => {
return vec![WsOutbound::Error {
request_id,
code: "duplicate".into(),
message: "duplicate message".into(),
}];
}
Err(e) => tracing::warn!(error = %e, "dedup check failed"),
}
let ctx = ImSession::new(session.user_id);
let params = SendMessageParams {
body,
message_type,
thread_id,
reply_to_message_id: reply_to,
pinned: None,
attachments: None,
embeds: None,
};
match self
.service
.message_send(
&ctx,
&session.workspace_name,
channel_id,
params,
request_id,
)
.await
{
Ok(msg) => vec![WsOutbound::SeqAck {
request_id,
channel_id,
seq: msg.seq,
}],
Err(e) => {
if let Err(clear_err) = self.dedup.clear(request_id, channel_id) {
tracing::warn!(error = %clear_err, "dedup clear failed after message send error");
}
vec![WsOutbound::Error {
request_id,
code: "message_send_failed".into(),
message: e.to_string(),
}]
}
}
}
WsInbound::MessageEdit {
request_id,
channel_id,
message_id,
body,
} => {
if body.len() > WS_MAX_MESSAGE_BYTES {
return vec![WsOutbound::Error {
request_id,
code: "message_too_large".into(),
message: "message body too large".into(),
}];
}
let ctx = ImSession::new(session.user_id);
let params = EditMessageParams { body };
match self
.service
.message_edit(
&ctx,
&session.workspace_name,
channel_id,
message_id,
params,
request_id,
)
.await
{
Ok(_) => vec![],
Err(e) => vec![WsOutbound::Error {
request_id,
code: "message_edit_failed".into(),
message: e.to_string(),
}],
}
}
WsInbound::MessageDelete {
request_id,
channel_id,
message_id,
} => {
let ctx = ImSession::new(session.user_id);
match self
.service
.message_delete(
&ctx,
&session.workspace_name,
channel_id,
message_id,
request_id,
)
.await
{
Ok(()) => vec![],
Err(e) => vec![WsOutbound::Error {
request_id,
code: "message_delete_failed".into(),
message: e.to_string(),
}],
}
}
WsInbound::PresenceUpdate {
request_id,
status,
custom_status_text,
custom_status_emoji,
} => {
let ctx = ImSession::new(session.user_id);
let params = UpdatePresenceParams {
status,
custom_status_text: custom_status_text.clone(),
custom_status_emoji: custom_status_emoji.clone(),
};
match self
.service
.presence_update(&ctx, &session.workspace_name, params)
.await
{
Ok(p) => {
self.nats
.emit(
&ImNats::presence_subject(session.user_id),
request_id,
&PresenceEvent {
user_id: session.user_id,
status: p.status.to_string(),
custom_status_text,
custom_status_emoji,
},
)
.await;
vec![]
}
Err(e) => vec![WsOutbound::Error {
request_id,
code: "presence_update_failed".into(),
message: e.to_string(),
}],
}
}
WsInbound::ReadReceipt {
request_id,
channel_id,
last_read_message_id,
last_seq,
} => {
if let Some(seq) = last_seq
&& let Err(e) =
self.reconnect
.save_read_position(session.user_id, channel_id, seq)
{
tracing::warn!(error = %e, "save read position failed");
}
vec![WsOutbound::ReadReceiptAck {
request_id,
channel_id,
last_read_message_id,
last_seq,
}]
}
WsInbound::Auth { .. } => unreachable!(),
}
}
fn close_replaced_connection(&self, old_id: Uuid, new_id: Uuid) {
let _ = self.sinks.send(
old_id,
WsOutbound::Error {
request_id: Uuid::nil(),
code: "session_replaced".into(),
message: format!("session replaced by {new_id}"),
},
);
self.sinks.detach(old_id);
if let Some(old) = self.manager.get_session(old_id)
&& let Err(e) = self.manager.unregister_connection(&old)
{
tracing::warn!(conn = %old_id, error = %e, "unregister replaced connection failed");
}
}
async fn handle_auth(&mut self, request_id: Uuid, token: String) -> Vec<WsOutbound> {
match self.manager.redeem_token(&token) {
Ok(session) => {
match self.manager.register_connection_with_replacement(&session) {
Ok(Some(old_id)) => {
self.close_replaced_connection(old_id, session.connection_id)
}
Ok(None) => {}
Err(e) => tracing::warn!(error = %e, "register connection failed"),
}
let cid = session.connection_id;
let interval = self.manager.heartbeat_interval_secs();
self.session = Some(session);
vec![WsOutbound::AuthOk {
request_id,
connection_id: cid,
heartbeat_interval_secs: interval,
}]
}
Err(e) => vec![WsOutbound::AuthError {
request_id,
message: e.to_string(),
}],
}
}
}
#[allow(dead_code)]
fn request_id_of(msg: &WsInbound) -> Uuid {
match msg {
WsInbound::Auth { request_id, .. } => *request_id,
WsInbound::Heartbeat { request_id } => *request_id,
WsInbound::JoinChannel { request_id, .. } => *request_id,
WsInbound::LeaveChannel { request_id, .. } => *request_id,
WsInbound::TypingStart { request_id, .. } => *request_id,
WsInbound::TypingStop { request_id, .. } => *request_id,
WsInbound::MessageSend { request_id, .. } => *request_id,
WsInbound::MessageEdit { request_id, .. } => *request_id,
WsInbound::MessageDelete { request_id, .. } => *request_id,
WsInbound::PresenceUpdate { request_id, .. } => *request_id,
WsInbound::ReadReceipt { request_id, .. } => *request_id,
}
}
-68
View File
@@ -1,68 +0,0 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum WsInbound {
Auth {
request_id: Uuid,
token: String,
},
Heartbeat {
request_id: Uuid,
},
JoinChannel {
request_id: Uuid,
channel_id: Uuid,
},
LeaveChannel {
request_id: Uuid,
channel_id: Uuid,
},
TypingStart {
request_id: Uuid,
channel_id: Uuid,
thread_id: Option<Uuid>,
},
TypingStop {
request_id: Uuid,
channel_id: Uuid,
thread_id: Option<Uuid>,
},
MessageSend {
request_id: Uuid,
channel_id: Uuid,
body: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
reply_to: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
message_type: Option<String>,
},
MessageEdit {
request_id: Uuid,
channel_id: Uuid,
message_id: Uuid,
body: String,
},
MessageDelete {
request_id: Uuid,
channel_id: Uuid,
message_id: Uuid,
},
PresenceUpdate {
request_id: Uuid,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
custom_status_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
custom_status_emoji: Option<String>,
},
ReadReceipt {
request_id: Uuid,
channel_id: Uuid,
last_read_message_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
last_seq: Option<i64>,
},
}
-46
View File
@@ -1,46 +0,0 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HandlerLimitError;
#[derive(Clone)]
pub struct HandlerLimiter {
sem: Arc<Semaphore>,
max_inflight: usize,
rejected: Arc<AtomicU64>,
}
impl HandlerLimiter {
pub fn new(max_inflight: usize) -> Self {
Self {
sem: Arc::new(Semaphore::new(max_inflight)),
max_inflight,
rejected: Arc::new(AtomicU64::new(0)),
}
}
pub fn try_acquire(&self) -> Result<OwnedSemaphorePermit, HandlerLimitError> {
match self.sem.clone().try_acquire_owned() {
Ok(permit) => Ok(permit),
Err(_) => {
self.rejected.fetch_add(1, Ordering::Relaxed);
Err(HandlerLimitError)
}
}
}
pub fn inflight(&self) -> usize {
self.max_inflight - self.sem.available_permits()
}
pub fn available(&self) -> usize {
self.sem.available_permits()
}
pub fn rejected_total(&self) -> u64 {
self.rejected.load(Ordering::Relaxed)
}
}
-36
View File
@@ -1,36 +0,0 @@
mod bridge;
mod dedup;
mod envelope;
mod handler;
mod inbound;
mod limiter;
mod nats;
mod outbound;
mod rate_limit;
mod reconnect;
mod redis_keys;
mod runtime;
mod seq;
mod session;
mod session_redis;
mod sink;
mod typing;
pub use bridge::NatsWsBridge;
pub use dedup::DedupManager;
pub use envelope::TransportEnvelope;
pub use inbound::WsInbound;
pub use limiter::HandlerLimiter;
pub use nats::ImNats;
pub use outbound::{
ArticleAction, ArticleEvent, CategoryAction, CategoryEvent, ChannelAction, ChannelEvent,
DraftAction, DraftEvent, FollowAction, FollowEvent, MemberAction, MemberEvent, MessageAction,
MessageEvent, PollAction, PollEvent, PresenceEvent, ReactionAction, ReactionEvent,
ThreadAction, ThreadEvent, TypingEvent, WsOutbound,
};
pub use rate_limit::{LocalRateLimiter, RateLimiter};
pub use reconnect::ReconnectManager;
pub use runtime::WsRuntime;
pub use seq::SeqAllocator;
pub use session::{WsSession, WsSessionManager, WsSessionState};
pub use sink::{WsReceiver, WsSender, WsSinkManager};
-81
View File
@@ -1,81 +0,0 @@
use std::sync::Arc;
use serde::Serialize;
use uuid::Uuid;
use crate::queue::NatsQueue;
#[derive(Clone)]
pub struct ImNats {
inner: Arc<NatsQueue>,
}
impl ImNats {
pub fn new(nats: Arc<NatsQueue>) -> Self {
Self { inner: nats }
}
pub async fn emit<T: Serialize>(&self, subject: &str, request_id: Uuid, event: &T) {
if let Err(e) = self
.inner
.publish_with_headers(
subject,
&serde_json::to_vec(event).unwrap_or_default(),
vec![("X-Request-Id".into(), request_id.to_string())],
)
.await
{
tracing::warn!(subject, error = %e, "nats emit failed");
}
}
#[inline]
pub fn channel_subject(channel_id: Uuid) -> String {
format!("im.channel.{channel_id}")
}
#[inline]
pub fn message_subject(channel_id: Uuid) -> String {
format!("im.message.{channel_id}")
}
#[inline]
pub fn thread_subject(channel_id: Uuid, thread_id: Uuid) -> String {
format!("im.thread.{channel_id}.{thread_id}")
}
#[inline]
pub fn member_subject(channel_id: Uuid) -> String {
format!("im.member.{channel_id}")
}
#[inline]
pub fn reaction_subject(channel_id: Uuid) -> String {
format!("im.reaction.{channel_id}")
}
#[inline]
pub fn typing_subject(channel_id: Uuid) -> String {
format!("im.typing.{channel_id}")
}
#[inline]
pub fn presence_subject(user_id: Uuid) -> String {
format!("im.presence.{user_id}")
}
#[inline]
pub fn poll_subject(channel_id: Uuid) -> String {
format!("im.poll.{channel_id}")
}
#[inline]
pub fn article_subject(channel_id: Uuid) -> String {
format!("im.article.{channel_id}")
}
#[inline]
pub fn workspace_channels_subject(workspace_name: &str) -> String {
format!("im.ws_channels.{workspace_name}")
}
}
-256
View File
@@ -1,256 +0,0 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum WsOutbound {
AuthOk {
request_id: Uuid,
connection_id: Uuid,
heartbeat_interval_secs: u64,
},
AuthError {
request_id: Uuid,
message: String,
},
HeartbeatAck {
request_id: Uuid,
timestamp_ms: i64,
},
Error {
request_id: Uuid,
code: String,
message: String,
},
Typing {
request_id: Uuid,
data: TypingEvent,
},
Presence {
request_id: Uuid,
data: PresenceEvent,
},
Message {
request_id: Uuid,
data: MessageEvent,
},
Channel {
request_id: Uuid,
data: ChannelEvent,
},
Thread {
request_id: Uuid,
data: ThreadEvent,
},
Member {
request_id: Uuid,
data: MemberEvent,
},
Reaction {
request_id: Uuid,
data: ReactionEvent,
},
Poll {
request_id: Uuid,
data: PollEvent,
},
Article {
request_id: Uuid,
data: ArticleEvent,
},
Category {
request_id: Uuid,
data: CategoryEvent,
},
Draft {
request_id: Uuid,
data: DraftEvent,
},
Follow {
request_id: Uuid,
data: FollowEvent,
},
ReadReceiptAck {
request_id: Uuid,
channel_id: Uuid,
last_read_message_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
last_seq: Option<i64>,
},
SeqAck {
request_id: Uuid,
channel_id: Uuid,
seq: i64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypingEvent {
pub channel_id: Uuid,
pub thread_id: Option<Uuid>,
pub user_id: Uuid,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresenceEvent {
pub user_id: Uuid,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_status_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_status_emoji: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageEvent {
pub channel_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<Uuid>,
pub message_id: Uuid,
pub author_id: Uuid,
pub action: MessageAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seq: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MessageAction {
Created,
Edited,
Deleted,
Pinned,
Unpinned,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelEvent {
pub channel_id: Uuid,
pub action: ChannelAction,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ChannelAction {
Created,
Updated,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadEvent {
pub channel_id: Uuid,
pub thread_id: Uuid,
pub action: ThreadAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ThreadAction {
Created,
Updated,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemberEvent {
pub channel_id: Uuid,
pub user_id: Uuid,
pub action: MemberAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MemberAction {
Joined,
Left,
Kicked,
Updated,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReactionEvent {
pub channel_id: Uuid,
pub message_id: Uuid,
pub user_id: Uuid,
pub action: ReactionAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ReactionAction {
Added,
Removed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PollEvent {
pub channel_id: Uuid,
pub poll_id: Uuid,
pub action: PollAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PollAction {
Created,
Voted,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArticleEvent {
pub channel_id: Uuid,
pub article_id: Uuid,
pub action: ArticleAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ArticleAction {
Created,
Updated,
Published,
Unpublished,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CategoryEvent {
pub workspace_name: String,
pub category_id: Uuid,
pub action: CategoryAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CategoryAction {
Created,
Updated,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DraftEvent {
pub channel_id: Uuid,
pub user_id: Uuid,
pub thread_id: Option<Uuid>,
pub action: DraftAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DraftAction {
Saved,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FollowEvent {
pub channel_id: Uuid,
pub follow_id: Uuid,
pub action: FollowAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FollowAction {
Created,
Deleted,
Retried,
}
-102
View File
@@ -1,102 +0,0 @@
use std::time::Instant;
use uuid::Uuid;
use crate::cache::redis::AppRedis;
use crate::error::AppResult;
use ::redis::Cmd;
use super::redis_keys::*;
pub struct RateLimiter {
redis: AppRedis,
max_per_sec: u32,
}
impl RateLimiter {
pub fn new(redis: AppRedis) -> Self {
Self {
redis,
max_per_sec: WS_MAX_MESSAGES_PER_SEC,
}
}
pub fn with_limit(redis: AppRedis, max_per_sec: u32) -> Self {
Self { redis, max_per_sec }
}
pub fn check(&self, connection_id: Uuid) -> AppResult<bool> {
let key = format!("{WS_RATE_PREFIX}{connection_id}");
let mut conn = self.redis.get_connection()?;
let count: i64 = Cmd::new()
.arg("INCR")
.arg(&key)
.query(&mut *conn.inner_mut())
.map_err(crate::error::AppError::Redis)?;
if count == 1 {
let _ = Cmd::new()
.arg("EXPIRE")
.arg(&key)
.arg(1_u64)
.query::<()>(&mut *conn.inner_mut());
}
Ok(count <= self.max_per_sec as i64)
}
pub fn check_sliding(&self, connection_id: Uuid) -> AppResult<bool> {
let key = format!("{WS_RATE_PREFIX}{connection_id}");
let mut conn = self.redis.get_connection()?;
let count: i64 = Cmd::new()
.arg("INCR")
.arg(&key)
.query(&mut *conn.inner_mut())
.map_err(crate::error::AppError::Redis)?;
if count == 1 {
let _ = Cmd::new()
.arg("EXPIRE")
.arg(&key)
.arg(2_u64)
.query::<()>(&mut *conn.inner_mut());
}
Ok(count <= self.max_per_sec as i64)
}
pub fn remaining(&self, connection_id: Uuid) -> AppResult<u32> {
let key = format!("{WS_RATE_PREFIX}{connection_id}");
let mut conn = self.redis.get_connection()?;
let count: Option<i64> = Cmd::new()
.arg("GET")
.arg(&key)
.query(&mut *conn.inner_mut())
.map_err(crate::error::AppError::Redis)?;
Ok(self.max_per_sec.saturating_sub(count.unwrap_or(0) as u32))
}
}
pub struct LocalRateLimiter {
count: std::sync::atomic::AtomicU32,
start: std::sync::Mutex<Instant>,
max_per_sec: u32,
}
impl LocalRateLimiter {
pub fn new(max_per_sec: u32) -> Self {
Self {
count: std::sync::atomic::AtomicU32::new(0),
start: std::sync::Mutex::new(Instant::now()),
max_per_sec,
}
}
pub fn check(&self) -> bool {
let mut start = self.start.lock().unwrap();
if start.elapsed().as_secs() >= 1 {
self.count.store(0, std::sync::atomic::Ordering::Relaxed);
*start = Instant::now();
}
drop(start);
self.count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
< self.max_per_sec
}
}
-101
View File
@@ -1,101 +0,0 @@
use std::collections::HashMap;
use uuid::Uuid;
use crate::cache::redis::AppRedis;
use crate::error::{AppError, AppResult};
use ::redis::Cmd;
use super::redis_keys::*;
pub struct ReconnectManager {
redis: AppRedis,
}
impl ReconnectManager {
pub fn new(redis: AppRedis) -> Self {
Self { redis }
}
pub fn save_read_position(&self, user_id: Uuid, channel_id: Uuid, seq: i64) -> AppResult<()> {
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
let mut conn = self.redis.get_connection()?;
Cmd::new()
.arg("SETEX")
.arg(&key)
.arg(WS_RECONNECT_STATE_TTL_SECS)
.arg(seq.to_string())
.query::<()>(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
Ok(())
}
pub fn save_read_positions(
&self,
user_id: Uuid,
positions: &HashMap<Uuid, i64>,
) -> AppResult<()> {
let mut conn = self.redis.get_connection()?;
for (channel_id, seq) in positions {
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
Cmd::new()
.arg("SETEX")
.arg(&key)
.arg(WS_RECONNECT_STATE_TTL_SECS)
.arg(seq.to_string())
.query::<()>(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
}
Ok(())
}
pub fn get_last_seq(&self, user_id: Uuid, channel_id: Uuid) -> AppResult<Option<i64>> {
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
let mut conn = self.redis.get_connection()?;
let val: Option<String> = Cmd::new()
.arg("GET")
.arg(&key)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
Ok(val.and_then(|v| v.parse().ok()))
}
pub fn get_all_positions(&self, user_id: Uuid) -> AppResult<HashMap<Uuid, i64>> {
let pattern = format!("{WS_RECONNECT_PREFIX}{user_id}:*");
let mut conn = self.redis.get_connection()?;
let keys: Vec<String> = Cmd::new()
.arg("KEYS")
.arg(&pattern)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
let mut result = HashMap::new();
let prefix_len = format!("{WS_RECONNECT_PREFIX}{user_id}:").len();
for key in &keys {
if let Some(channel_str) = key.get(prefix_len..)
&& let Ok(channel_id) = channel_str.parse::<Uuid>()
{
let val: Option<String> = Cmd::new()
.arg("GET")
.arg(key)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
if let Some(v) = val
&& let Ok(seq) = v.parse::<i64>()
{
result.insert(channel_id, seq);
}
}
}
Ok(result)
}
pub fn cleanup_channel(&self, user_id: Uuid, channel_id: Uuid) -> AppResult<()> {
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
let mut conn = self.redis.get_connection()?;
let _ = Cmd::new()
.arg("DEL")
.arg(&key)
.query::<()>(&mut *conn.inner_mut());
Ok(())
}
}
-19
View File
@@ -1,19 +0,0 @@
#![allow(dead_code)]
pub const WS_TOKEN_PREFIX: &str = "im:ws:token:";
pub const WS_ONLINE_PREFIX: &str = "im:ws:online:";
pub const WS_CONNS_PREFIX: &str = "im:ws:conns:";
pub const WS_SEQ_PREFIX: &str = "im:seq:";
pub const WS_DEDUP_PREFIX: &str = "im:dedup:";
pub const WS_RATE_PREFIX: &str = "im:rate:";
pub const WS_RECONNECT_PREFIX: &str = "im:reconnect:";
pub const WS_TOKEN_TTL_SECS: u64 = 30;
pub const WS_ONLINE_TTL_SECS: u64 = 60;
pub const WS_HEARTBEAT_INTERVAL_SECS: u64 = 30;
pub const WS_HEARTBEAT_TIMEOUT_SECS: u64 = 60;
pub const WS_MAX_IDLE_SECS: u64 = 300;
pub const WS_MAX_MESSAGE_BYTES: usize = 64 * 1024;
pub const WS_MAX_MESSAGES_PER_SEC: u32 = 100;
pub const WS_SEQ_SEGMENT_SIZE: u64 = 1024;
pub const WS_DEDUP_WINDOW_SECS: u64 = 300;
pub const WS_RECONNECT_STATE_TTL_SECS: u64 = 86400;
-52
View File
@@ -1,52 +0,0 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::queue::NatsQueue;
use super::{NatsWsBridge, WsReceiver, WsSender, WsSessionManager, WsSinkManager};
#[derive(Clone)]
pub struct WsRuntime {
sessions: Arc<WsSessionManager>,
sinks: Arc<WsSinkManager>,
bridge: NatsWsBridge,
}
impl WsRuntime {
pub fn new(queue: Arc<NatsQueue>, sessions: Arc<WsSessionManager>) -> Self {
let sinks = Arc::new(WsSinkManager::new());
let bridge = NatsWsBridge::new(queue, sessions.clone(), sinks.clone());
Self {
sessions,
sinks,
bridge,
}
}
pub fn sinks(&self) -> Arc<WsSinkManager> {
self.sinks.clone()
}
pub fn sessions(&self) -> Arc<WsSessionManager> {
self.sessions.clone()
}
pub fn attach(&self, connection_id: Uuid) -> WsReceiver {
let (tx, rx): (WsSender, WsReceiver) = WsSinkManager::channel();
self.sinks.attach(connection_id, tx);
rx
}
pub fn detach(&self, connection_id: Uuid) {
self.sinks.detach(connection_id);
self.sessions.unsubscribe_all(connection_id);
}
pub fn start_nats_bridge(&self) {
let bridge = self.bridge.clone();
tokio::spawn(async move {
bridge.run_ephemeral("im.>").await;
});
}
}
-117
View File
@@ -1,117 +0,0 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use dashmap::DashMap;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::cache::redis::AppRedis;
use crate::error::{AppError, AppResult};
use ::redis::Cmd;
use super::redis_keys::*;
struct Segment {
end: i64,
next: AtomicI64,
}
pub struct SeqAllocator {
redis: AppRedis,
segments: DashMap<Uuid, Arc<Segment>>,
locks: DashMap<Uuid, Arc<Mutex<()>>>,
segment_size: u64,
}
const MAX_RETRIES: u32 = 3;
impl SeqAllocator {
pub fn new(redis: AppRedis) -> Self {
Self {
redis,
segments: DashMap::new(),
locks: DashMap::new(),
segment_size: WS_SEQ_SEGMENT_SIZE,
}
}
pub async fn next(&self, channel_id: Uuid) -> AppResult<i64> {
for _ in 0..MAX_RETRIES {
if let Some(seq) = self.try_allocate(&channel_id) {
return Ok(seq);
}
let lock = self
.locks
.entry(channel_id)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone();
let _guard = lock.lock().await;
if let Some(seq) = self.try_allocate(&channel_id) {
return Ok(seq);
}
self.refresh(channel_id).await?;
}
Err(AppError::InternalServerError(
"seq allocation exhausted retries".into(),
))
}
pub async fn bootstrap(&self, channel_id: Uuid, db_max: i64) -> AppResult<i64> {
let key = format!("{WS_SEQ_PREFIX}{channel_id}");
let mut conn = self.redis.get_connection()?;
let current: i64 = Cmd::new()
.arg("SET")
.arg(&key)
.arg(db_max)
.arg("NX")
.arg("EX")
.arg(86400)
.query::<Option<String>>(&mut *conn.inner_mut())
.map_err(AppError::Redis)?
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| {
let existing: i64 = Cmd::new()
.arg("GET")
.arg(&key)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)
.unwrap_or(db_max);
if existing < db_max { db_max } else { existing }
});
self.segments.remove(&channel_id);
Ok(current)
}
fn try_allocate(&self, channel_id: &Uuid) -> Option<i64> {
let state = self.segments.get(channel_id)?;
let next = state.next.fetch_add(1, Ordering::Relaxed);
if next < state.end { Some(next) } else { None }
}
async fn refresh(&self, channel_id: Uuid) -> AppResult<()> {
let key = format!("{WS_SEQ_PREFIX}{channel_id}");
let mut conn = self.redis.get_connection()?;
let counter: i64 = Cmd::new()
.arg("INCRBY")
.arg(&key)
.arg(self.segment_size as i64)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
let start = counter - self.segment_size as i64 + 1;
let end = counter + 1;
self.segments.insert(
channel_id,
Arc::new(Segment {
end,
next: AtomicI64::new(start),
}),
);
let _ = Cmd::new()
.arg("EXPIRE")
.arg(&key)
.arg(86400_u64)
.query::<()>(&mut *conn.inner_mut());
Ok(())
}
}
-301
View File
@@ -1,301 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::cache::redis::AppRedis;
use crate::error::{AppError, AppResult};
use crate::queue::NatsQueue;
use ::redis::Cmd;
use super::redis_keys::*;
use super::session_redis::{heartbeat_redis, register_redis_online, unregister_redis_online};
use super::typing;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WsSessionState {
Connecting,
Authenticated,
Replaced,
Closing,
Closed,
}
impl WsSessionState {
pub fn is_deliverable(self) -> bool {
matches!(self, Self::Authenticated)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsSession {
pub user_id: Uuid,
pub device_id: String,
pub connection_id: Uuid,
pub workspace_name: String,
pub connected_at: i64,
pub authenticated_at: Option<i64>,
pub state: WsSessionState,
pub superseded_by: Option<Uuid>,
}
#[derive(Clone)]
pub struct WsSessionManager {
redis: AppRedis,
#[allow(dead_code)]
nats: Arc<NatsQueue>,
user_devices: Arc<DashMap<Uuid, HashMap<String, Uuid>>>,
sessions: Arc<DashMap<Uuid, WsSession>>,
channel_routes: Arc<DashMap<Uuid, HashSet<Uuid>>>,
session_channels: Arc<DashMap<Uuid, HashSet<Uuid>>>,
}
impl WsSessionManager {
pub fn new(redis: AppRedis, nats: Arc<NatsQueue>) -> Self {
Self {
redis,
nats,
user_devices: Arc::new(DashMap::new()),
sessions: Arc::new(DashMap::new()),
channel_routes: Arc::new(DashMap::new()),
session_channels: Arc::new(DashMap::new()),
}
}
pub fn issue_token(&self, user_id: Uuid, workspace_name: &str) -> AppResult<String> {
self.issue_token_for_device(user_id, workspace_name, "default")
}
pub fn issue_token_for_device(
&self,
user_id: Uuid,
workspace_name: &str,
device_id: &str,
) -> AppResult<String> {
let token = format!("ws_{}", Uuid::now_v7());
let session = WsSession {
user_id,
device_id: device_id.to_string(),
connection_id: Uuid::nil(),
workspace_name: workspace_name.to_string(),
connected_at: 0,
authenticated_at: None,
state: WsSessionState::Connecting,
superseded_by: None,
};
let json = serde_json::to_string(&session)?;
let key = format!("{WS_TOKEN_PREFIX}{token}");
let mut conn = self.redis.get_connection()?;
Cmd::new()
.arg("SETEX")
.arg(&key)
.arg(WS_TOKEN_TTL_SECS)
.arg(&json)
.query::<()>(&mut *conn.inner_mut())?;
Ok(token)
}
pub fn redeem_token(&self, token: &str) -> AppResult<WsSession> {
let key = format!("{WS_TOKEN_PREFIX}{token}");
let mut conn = self.redis.get_connection()?;
let json: Option<String> = Cmd::new()
.arg("GETDEL")
.arg(&key)
.query::<Option<String>>(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
let json = json.ok_or(AppError::Unauthorized)?;
let mut session: WsSession = serde_json::from_str(&json)
.map_err(|e| AppError::Config(format!("invalid ws session: {e}")))?;
let now = chrono::Utc::now().timestamp_millis();
session.connection_id = Uuid::now_v7();
session.connected_at = now;
session.authenticated_at = Some(now);
session.state = WsSessionState::Authenticated;
session.superseded_by = None;
Ok(session)
}
pub fn register_connection(&self, session: &WsSession) -> AppResult<()> {
let _ = self.register_connection_with_replacement(session)?;
Ok(())
}
pub fn register_connection_with_replacement(
&self,
session: &WsSession,
) -> AppResult<Option<Uuid>> {
let mut current = session.clone();
current.state = WsSessionState::Authenticated;
current.superseded_by = None;
self.sessions.insert(current.connection_id, current.clone());
let replaced = {
let mut entry = self.user_devices.entry(current.user_id).or_default();
entry.insert(current.device_id.clone(), current.connection_id)
};
if let Some(old_id) = replaced
&& old_id != current.connection_id
{
if let Some(mut old) = self.sessions.get_mut(&old_id) {
old.state = WsSessionState::Replaced;
old.superseded_by = Some(current.connection_id);
}
self.unsubscribe_all(old_id);
}
register_redis_online(&self.redis, &current)?;
Ok(replaced.filter(|old| *old != current.connection_id))
}
pub fn unregister_connection(&self, session: &WsSession) -> AppResult<()> {
let removed = self.sessions.remove(&session.connection_id).map(|(_, s)| s);
let current = removed.as_ref().unwrap_or(session);
self.unsubscribe_all(current.connection_id);
if let Some(mut devices) = self.user_devices.get_mut(&current.user_id)
&& devices.get(&current.device_id).copied() == Some(current.connection_id)
{
devices.remove(&current.device_id);
}
self.user_devices
.remove_if(&current.user_id, |_, devices| devices.is_empty());
unregister_redis_online(&self.redis, current)
}
pub fn heartbeat(&self, session: &WsSession) -> AppResult<()> {
if !self.is_deliverable(session.connection_id) {
return Err(AppError::Unauthorized);
}
heartbeat_redis(&self.redis, session)
}
pub fn subscribe_channel(&self, connection_id: Uuid, channel_id: Uuid) {
self.channel_routes
.entry(channel_id)
.or_default()
.insert(connection_id);
self.session_channels
.entry(connection_id)
.or_default()
.insert(channel_id);
}
pub fn unsubscribe_channel(&self, connection_id: Uuid, channel_id: Uuid) {
if let Some(mut sessions) = self.channel_routes.get_mut(&channel_id) {
sessions.remove(&connection_id);
}
self.channel_routes
.remove_if(&channel_id, |_, sessions| sessions.is_empty());
if let Some(mut channels) = self.session_channels.get_mut(&connection_id) {
channels.remove(&channel_id);
}
self.session_channels
.remove_if(&connection_id, |_, channels| channels.is_empty());
}
pub fn unsubscribe_all(&self, connection_id: Uuid) {
let channels = self
.session_channels
.remove(&connection_id)
.map(|(_, channels)| channels)
.unwrap_or_default();
for channel_id in channels {
if let Some(mut sessions) = self.channel_routes.get_mut(&channel_id) {
sessions.remove(&connection_id);
}
self.channel_routes
.remove_if(&channel_id, |_, sessions| sessions.is_empty());
}
}
pub fn subscribers(&self, channel_id: Uuid) -> Vec<Uuid> {
self.channel_routes
.get(&channel_id)
.map(|sessions| sessions.iter().copied().collect())
.unwrap_or_default()
}
pub fn user_connections(&self, user_id: Uuid) -> Vec<Uuid> {
self.user_devices
.get(&user_id)
.map(|devices| devices.values().copied().collect())
.unwrap_or_default()
}
pub fn workspace_connections(&self, workspace_name: &str) -> Vec<Uuid> {
self.sessions
.iter()
.filter_map(|entry| {
let session = entry.value();
(session.workspace_name == workspace_name && session.state.is_deliverable())
.then_some(session.connection_id)
})
.collect()
}
pub fn get_session(&self, connection_id: Uuid) -> Option<WsSession> {
self.sessions
.get(&connection_id)
.map(|session| session.clone())
}
pub fn is_deliverable(&self, connection_id: Uuid) -> bool {
self.sessions
.get(&connection_id)
.map(|session| session.state.is_deliverable() && session.superseded_by.is_none())
.unwrap_or(false)
}
pub fn is_user_online(&self, user_id: Uuid) -> AppResult<bool> {
Ok(self
.user_devices
.get(&user_id)
.map(|devices| !devices.is_empty())
.unwrap_or(false))
}
pub fn get_connection_count(&self, user_id: Uuid) -> AppResult<u32> {
Ok(self
.user_devices
.get(&user_id)
.map(|devices| devices.len() as u32)
.unwrap_or(0))
}
pub fn set_typing(
&self,
channel_id: Uuid,
thread_id: Option<Uuid>,
user_id: Uuid,
) -> AppResult<()> {
typing::set_typing(&self.redis, channel_id, thread_id, user_id)
}
pub fn clear_typing(
&self,
channel_id: Uuid,
thread_id: Option<Uuid>,
user_id: Uuid,
) -> AppResult<()> {
typing::clear_typing(&self.redis, channel_id, thread_id, user_id)
}
pub fn get_typing_users(
&self,
channel_id: Uuid,
thread_id: Option<Uuid>,
) -> AppResult<Vec<Uuid>> {
typing::get_typing_users(&self.redis, channel_id, thread_id)
}
pub fn heartbeat_interval(&self) -> Duration {
Duration::from_secs(WS_HEARTBEAT_INTERVAL_SECS)
}
pub fn heartbeat_interval_secs(&self) -> u64 {
WS_HEARTBEAT_INTERVAL_SECS
}
}
-93
View File
@@ -1,93 +0,0 @@
use crate::cache::redis::AppRedis;
use crate::error::{AppError, AppResult};
use crate::service::im::util::PRESENCE_PREFIX;
use ::redis::Cmd;
use super::redis_keys::*;
use super::session::WsSession;
pub fn register_redis_online(redis: &AppRedis, session: &WsSession) -> AppResult<()> {
let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id);
let conn_id = session.connection_id.to_string();
let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id);
let mut conn = redis.get_connection()?;
Cmd::new()
.arg("SADD")
.arg(&set_key)
.arg(&conn_id)
.query::<i32>(&mut *conn.inner_mut())?;
Cmd::new()
.arg("EXPIRE")
.arg(&set_key)
.arg(WS_ONLINE_TTL_SECS)
.query::<()>(&mut *conn.inner_mut())?;
Cmd::new()
.arg("SETEX")
.arg(&meta_key)
.arg(WS_ONLINE_TTL_SECS)
.arg(session.workspace_name.as_str())
.query::<()>(&mut *conn.inner_mut())?;
Ok(())
}
pub fn unregister_redis_online(redis: &AppRedis, session: &WsSession) -> AppResult<()> {
let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id);
let conn_id = session.connection_id.to_string();
let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id);
let mut conn = redis.get_connection()?;
Cmd::new()
.arg("SREM")
.arg(&set_key)
.arg(&conn_id)
.query::<i32>(&mut *conn.inner_mut())?;
let remaining: i32 = Cmd::new()
.arg("SCARD")
.arg(&set_key)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
if remaining == 0 {
Cmd::new()
.arg("DEL")
.arg(&set_key)
.query::<()>(&mut *conn.inner_mut())?;
let pk = format!("{PRESENCE_PREFIX}{}", session.user_id);
let _ = Cmd::new()
.arg("DEL")
.arg(&pk)
.query::<()>(&mut *conn.inner_mut());
}
let _ = Cmd::new()
.arg("DEL")
.arg(&meta_key)
.query::<()>(&mut *conn.inner_mut());
Ok(())
}
pub fn heartbeat_redis(redis: &AppRedis, session: &WsSession) -> AppResult<()> {
let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id);
let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id);
let pk = format!("{PRESENCE_PREFIX}{}", session.user_id);
let mut conn = redis.get_connection()?;
let _ = Cmd::new()
.arg("EXPIRE")
.arg(&set_key)
.arg(WS_ONLINE_TTL_SECS)
.query::<()>(&mut *conn.inner_mut());
let _ = Cmd::new()
.arg("SETEX")
.arg(&meta_key)
.arg(WS_ONLINE_TTL_SECS)
.arg(session.workspace_name.as_str())
.query::<()>(&mut *conn.inner_mut());
let _ = Cmd::new()
.arg("SETEX")
.arg(&pk)
.arg(WS_ONLINE_TTL_SECS)
.arg("online")
.query::<()>(&mut *conn.inner_mut());
Ok(())
}
-53
View File
@@ -1,53 +0,0 @@
use std::sync::Arc;
use dashmap::DashMap;
use tokio::sync::mpsc;
use uuid::Uuid;
use super::WsOutbound;
pub type WsSender = mpsc::UnboundedSender<WsOutbound>;
pub type WsReceiver = mpsc::UnboundedReceiver<WsOutbound>;
#[derive(Clone, Default)]
pub struct WsSinkManager {
sinks: Arc<DashMap<Uuid, WsSender>>,
}
impl WsSinkManager {
pub fn new() -> Self {
Self::default()
}
pub fn channel() -> (WsSender, WsReceiver) {
mpsc::unbounded_channel()
}
pub fn attach(&self, connection_id: Uuid, sender: WsSender) {
self.sinks.insert(connection_id, sender);
}
pub fn detach(&self, connection_id: Uuid) {
self.sinks.remove(&connection_id);
}
pub fn send(&self, connection_id: Uuid, message: WsOutbound) -> bool {
self.sinks
.get(&connection_id)
.map(|sink| sink.send(message).is_ok())
.unwrap_or(false)
}
pub fn send_many<I>(&self, ids: I, message: WsOutbound) -> usize
where
I: IntoIterator<Item = Uuid>,
{
ids.into_iter()
.filter(|id| self.send(*id, message.clone()))
.count()
}
pub fn contains(&self, connection_id: Uuid) -> bool {
self.sinks.contains_key(&connection_id)
}
}
-71
View File
@@ -1,71 +0,0 @@
use uuid::Uuid;
use crate::cache::redis::AppRedis;
use crate::error::{AppError, AppResult};
use crate::service::im::util::{TYPING_PREFIX, TYPING_TTL_SECS};
use ::redis::Cmd;
pub fn set_typing(
redis: &AppRedis,
channel_id: Uuid,
thread_id: Option<Uuid>,
user_id: Uuid,
) -> AppResult<()> {
let key = typing_key(channel_id, thread_id, user_id);
let mut conn = redis.get_connection()?;
Cmd::new()
.arg("SETEX")
.arg(&key)
.arg(TYPING_TTL_SECS as u64)
.arg("1")
.query::<()>(&mut *conn.inner_mut())?;
Ok(())
}
pub fn clear_typing(
redis: &AppRedis,
channel_id: Uuid,
thread_id: Option<Uuid>,
user_id: Uuid,
) -> AppResult<()> {
let key = typing_key(channel_id, thread_id, user_id);
let mut conn = redis.get_connection()?;
Cmd::new()
.arg("DEL")
.arg(&key)
.query::<()>(&mut *conn.inner_mut())?;
Ok(())
}
pub fn get_typing_users(
redis: &AppRedis,
channel_id: Uuid,
thread_id: Option<Uuid>,
) -> AppResult<Vec<Uuid>> {
let pattern = match thread_id {
Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:*"),
None => format!("{TYPING_PREFIX}{channel_id}:*"),
};
let mut conn = redis.get_connection()?;
let keys: Vec<String> = Cmd::new()
.arg("KEYS")
.arg(&pattern)
.query(&mut *conn.inner_mut())
.map_err(AppError::Redis)?;
let mut ids = Vec::with_capacity(keys.len());
for key in &keys {
if let Some(part) = key.rsplit(':').next()
&& let Ok(uid) = part.parse::<Uuid>()
{
ids.push(uid);
}
}
Ok(ids)
}
fn typing_key(channel_id: Uuid, thread_id: Option<Uuid>, user_id: Uuid) -> String {
match thread_id {
Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:{user_id}"),
None => format!("{TYPING_PREFIX}{channel_id}:{user_id}"),
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ pub mod cache;
pub mod config;
pub mod error;
pub mod etcd;
pub mod immediate;
pub mod grpc;
pub mod models;
pub mod pb;
pub mod queue;
+17459 -416
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
// Generated from proto/this/*.proto (package appks.v1)
// Compiled via tonic-build in build.rs using OUT_DIR + include!
// Build server = true, build client = false (appks is the server side).
include!(concat!(env!("OUT_DIR"), "/appks.v1.rs"));
+5
View File
@@ -0,0 +1,5 @@
// Generated from proto/this/im/*.proto (package appks.im.v1)
// Compiled via tonic-build in build.rs using OUT_DIR + include!
// Build server = true, build client = false (appks is the server side for IM RPCs).
include!(concat!(env!("OUT_DIR"), "/appks.im.v1.rs"));
+49 -1
View File
@@ -1,8 +1,37 @@
pub mod appks;
pub mod email;
pub mod im;
pub mod repo;
use serde::{Deserialize, Serialize};
use tonic::transport::{Channel, Endpoint};
#[derive(Clone, PartialEq, Eq, Hash, prost::Message, Serialize, Deserialize, utoipa::ToSchema)]
pub struct Timestamp {
#[prost(int64, tag = "1")]
pub seconds: i64,
#[prost(int32, tag = "2")]
pub nanos: i32,
}
impl From<prost_types::Timestamp> for Timestamp {
fn from(t: prost_types::Timestamp) -> Self {
Self {
seconds: t.seconds,
nanos: t.nanos,
}
}
}
impl From<Timestamp> for prost_types::Timestamp {
fn from(t: Timestamp) -> Self {
Self {
seconds: t.seconds,
nanos: t.nanos,
}
}
}
#[derive(Clone)]
pub struct RepoClient {
pub repository: repo::repository_service_client::RepositoryServiceClient<Channel>,
@@ -15,6 +44,8 @@ pub struct RepoClient {
pub blame: repo::blame_service_client::BlameServiceClient<Channel>,
pub archive: repo::archive_service_client::ArchiveServiceClient<Channel>,
pub pack: repo::pack_service_client::PackServiceClient<Channel>,
pub ref_: repo::ref_service_client::RefServiceClient<Channel>,
pub remote: repo::remote_service_client::RemoteServiceClient<Channel>,
}
impl RepoClient {
@@ -41,7 +72,9 @@ impl RepoClient {
merge: repo::merge_service_client::MergeServiceClient::new(channel.clone()),
blame: repo::blame_service_client::BlameServiceClient::new(channel.clone()),
archive: repo::archive_service_client::ArchiveServiceClient::new(channel.clone()),
pack: repo::pack_service_client::PackServiceClient::new(channel),
pack: repo::pack_service_client::PackServiceClient::new(channel.clone()),
ref_: repo::ref_service_client::RefServiceClient::new(channel.clone()),
remote: repo::remote_service_client::RemoteServiceClient::new(channel),
}
}
}
@@ -82,3 +115,18 @@ impl std::ops::DerefMut for EmailClient {
&mut self.inner
}
}
// Section: Appks gRPC server traits
//
// Core services (package appks.v1) live in pb::appks::
// - RepoService
//
// IM services (package appks.im.v1) live in pb::im::
// - ChannelService, MemberService, PermissionService
// - InternalAuthService
// - ChannelRoleService, ChannelInvitationService, ChannelWebhookService
// - ChannelSlashCommandService, ChannelRepoLinkService, ImIntegrationService
// - CustomEmojiService, ForumTagService, VoiceService, StageService
// - ChannelAuditService
//
// Implementations are in grpc/ and wired into the tonic server in grpc/mod.rs.
+107
View File
@@ -154,6 +154,102 @@ message CompareCommitsResponse {
Oid merge_base = 4;
}
message FindCommitRequest {
RepositoryHeader repository = 1;
ObjectSelector revision = 2;
bool include_stats = 3;
}
message ListCommitsByOidRequest {
RepositoryHeader repository = 1;
repeated bytes oids = 2; // binary OID values
bool include_stats = 3;
}
message ListCommitsByOidResponse {
repeated Commit commits = 1;
}
message CommitIsAncestorRequest {
RepositoryHeader repository = 1;
string ancestor_oid = 2;
string descendant_oid = 3;
}
message CommitIsAncestorResponse {
bool is_ancestor = 1;
}
message CheckObjectsExistRequest {
RepositoryHeader repository = 1;
repeated string revisions = 2; // hex OIDs or rev expressions
}
message RevisionExistence {
string revision = 1;
bool exists = 2;
}
message CheckObjectsExistResponse {
repeated RevisionExistence revisions = 1;
}
message CommitsByMessageRequest {
RepositoryHeader repository = 1;
string query = 2; // regex or literal to search in commit messages
string revision = 3; // limit to this branch/ref (empty = all branches)
uint32 limit = 4;
uint32 offset = 5;
bool case_insensitive = 6;
}
message CommitsByMessageResponse {
repeated Commit commits = 1;
}
message GetCommitStatsRequest {
RepositoryHeader repository = 1;
ObjectSelector revision = 2;
}
message LastCommitForPathRequest {
RepositoryHeader repository = 1;
string path = 2;
string revision = 3; // limit history to this ref
bool literal_pathspec = 4;
}
message LastCommitForPathResponse {
Commit commit = 1;
string path = 2;
}
message CountCommitsRequest {
RepositoryHeader repository = 1;
string revision = 2;
string path = 3;
string since = 4; // ISO 8601 date
string until = 5;
}
message CountCommitsResponse {
uint64 count = 1;
}
message CountDivergingCommitsRequest {
RepositoryHeader repository = 1;
string left = 2;
string right = 3;
}
message CountDivergingCommitsResponse {
uint64 left_count = 1;
uint64 right_count = 2;
}
service CommitService {
rpc ListCommits(ListCommitsRequest) returns (ListCommitsResponse);
rpc GetCommit(GetCommitRequest) returns (Commit);
@@ -162,4 +258,15 @@ service CommitService {
rpc RevertCommit(RevertCommitRequest) returns (CreateCommitResponse);
rpc CherryPickCommit(CherryPickCommitRequest) returns (CreateCommitResponse);
rpc CompareCommits(CompareCommitsRequest) returns (CompareCommitsResponse);
rpc FindCommit(FindCommitRequest) returns (Commit);
rpc ListCommitsByOid(ListCommitsByOidRequest) returns (ListCommitsByOidResponse);
rpc CommitIsAncestor(CommitIsAncestorRequest) returns (CommitIsAncestorResponse);
rpc CheckObjectsExist(CheckObjectsExistRequest) returns (CheckObjectsExistResponse);
rpc CommitsByMessage(CommitsByMessageRequest) returns (CommitsByMessageResponse);
rpc GetCommitStats(GetCommitStatsRequest) returns (CommitStats);
rpc LastCommitForPath(LastCommitForPathRequest) returns (LastCommitForPathResponse);
rpc CountCommits(CountCommitsRequest) returns (CountCommitsResponse);
rpc CountDivergingCommits(CountDivergingCommitsRequest) returns (CountDivergingCommitsResponse);
}
+58
View File
@@ -132,9 +132,67 @@ message GetDiffStatsRequest {
DiffOptions options = 4;
}
message RawDiffRequest {
RepositoryHeader repository = 1;
string base = 2; // revision or OID
string head = 3;
DiffOptions options = 4;
}
message RawDiffResponse {
bytes data = 1;
}
message RawPatchRequest {
RepositoryHeader repository = 1;
string base = 2;
string head = 3;
}
message RawPatchResponse {
bytes data = 1;
}
message FindChangedPathsRequest {
RepositoryHeader repository = 1;
string base = 2;
string head = 3;
repeated string paths = 4; // filter to these paths
}
message ChangedPath {
enum Status {
CHANGED_PATH_STATUS_UNSPECIFIED = 0;
CHANGED_PATH_STATUS_ADDED = 1;
CHANGED_PATH_STATUS_MODIFIED = 2;
CHANGED_PATH_STATUS_DELETED = 3;
CHANGED_PATH_STATUS_RENAMED = 4;
CHANGED_PATH_STATUS_COPIED = 5;
CHANGED_PATH_STATUS_TYPE_CHANGED = 6;
}
Status status = 1;
string old_path = 2;
string new_path = 3;
uint32 additions = 4;
uint32 deletions = 5;
bool binary = 6;
}
message FindChangedPathsResponse {
repeated ChangedPath paths = 1;
}
service DiffService {
rpc GetDiff(GetDiffRequest) returns (GetDiffResponse);
rpc GetCommitDiff(GetCommitDiffRequest) returns (GetDiffResponse);
rpc GetPatch(GetPatchRequest) returns (stream GetPatchResponse);
rpc GetDiffStats(GetDiffStatsRequest) returns (DiffStats);
rpc RawDiff(RawDiffRequest) returns (stream RawDiffResponse);
rpc RawPatch(RawPatchRequest) returns (stream RawPatchResponse);
rpc FindChangedPaths(FindChangedPathsRequest) returns (FindChangedPathsResponse);
}
+61
View File
@@ -0,0 +1,61 @@
syntax = "proto3";
package gitks;
import "repository.proto";
// HookService provides gRPC callback hooks for git operations.
// External services can implement this interface to receive hook callbacks.
service HookService {
// Pre-receive hook callback: validate push before it happens.
rpc PreReceiveHook(PreReceiveHookRequest) returns (PreReceiveHookResponse);
// Update hook callback: validate each ref update individually.
rpc UpdateHook(UpdateHookRequest) returns (UpdateHookResponse);
// Post-receive hook callback: notify after push has completed.
rpc PostReceiveHook(PostReceiveHookRequest) returns (PostReceiveHookResponse);
}
message PreReceiveHookRequest {
RepositoryHeader repository = 1;
repeated RefUpdate ref_updates = 2;
string push_options = 3;
}
message PreReceiveHookResponse {
bool accept = 1;
string rejection_message = 2;
}
message UpdateHookRequest {
RepositoryHeader repository = 1;
string ref_name = 2;
string old_oid = 3;
string new_oid = 4;
}
message UpdateHookResponse {
bool accept = 1;
string rejection_message = 2;
}
message PostReceiveHookRequest {
RepositoryHeader repository = 1;
repeated RefUpdate ref_updates = 2;
}
message PostReceiveHookResponse {
repeated HookAction actions = 1;
}
message RefUpdate {
string old_oid = 1;
string new_oid = 2;
string ref_name = 3;
}
message HookAction {
string action_type = 1; // "trigger_ci", "update_index", etc.
string payload = 2;
}
+3
View File
@@ -10,6 +10,7 @@ message GitProtocolFeatures {
repeated string capabilities = 2;
repeated string server_options = 3;
repeated string agent = 4;
bool stateless = 5;
}
message ReferenceAdvertisement {
@@ -24,11 +25,13 @@ message AdvertiseRefsRequest {
RepositoryHeader repository = 1;
GitProtocolFeatures protocol = 2;
string service = 3;
bool raw = 4;
}
message AdvertiseRefsResponse {
repeated ReferenceAdvertisement references = 1;
repeated string capabilities = 2;
bytes raw_data = 3;
}
message UploadPackRequest {
+99
View File
@@ -0,0 +1,99 @@
syntax = "proto3";
package gitks;
import "google/protobuf/empty.proto";
import "oid.proto";
import "repository.proto";
message FindDefaultBranchNameRequest {
RepositoryHeader repository = 1;
}
message FindDefaultBranchNameResponse {
string name = 1;
}
message RefExistsRequest {
RepositoryHeader repository = 1;
string ref_name = 2;
}
message RefExistsResponse {
bool exists = 1;
}
message RefUpdateEntry {
string ref_name = 1;
string new_oid = 2;
string old_oid = 3; // expected old OID (empty = no check)
}
message UpdateReferencesRequest {
RepositoryHeader repository = 1;
repeated RefUpdateEntry updates = 2;
}
message UpdateReferencesResponse {
repeated string failed_refs = 1;
string error = 2;
}
message DeleteRefsRequest {
RepositoryHeader repository = 1;
repeated string ref_names = 2;
}
message DeleteRefsResponse {
repeated string failed_refs = 1;
string error = 2;
}
message FindRefsByOIDRequest {
RepositoryHeader repository = 1;
string oid = 2;
RefFilter filter = 3;
}
message RefFilter {
repeated string prefixes = 1; // e.g. ["refs/heads/", "refs/tags/"]
uint32 limit = 2;
}
message FoundRef {
string ref_name = 1;
string target_oid = 2;
bool symbolic = 3;
string symbolic_target = 4;
}
message FindRefsByOIDResponse {
repeated FoundRef refs = 1;
}
message ListRefsRequest {
RepositoryHeader repository = 1;
repeated string prefixes = 2;
string pattern = 3; // glob pattern, e.g. "refs/heads/*"
repeated string containing_oids = 4;
SortDirection sort_direction = 5;
Pagination pagination = 6;
}
message ListRefsResponse {
repeated FoundRef refs = 1;
PageInfo page_info = 2;
}
service RefService {
rpc FindDefaultBranchName(FindDefaultBranchNameRequest) returns (FindDefaultBranchNameResponse);
rpc RefExists(RefExistsRequest) returns (RefExistsResponse);
rpc UpdateReferences(UpdateReferencesRequest) returns (UpdateReferencesResponse);
rpc DeleteRefs(DeleteRefsRequest) returns (DeleteRefsResponse);
rpc FindRefsByOID(FindRefsByOIDRequest) returns (FindRefsByOIDResponse);
rpc ListRefs(ListRefsRequest) returns (ListRefsResponse);
}
+53
View File
@@ -0,0 +1,53 @@
syntax = "proto3";
package gitks;
import "oid.proto";
import "repository.proto";
message FindRemoteRepositoryRequest {
string remote_url = 1;
}
message RemoteHead {
string ref_name = 1;
string target_oid = 2;
bool symbolic = 3;
string symbolic_target = 4;
}
message FindRemoteRepositoryResponse {
repeated RemoteHead refs = 1;
bool exists = 2;
}
message FindRemoteRootRefRequest {
string remote_url = 1;
}
message FindRemoteRootRefResponse {
string ref_name = 1;
string target_oid = 2;
}
message UpdateRemoteMirrorRequest {
RepositoryHeader repository = 1;
string remote_url = 2;
string remote_name = 3; // defaults to "origin"
bool force = 4;
bool prune = 5;
repeated string refspecs = 6; // if empty, fetch all refs
}
message UpdateRemoteMirrorResponse {
bool ok = 1;
string error = 2;
}
service RemoteService {
rpc FindRemoteRepository(FindRemoteRepositoryRequest) returns (FindRemoteRepositoryResponse);
rpc FindRemoteRootRef(FindRemoteRootRefRequest) returns (FindRemoteRootRefResponse);
rpc UpdateRemoteMirror(UpdateRemoteMirrorRequest) returns (UpdateRemoteMirrorResponse);
}
+299
View File
@@ -139,6 +139,276 @@ message RepositoryMaintenanceResponse {
string stderr = 3;
}
message ListHooksRequest {
RepositoryHeader repository = 1;
}
message HookInfo {
string hook_type = 1;
string level = 2; // "server" or "custom"
string path = 3;
}
message ListHooksResponse {
repeated HookInfo hooks = 1;
}
message SetCustomHookRequest {
RepositoryHeader repository = 1;
string hook_name = 2; // "pre-receive", "update", "post-receive"
string content = 3; // Hook script content
}
message RemoveCustomHookRequest {
RepositoryHeader repository = 1;
string hook_name = 2;
}
enum SnapshotStorage {
SNAPSHOT_STORAGE_LOCAL = 0;
SNAPSHOT_STORAGE_S3 = 1;
SNAPSHOT_STORAGE_GCS = 2;
}
message SnapshotInfo {
string snapshot_id = 1;
string relative_path = 2;
uint64 size_bytes = 3;
string created_at = 4; // ISO 8601
string head_oid = 5;
}
message CreateSnapshotRequest {
RepositoryHeader repository = 1;
SnapshotStorage storage = 2;
string storage_path = 3;
}
message CreateSnapshotResponse {
string snapshot_id = 1;
uint64 size_bytes = 2;
string head_oid = 3;
}
message RestoreSnapshotRequest {
RepositoryHeader target_repository = 1;
string snapshot_id = 2;
SnapshotStorage storage = 3;
string storage_path = 4;
}
message ListSnapshotsRequest {
RepositoryHeader repository = 1;
uint32 limit = 2;
}
message ListSnapshotsResponse {
repeated SnapshotInfo snapshots = 1;
}
message DeleteSnapshotRequest {
string snapshot_id = 1;
SnapshotStorage storage = 2;
}
enum MoveRepositoryState {
MOVE_STATE_UNKNOWN = 0;
MOVE_STATE_PREPARING = 1;
MOVE_STATE_TRANSFERRING = 2;
MOVE_STATE_VERIFYING = 3;
MOVE_STATE_COMPLETED = 4;
MOVE_STATE_FAILED = 5;
MOVE_STATE_CANCELLED = 6;
}
message MoveRepositoryRequest {
RepositoryHeader source_repository = 1;
RepositoryHeader target_repository = 2;
}
message MoveRepositoryResponse {
MoveRepositoryState state = 1;
string error_message = 2;
}
message FetchRepositoryDataRequest {
RepositoryHeader repository = 1;
}
message FetchRepositoryDataResponse {
bytes data = 1;
bool done = 2;
}
message FindMergeBaseRequest {
RepositoryHeader repository = 1;
repeated bytes revisions = 2; // hex OIDs to find merge-base for
}
message FindMergeBaseResponse {
string base_oid = 1;
}
message WriteRefRequest {
RepositoryHeader repository = 1;
string ref_name = 2;
string new_oid = 3;
string old_oid = 4; // expected old OID (empty = no check)
bool force = 5;
}
message WriteRefResponse {
bool ok = 1;
string error = 2;
}
message SearchFilesByContentRequest {
RepositoryHeader repository = 1;
string query = 2; // regex pattern
string revision = 3; // tree-ish to search in (default HEAD)
uint32 max_results = 4; // default 100
bool case_sensitive = 5;
}
message SearchFilesByContentResponse {
repeated SearchResult results = 1;
}
message SearchFilesByNameRequest {
RepositoryHeader repository = 1;
string query = 2; // regex pattern for file names
string revision = 3;
uint32 max_results = 4;
bool recursive = 5;
}
message SearchFilesByNameResponse {
repeated SearchResult results = 1;
}
message SearchResult {
string path = 1;
uint32 line = 2; // 0 for name-only search
string matched_text = 3; // the surrounding line content
}
message ObjectsSizeRequest {
RepositoryHeader repository = 1;
repeated string oids = 2;
}
message ObjectsSizeResponse {
repeated ObjectSize sizes = 1;
}
message ObjectSize {
string oid = 1;
uint64 size = 2;
bool found = 3;
}
message RepositorySizeRequest {
RepositoryHeader repository = 1;
}
message RepositorySizeResponse {
uint64 size_bytes = 1;
}
message FindLicenseRequest {
RepositoryHeader repository = 1;
}
message FindLicenseResponse {
string license_spdx = 1; // SPDX identifier, e.g. "MIT"
string license_name = 2; // human-readable name
double confidence = 3; // 0.0 — 1.0
string license_path = 4; // path to LICENSE file
}
enum OptimizeStrategy {
OPTIMIZE_STRATEGY_UNSPECIFIED = 0;
OPTIMIZE_STRATEGY_HEURISTIC = 1; // auto-decide based on repo state
OPTIMIZE_STRATEGY_AGGRESSIVE = 2;
OPTIMIZE_STRATEGY_INCREMENTAL = 3;
}
message OptimizeRepositoryRequest {
RepositoryHeader repository = 1;
OptimizeStrategy strategy = 2;
}
message OptimizeRepositoryResponse {
bool ok = 1;
string stdout = 2;
string stderr = 3;
}
message GetRawChangesRequest {
RepositoryHeader repository = 1;
string base = 2; // revision or OID
string head = 3;
}
message RawChange {
enum Operation {
RAW_CHANGE_OPERATION_UNSPECIFIED = 0;
RAW_CHANGE_OPERATION_ADDED = 1;
RAW_CHANGE_OPERATION_MODIFIED = 2;
RAW_CHANGE_OPERATION_DELETED = 3;
RAW_CHANGE_OPERATION_RENAMED = 4;
RAW_CHANGE_OPERATION_COPIED = 5;
}
Operation operation = 1;
string old_path = 2;
string new_path = 3;
uint32 old_mode = 4;
uint32 new_mode = 5;
string old_oid = 6;
string new_oid = 7;
double similarity = 8;
}
message GetRawChangesResponse {
repeated RawChange changes = 1;
}
message FetchRemoteRequest {
RepositoryHeader repository = 1;
string remote_url = 2;
string remote_name = 3; // defaults to "origin"
repeated string refspecs = 4;
bool force = 5;
bool prune = 6;
}
message FetchRemoteResponse {
bool ok = 1;
string error = 2;
}
message CreateRepositoryFromURLRequest {
RepositoryHeader repository = 1;
string remote_url = 2;
bool mirror = 3;
}
message CreateRepositoryFromURLResponse {
Repository repository = 1;
}
service RepositoryService {
rpc GetRepository(GetRepositoryRequest) returns (Repository);
rpc InitRepository(InitRepositoryRequest) returns (Repository);
@@ -154,4 +424,33 @@ service RepositoryService {
rpc GarbageCollect(GarbageCollectRequest) returns (RepositoryMaintenanceResponse);
rpc Repack(RepackRequest) returns (RepositoryMaintenanceResponse);
rpc WriteCommitGraph(WriteCommitGraphRequest) returns (RepositoryMaintenanceResponse);
// Hooks management
rpc ListHooks(ListHooksRequest) returns (ListHooksResponse);
rpc SetCustomHook(SetCustomHookRequest) returns (google.protobuf.Empty);
rpc RemoveCustomHook(RemoveCustomHookRequest) returns (google.protobuf.Empty);
// Snapshot operations
rpc CreateSnapshot(CreateSnapshotRequest) returns (CreateSnapshotResponse);
rpc RestoreSnapshot(RestoreSnapshotRequest) returns (google.protobuf.Empty);
rpc ListSnapshots(ListSnapshotsRequest) returns (ListSnapshotsResponse);
rpc DeleteSnapshot(DeleteSnapshotRequest) returns (google.protobuf.Empty);
// Repository move
rpc MoveRepository(MoveRepositoryRequest) returns (MoveRepositoryResponse);
rpc FetchRepositoryData(FetchRepositoryDataRequest) returns (stream FetchRepositoryDataResponse);
rpc FindMergeBase(FindMergeBaseRequest) returns (FindMergeBaseResponse);
rpc WriteRef(WriteRefRequest) returns (WriteRefResponse);
rpc SearchFilesByContent(SearchFilesByContentRequest) returns (SearchFilesByContentResponse);
rpc SearchFilesByName(SearchFilesByNameRequest) returns (SearchFilesByNameResponse);
rpc ObjectsSize(ObjectsSizeRequest) returns (ObjectsSizeResponse);
rpc RepositorySize(RepositorySizeRequest) returns (RepositorySizeResponse);
rpc FetchRemote(FetchRemoteRequest) returns (FetchRemoteResponse);
rpc CreateRepositoryFromURL(CreateRepositoryFromURLRequest) returns (CreateRepositoryFromURLResponse);
rpc FindLicense(FindLicenseRequest) returns (FindLicenseResponse);
rpc OptimizeRepository(OptimizeRepositoryRequest) returns (OptimizeRepositoryResponse);
rpc GetRawChanges(GetRawChangesRequest) returns (GetRawChangesResponse);
}
+26
View File
@@ -0,0 +1,26 @@
syntax = "proto3";
package appks.im.v1;
// Internal service-to-service authentication.
// appks issues API keys (stored in Redis), remote services
// carry the key in gRPC metadata "x-api-key", and call
// Authenticate to verify identity.
message AuthenticateRequest {
string api_key = 1;
}
message AuthenticateResponse {
bool authenticated = 1;
string service_name = 2;
string service_id = 3;
repeated string scopes = 4;
int64 expires_at = 5;
}
service InternalAuthService {
// Verify an API key and return the associated service identity.
// Called by remote services to authenticate themselves.
rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse);
}
+212
View File
@@ -0,0 +1,212 @@
syntax = "proto3";
package appks.im.v1;
import "google/protobuf/timestamp.proto";
// Channel management service for the IM microservice.
// Provides CRUD for channels and categories, plus channel statistics.
// ── Enums ──────────────────────────────────────────────────────────────
enum ChannelType {
CHANNEL_TYPE_UNSPECIFIED = 0;
CHANNEL_TYPE_PUBLIC = 1;
CHANNEL_TYPE_PRIVATE = 2;
CHANNEL_TYPE_DIRECT = 3;
CHANNEL_TYPE_GROUP = 4;
CHANNEL_TYPE_REPO = 5;
CHANNEL_TYPE_SYSTEM = 6;
}
enum ChannelKind {
CHANNEL_KIND_UNSPECIFIED = 0;
CHANNEL_KIND_TEXT = 1;
CHANNEL_KIND_VOICE = 2;
CHANNEL_KIND_STAGE = 3;
CHANNEL_KIND_FORUM = 4;
CHANNEL_KIND_ANNOUNCEMENT = 5;
}
enum Visibility {
VISIBILITY_UNSPECIFIED = 0;
VISIBILITY_PUBLIC = 1;
VISIBILITY_PRIVATE = 2;
VISIBILITY_INTERNAL = 3;
VISIBILITY_WORKSPACE = 4;
VISIBILITY_PROTECTED = 5;
VISIBILITY_HIDDEN = 6;
VISIBILITY_SECRET = 7;
}
// ── Messages ───────────────────────────────────────────────────────────
message Channel {
string id = 1;
string workspace_id = 2;
optional string category_id = 3;
optional string parent_channel_id = 4;
string name = 5;
optional string topic = 6;
optional string description = 7;
ChannelType channel_type = 8;
ChannelKind channel_kind = 9;
Visibility visibility = 10;
int32 position = 11;
bool nsfw = 12;
bool read_only = 13;
bool archived = 14;
optional string created_by = 15;
optional int32 rate_limit_per_user = 16;
optional google.protobuf.Timestamp archived_at = 17;
optional string last_message_id = 18;
optional google.protobuf.Timestamp last_message_at = 19;
google.protobuf.Timestamp created_at = 20;
google.protobuf.Timestamp updated_at = 21;
}
message ChannelStats {
string channel_id = 1;
int32 members_count = 2;
int32 messages_count = 3;
int32 threads_count = 4;
int32 reactions_count = 5;
int32 mentions_count = 6;
int32 files_count = 7;
optional google.protobuf.Timestamp last_activity_at = 8;
google.protobuf.Timestamp updated_at = 9;
}
message ChannelCategory {
string id = 1;
string workspace_id = 2;
string name = 3;
int32 position = 4;
bool collapsed = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
// ── Requests / Responses ──────────────────────────────────────────────
message GetChannelRequest {
string channel_id = 1;
}
message GetChannelResponse {
Channel channel = 1;
}
message ListChannelsRequest {
string workspace_name = 1;
optional string category_id = 2;
optional ChannelType channel_type = 3;
optional ChannelKind channel_kind = 4;
int32 limit = 5;
int32 offset = 6;
}
message ListChannelsResponse {
repeated Channel channels = 1;
int32 total = 2;
}
message CreateChannelRequest {
string workspace_name = 1;
string name = 2;
optional string topic = 3;
optional string description = 4;
optional string channel_type = 5;
optional string channel_kind = 6;
optional string visibility = 7;
optional string category_id = 8;
optional string parent_channel_id = 9;
optional string created_by = 10;
optional int32 rate_limit_per_user = 11;
}
message CreateChannelResponse {
Channel channel = 1;
}
message UpdateChannelRequest {
string channel_id = 1;
optional string name = 2;
optional string topic = 3;
optional string description = 4;
optional string visibility = 5;
optional int32 position = 6;
optional bool nsfw = 7;
optional bool read_only = 8;
optional bool archived = 9;
optional string category_id = 10;
optional int32 rate_limit_per_user = 11;
}
message UpdateChannelResponse {
Channel channel = 1;
}
message DeleteChannelRequest {
string channel_id = 1;
}
message DeleteChannelResponse {}
message GetChannelStatsRequest {
string channel_id = 1;
}
message GetChannelStatsResponse {
ChannelStats stats = 1;
}
message ListCategoriesRequest {
string workspace_name = 1;
}
message ListCategoriesResponse {
repeated ChannelCategory categories = 1;
}
message CreateCategoryRequest {
string workspace_name = 1;
string name = 2;
optional int32 position = 3;
}
message CreateCategoryResponse {
ChannelCategory category = 1;
}
message UpdateCategoryRequest {
string category_id = 1;
optional string name = 2;
optional int32 position = 3;
optional bool collapsed = 4;
}
message UpdateCategoryResponse {
ChannelCategory category = 1;
}
message DeleteCategoryRequest {
string category_id = 1;
}
message DeleteCategoryResponse {}
// ── Service ───────────────────────────────────────────────────────────
service ChannelService {
rpc GetChannel(GetChannelRequest) returns (GetChannelResponse);
rpc ListChannels(ListChannelsRequest) returns (ListChannelsResponse);
rpc CreateChannel(CreateChannelRequest) returns (CreateChannelResponse);
rpc UpdateChannel(UpdateChannelRequest) returns (UpdateChannelResponse);
rpc DeleteChannel(DeleteChannelRequest) returns (DeleteChannelResponse);
rpc GetChannelStats(GetChannelStatsRequest) returns (GetChannelStatsResponse);
rpc ListCategories(ListCategoriesRequest) returns (ListCategoriesResponse);
rpc CreateCategory(CreateCategoryRequest) returns (CreateCategoryResponse);
rpc UpdateCategory(UpdateCategoryRequest) returns (UpdateCategoryResponse);
rpc DeleteCategory(DeleteCategoryRequest) returns (DeleteCategoryResponse);
}
+412
View File
@@ -0,0 +1,412 @@
syntax = "proto3";
package appks.im.v1;
import "google/protobuf/timestamp.proto";
// ── ChannelMemberRole ──────────────────────────────────────────────────
message ChannelRole {
string id = 1;
string channel_id = 2;
string name = 3;
repeated string permissions = 4;
bool assignable = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
message ListChannelRolesRequest { string channel_id = 1; }
message ListChannelRolesResponse { repeated ChannelRole roles = 1; }
message CreateChannelRoleRequest {
string channel_id = 1;
string name = 2;
repeated string permissions = 3;
bool assignable = 4;
}
message CreateChannelRoleResponse { ChannelRole role = 1; }
message UpdateChannelRoleRequest {
string role_id = 1;
optional string name = 2;
repeated string permissions = 3;
optional bool assignable = 4;
}
message UpdateChannelRoleResponse { ChannelRole role = 1; }
message DeleteChannelRoleRequest { string role_id = 1; }
message DeleteChannelRoleResponse {}
service ChannelRoleService {
rpc ListChannelRoles(ListChannelRolesRequest) returns (ListChannelRolesResponse);
rpc CreateChannelRole(CreateChannelRoleRequest) returns (CreateChannelRoleResponse);
rpc UpdateChannelRole(UpdateChannelRoleRequest) returns (UpdateChannelRoleResponse);
rpc DeleteChannelRole(DeleteChannelRoleRequest) returns (DeleteChannelRoleResponse);
}
// ── ChannelInvitation ──────────────────────────────────────────────────
message ChannelInvitation {
string id = 1;
string channel_id = 2;
string invited_by = 3;
string invited_user_id = 4;
string role = 5;
string status = 6;
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp updated_at = 8;
}
message ListInvitationsRequest { string channel_id = 1; }
message ListInvitationsResponse { repeated ChannelInvitation invitations = 1; }
message CreateInvitationRequest {
string channel_id = 1;
string invited_user_id = 2;
string role = 3;
}
message CreateInvitationResponse { ChannelInvitation invitation = 1; }
message AcceptInvitationRequest { string invitation_id = 1; }
message AcceptInvitationResponse { ChannelInvitation invitation = 1; }
message RevokeInvitationRequest { string invitation_id = 1; }
message RevokeInvitationResponse {}
service ChannelInvitationService {
rpc ListInvitations(ListInvitationsRequest) returns (ListInvitationsResponse);
rpc CreateInvitation(CreateInvitationRequest) returns (CreateInvitationResponse);
rpc AcceptInvitation(AcceptInvitationRequest) returns (AcceptInvitationResponse);
rpc RevokeInvitation(RevokeInvitationRequest) returns (RevokeInvitationResponse);
}
// ── ChannelWebhook ─────────────────────────────────────────────────────
message ChannelWebhook {
string id = 1;
string channel_id = 2;
string name = 3;
string url = 4;
string secret = 5;
repeated string events = 6;
bool active = 7;
google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp updated_at = 9;
}
message ListWebhooksRequest { string channel_id = 1; }
message ListWebhooksResponse { repeated ChannelWebhook webhooks = 1; }
message CreateWebhookRequest {
string channel_id = 1;
string name = 2;
string url = 3;
optional string secret = 4;
repeated string events = 5;
}
message CreateWebhookResponse { ChannelWebhook webhook = 1; }
message UpdateWebhookRequest {
string webhook_id = 1;
optional string name = 2;
optional string url = 3;
optional string secret = 4;
repeated string events = 5;
optional bool active = 6;
}
message UpdateWebhookResponse { ChannelWebhook webhook = 1; }
message DeleteWebhookRequest { string webhook_id = 1; }
message DeleteWebhookResponse {}
service ChannelWebhookService {
rpc ListWebhooks(ListWebhooksRequest) returns (ListWebhooksResponse);
rpc CreateWebhook(CreateWebhookRequest) returns (CreateWebhookResponse);
rpc UpdateWebhook(UpdateWebhookRequest) returns (UpdateWebhookResponse);
rpc DeleteWebhook(DeleteWebhookRequest) returns (DeleteWebhookResponse);
}
// ── ChannelSlashCommand ────────────────────────────────────────────────
message ChannelSlashCommand {
string id = 1;
string channel_id = 2;
string command = 3;
string description = 4;
string request_url = 5;
repeated string scopes = 6;
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp updated_at = 8;
}
message ListSlashCommandsRequest { string channel_id = 1; }
message ListSlashCommandsResponse { repeated ChannelSlashCommand commands = 1; }
message CreateSlashCommandRequest {
string channel_id = 1;
string command = 2;
string description = 3;
string request_url = 4;
repeated string scopes = 5;
}
message CreateSlashCommandResponse { ChannelSlashCommand command = 1; }
message UpdateSlashCommandRequest {
string command_id = 1;
optional string description = 2;
optional string request_url = 3;
repeated string scopes = 4;
}
message UpdateSlashCommandResponse { ChannelSlashCommand command = 1; }
message DeleteSlashCommandRequest { string command_id = 1; }
message DeleteSlashCommandResponse {}
service ChannelSlashCommandService {
rpc ListSlashCommands(ListSlashCommandsRequest) returns (ListSlashCommandsResponse);
rpc CreateSlashCommand(CreateSlashCommandRequest) returns (CreateSlashCommandResponse);
rpc UpdateSlashCommand(UpdateSlashCommandRequest) returns (UpdateSlashCommandResponse);
rpc DeleteSlashCommand(DeleteSlashCommandRequest) returns (DeleteSlashCommandResponse);
}
// ── ChannelRepoLink ────────────────────────────────────────────────────
message ChannelRepoLink {
string id = 1;
string channel_id = 2;
string repo_id = 3;
string link_type = 4;
repeated string events = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
message ListRepoLinksRequest { string channel_id = 1; }
message ListRepoLinksResponse { repeated ChannelRepoLink links = 1; }
message CreateRepoLinkRequest {
string channel_id = 1;
string repo_id = 2;
string link_type = 3;
repeated string events = 4;
}
message CreateRepoLinkResponse { ChannelRepoLink link = 1; }
message DeleteRepoLinkRequest { string link_id = 1; }
message DeleteRepoLinkResponse {}
service ChannelRepoLinkService {
rpc ListRepoLinks(ListRepoLinksRequest) returns (ListRepoLinksResponse);
rpc CreateRepoLink(CreateRepoLinkRequest) returns (CreateRepoLinkResponse);
rpc DeleteRepoLink(DeleteRepoLinkRequest) returns (DeleteRepoLinkResponse);
}
// ── ImIntegration ──────────────────────────────────────────────────────
message ImIntegration {
string id = 1;
string channel_id = 2;
string provider = 3;
string external_channel_id = 4;
string sync_direction = 5;
bool active = 6;
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp updated_at = 8;
}
message ListIntegrationsRequest { string channel_id = 1; }
message ListIntegrationsResponse { repeated ImIntegration integrations = 1; }
message CreateIntegrationRequest {
string channel_id = 1;
string provider = 2;
string external_channel_id = 3;
string sync_direction = 4;
}
message CreateIntegrationResponse { ImIntegration integration = 1; }
message UpdateIntegrationRequest {
string integration_id = 1;
optional string sync_direction = 2;
optional bool active = 3;
}
message UpdateIntegrationResponse { ImIntegration integration = 1; }
message DeleteIntegrationRequest { string integration_id = 1; }
message DeleteIntegrationResponse {}
service ImIntegrationService {
rpc ListIntegrations(ListIntegrationsRequest) returns (ListIntegrationsResponse);
rpc CreateIntegration(CreateIntegrationRequest) returns (CreateIntegrationResponse);
rpc UpdateIntegration(UpdateIntegrationRequest) returns (UpdateIntegrationResponse);
rpc DeleteIntegration(DeleteIntegrationRequest) returns (DeleteIntegrationResponse);
}
// ── CustomEmoji ────────────────────────────────────────────────────────
message CustomEmoji {
string id = 1;
string workspace_id = 2;
string name = 3;
string image_url = 4;
google.protobuf.Timestamp created_at = 5;
}
message ListCustomEmojisRequest { string workspace_id = 1; }
message ListCustomEmojisResponse { repeated CustomEmoji emojis = 1; }
message CreateCustomEmojiRequest {
string workspace_id = 1;
string name = 2;
string image_url = 3;
}
message CreateCustomEmojiResponse { CustomEmoji emoji = 1; }
message DeleteCustomEmojiRequest { string emoji_id = 1; }
message DeleteCustomEmojiResponse {}
service CustomEmojiService {
rpc ListCustomEmojis(ListCustomEmojisRequest) returns (ListCustomEmojisResponse);
rpc CreateCustomEmoji(CreateCustomEmojiRequest) returns (CreateCustomEmojiResponse);
rpc DeleteCustomEmoji(DeleteCustomEmojiRequest) returns (DeleteCustomEmojiResponse);
}
// ── ForumTag ───────────────────────────────────────────────────────────
message ForumTag {
string id = 1;
string channel_id = 2;
string name = 3;
bool moderated = 4;
int32 position = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
message ListForumTagsRequest { string channel_id = 1; }
message ListForumTagsResponse { repeated ForumTag tags = 1; }
message CreateForumTagRequest {
string channel_id = 1;
string name = 2;
bool moderated = 3;
optional int32 position = 4;
}
message CreateForumTagResponse { ForumTag tag = 1; }
message UpdateForumTagRequest {
string tag_id = 1;
optional string name = 2;
optional bool moderated = 3;
optional int32 position = 4;
}
message UpdateForumTagResponse { ForumTag tag = 1; }
message DeleteForumTagRequest { string tag_id = 1; }
message DeleteForumTagResponse {}
service ForumTagService {
rpc ListForumTags(ListForumTagsRequest) returns (ListForumTagsResponse);
rpc CreateForumTag(CreateForumTagRequest) returns (CreateForumTagResponse);
rpc UpdateForumTag(UpdateForumTagRequest) returns (UpdateForumTagResponse);
rpc DeleteForumTag(DeleteForumTagRequest) returns (DeleteForumTagResponse);
}
// ── VoiceParticipant ───────────────────────────────────────────────────
message VoiceParticipant {
string id = 1;
string channel_id = 2;
string user_id = 3;
bool muted = 4;
bool deafened = 5;
google.protobuf.Timestamp joined_at = 6;
}
message ListVoiceParticipantsRequest { string channel_id = 1; }
message ListVoiceParticipantsResponse { repeated VoiceParticipant participants = 1; }
message UpdateVoiceStateRequest {
string channel_id = 1;
string user_id = 2;
optional bool muted = 3;
optional bool deafened = 4;
}
message UpdateVoiceStateResponse { VoiceParticipant participant = 1; }
service VoiceService {
rpc ListVoiceParticipants(ListVoiceParticipantsRequest) returns (ListVoiceParticipantsResponse);
rpc UpdateVoiceState(UpdateVoiceStateRequest) returns (UpdateVoiceStateResponse);
}
// ── Stage ──────────────────────────────────────────────────────────────
message Stage {
string id = 1;
string channel_id = 2;
string topic = 3;
string privacy_level = 4;
bool discoverable = 5;
google.protobuf.Timestamp started_at = 6;
google.protobuf.Timestamp ended_at = 7;
google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp updated_at = 9;
}
message GetStageRequest { string channel_id = 1; }
message GetStageResponse { Stage stage = 1; }
message CreateStageRequest {
string channel_id = 1;
string topic = 2;
string privacy_level = 3;
bool discoverable = 4;
}
message CreateStageResponse { Stage stage = 1; }
message UpdateStageRequest {
string stage_id = 1;
optional string topic = 2;
optional string privacy_level = 3;
optional bool discoverable = 4;
}
message UpdateStageResponse { Stage stage = 1; }
message DeleteStageRequest { string stage_id = 1; }
message DeleteStageResponse {}
service StageService {
rpc GetStage(GetStageRequest) returns (GetStageResponse);
rpc CreateStage(CreateStageRequest) returns (CreateStageResponse);
rpc UpdateStage(UpdateStageRequest) returns (UpdateStageResponse);
rpc DeleteStage(DeleteStageRequest) returns (DeleteStageResponse);
}
// ── ChannelEvent (Audit Log) ───────────────────────────────────────────
message ChannelAuditEvent {
string id = 1;
string channel_id = 2;
string actor_id = 3;
string event_type = 4;
string target_type = 5;
string target_id = 6;
optional string old_value = 7;
optional string new_value = 8;
google.protobuf.Timestamp created_at = 9;
}
message ListChannelEventsRequest {
string channel_id = 1;
int32 limit = 2;
int32 offset = 3;
}
message ListChannelEventsResponse {
repeated ChannelAuditEvent events = 1;
int32 total = 2;
}
service ChannelAuditService {
rpc ListChannelEvents(ListChannelEventsRequest) returns (ListChannelEventsResponse);
}
+131
View File
@@ -0,0 +1,131 @@
syntax = "proto3";
package appks.im.v1;
import "google/protobuf/timestamp.proto";
// Member management service for the IM microservice.
// Provides CRUD for channel members, join/leave, and membership checks.
// ── Enums ──────────────────────────────────────────────────────────────
enum Role {
ROLE_UNSPECIFIED = 0;
ROLE_OWNER = 1;
ROLE_ADMIN = 2;
ROLE_MAINTAINER = 3;
ROLE_MODERATOR = 4;
ROLE_MEMBER = 5;
ROLE_CONTRIBUTOR = 6;
ROLE_VIEWER = 7;
ROLE_GUEST = 8;
ROLE_BOT = 9;
}
enum MemberStatus {
MEMBER_STATUS_UNSPECIFIED = 0;
MEMBER_STATUS_ACTIVE = 1;
MEMBER_STATUS_INVITED = 2;
MEMBER_STATUS_LEFT = 3;
MEMBER_STATUS_KICKED = 4;
MEMBER_STATUS_BANNED = 5;
}
// ── Messages ───────────────────────────────────────────────────────────
message ChannelMember {
string id = 1;
string channel_id = 2;
string user_id = 3;
string role = 4;
string status = 5;
bool muted = 6;
bool pinned = 7;
optional string last_read_message_id = 8;
optional google.protobuf.Timestamp last_read_at = 9;
optional google.protobuf.Timestamp joined_at = 10;
optional google.protobuf.Timestamp left_at = 11;
google.protobuf.Timestamp created_at = 12;
google.protobuf.Timestamp updated_at = 13;
}
// ── Requests / Responses ──────────────────────────────────────────────
message ListMembersRequest {
string channel_id = 1;
optional string status = 2;
int32 limit = 3;
int32 offset = 4;
}
message ListMembersResponse {
repeated ChannelMember members = 1;
int32 total = 2;
}
message InviteMemberRequest {
string channel_id = 1;
string user_id = 2;
optional string role = 3;
}
message InviteMemberResponse {
ChannelMember member = 1;
}
message UpdateMemberRequest {
string channel_id = 1;
string user_id = 2;
optional string role = 3;
optional bool muted = 4;
optional bool pinned = 5;
}
message UpdateMemberResponse {
ChannelMember member = 1;
}
message KickMemberRequest {
string channel_id = 1;
string user_id = 2;
}
message KickMemberResponse {}
message JoinChannelRequest {
string channel_id = 1;
string user_id = 2;
}
message JoinChannelResponse {
ChannelMember member = 1;
}
message LeaveChannelRequest {
string channel_id = 1;
string user_id = 2;
}
message LeaveChannelResponse {}
message IsMemberRequest {
string channel_id = 1;
string user_id = 2;
}
message IsMemberResponse {
bool is_member = 1;
string role = 2;
}
// ── Service ───────────────────────────────────────────────────────────
service MemberService {
rpc ListMembers(ListMembersRequest) returns (ListMembersResponse);
rpc InviteMember(InviteMemberRequest) returns (InviteMemberResponse);
rpc UpdateMember(UpdateMemberRequest) returns (UpdateMemberResponse);
rpc KickMember(KickMemberRequest) returns (KickMemberResponse);
rpc JoinChannel(JoinChannelRequest) returns (JoinChannelResponse);
rpc LeaveChannel(LeaveChannelRequest) returns (LeaveChannelResponse);
rpc IsMember(IsMemberRequest) returns (IsMemberResponse);
}
+128
View File
@@ -0,0 +1,128 @@
syntax = "proto3";
package appks.im.v1;
// IM-specific permissions for channel operations.
// Separate from the general Permission enum used for repo/workspace access.
enum ImPermission {
IM_PERMISSION_UNSPECIFIED = 0;
IM_PERMISSION_READ_CHANNEL = 1;
IM_PERMISSION_SEND_MESSAGE = 2;
IM_PERMISSION_MANAGE_THREADS = 3;
IM_PERMISSION_MANAGE_REACTIONS = 4;
IM_PERMISSION_MANAGE_PINS = 5;
IM_PERMISSION_INVITE_MEMBERS = 6;
IM_PERMISSION_KICK_MEMBERS = 7;
IM_PERMISSION_MANAGE_CHANNEL = 8;
IM_PERMISSION_MANAGE_ROLES = 9;
IM_PERMISSION_MANAGE_WEBHOOKS = 10;
IM_PERMISSION_MANAGE_EMOJIS = 11;
IM_PERMISSION_VIEW_AUDIT_LOG = 12;
IM_PERMISSION_MANAGE_INTEGRATIONS = 13;
IM_PERMISSION_SEND_TTS = 14;
IM_PERMISSION_USE_SLASH_COMMANDS = 15;
IM_PERMISSION_ATTACH_FILES = 16;
IM_PERMISSION_MENTION_EVERYONE = 17;
IM_PERMISSION_MANAGE_MESSAGES = 18;
IM_PERMISSION_ADMIN = 19;
}
// ── Messages ───────────────────────────────────────────────────────────
message PermissionOverwrite {
string id = 1;
string channel_id = 2;
string target_type = 3;
string target_id = 4;
repeated ImPermission allow = 5;
repeated ImPermission deny = 6;
string created_at = 7;
string updated_at = 8;
}
// ── Requests / Responses ──────────────────────────────────────────────
message CheckPermissionRequest {
string channel_id = 1;
string user_id = 2;
ImPermission permission = 3;
}
message CheckPermissionResponse {
bool allowed = 1;
string role = 2;
}
message GetPermissionsRequest {
string channel_id = 1;
string user_id = 2;
}
message GetPermissionsResponse {
repeated ImPermission permissions = 1;
string role = 2;
}
message SetPermissionOverwriteRequest {
string channel_id = 1;
string target_type = 2;
string target_id = 3;
repeated ImPermission allow = 4;
repeated ImPermission deny = 5;
}
message SetPermissionOverwriteResponse {
PermissionOverwrite overwrite = 1;
}
message GetPermissionOverwritesRequest {
string channel_id = 1;
}
message GetPermissionOverwritesResponse {
repeated PermissionOverwrite overwrites = 1;
}
message DeletePermissionOverwriteRequest {
string channel_id = 1;
string target_type = 2;
string target_id = 3;
}
message DeletePermissionOverwriteResponse {}
message ResolveChannelRequest {
string channel_id = 1;
}
message ResolveChannelResponse {
string channel_id = 1;
string workspace_id = 2;
string name = 3;
string visibility = 4;
string channel_type = 5;
bool read_only = 6;
bool archived = 7;
optional string created_by = 8;
}
message EnsureReadableRequest {
string channel_id = 1;
string user_id = 2;
}
message EnsureReadableResponse {
bool allowed = 1;
}
// ── Service ───────────────────────────────────────────────────────────
service PermissionService {
rpc CheckPermission(CheckPermissionRequest) returns (CheckPermissionResponse);
rpc GetPermissions(GetPermissionsRequest) returns (GetPermissionsResponse);
rpc SetPermissionOverwrite(SetPermissionOverwriteRequest) returns (SetPermissionOverwriteResponse);
rpc GetPermissionOverwrites(GetPermissionOverwritesRequest) returns (GetPermissionOverwritesResponse);
rpc DeletePermissionOverwrite(DeletePermissionOverwriteRequest) returns (DeletePermissionOverwriteResponse);
rpc ResolveChannel(ResolveChannelRequest) returns (ResolveChannelResponse);
rpc EnsureReadable(EnsureReadableRequest) returns (EnsureReadableResponse);
}
+247
View File
@@ -0,0 +1,247 @@
syntax = "proto3";
package appks.v1;
import "google/protobuf/timestamp.proto";
// Repository-related services for gitshell.
// gitshell calls these RPCs to:
// 1. Check branch protection rules before accepting a push.
// 2. Locate which storage node hosts a given repository.
// 3. Verify user/agent permissions on a repository.
// 4. Acquire / release push locks for concurrency control.
// ── Enums ──────────────────────────────────────────────────────────────
enum PushLockStatus {
PUSH_LOCK_STATUS_UNSPECIFIED = 0;
PUSH_LOCK_STATUS_QUEUED = 1;
PUSH_LOCK_STATUS_ACTIVE = 2;
PUSH_LOCK_STATUS_FINISHED = 3;
PUSH_LOCK_STATUS_FAILED = 4;
}
enum MergeStrategy {
MERGE_STRATEGY_UNSPECIFIED = 0;
MERGE_STRATEGY_MERGE = 1;
MERGE_STRATEGY_SQUASH = 2;
MERGE_STRATEGY_REBASE = 3;
MERGE_STRATEGY_FAST_FORWARD = 4;
}
// ── Branch Protection ──────────────────────────────────────────────────
message BranchProtectionRule {
string id = 1;
string repo_id = 2;
string pattern = 3;
int32 require_approvals = 4;
bool require_status_checks = 5;
repeated string required_status_checks = 6;
bool require_linear_history = 7;
bool allow_force_pushes = 8;
bool allow_deletions = 9;
bool require_signed_commits = 10;
bool require_code_owner_review = 11;
bool dismiss_stale_reviews = 12;
bool restrict_pushes = 13;
repeated string push_allowances = 14;
bool restrict_review_dismissal = 15;
repeated string dismissal_allowances = 16;
bool require_conversation_resolution = 17;
}
message CheckBranchProtectionRequest {
string workspace_name = 1;
string repo_name = 2;
string branch_name = 3;
// The user attempting the push (for push-allowance checks).
optional string user_id = 4;
}
message CheckBranchProtectionResponse {
bool protected = 1;
BranchProtectionRule rule = 2;
// Human-readable reasons why the push would be blocked.
repeated string block_reasons = 3;
// Whether the given user is exempt (in push_allowances).
bool user_allowed = 4;
}
// ── Repository Locate ─────────────────────────────────────────────────
message StorageNode {
string node_id = 1;
string address = 2;
// Labels for routing decisions (e.g. region, disk-type).
map<string, string> labels = 3;
bool healthy = 4;
}
message LocateRepositoryRequest {
string workspace_name = 1;
string repo_name = 2;
}
message LocateRepositoryResponse {
bool found = 1;
string repo_id = 2;
// The storage path on the node (e.g. "ab/cd/12345.git").
string storage_path = 3;
// Primary storage node that hosts the repository.
StorageNode primary_node = 4;
// Additional replica / failover nodes.
repeated StorageNode replica_nodes = 5;
}
// ── Permission Check ──────────────────────────────────────────────────
message PermissionScope {
string scope = 1; // e.g. "repo:read", "repo:write"
optional string resource = 2; // e.g. specific repo name if scoped
}
message CheckRepoPermissionRequest {
string workspace_name = 1;
string repo_name = 2;
// The principal to check — either a user_id or a deploy_key_id.
oneof principal {
string user_id = 3;
string deploy_key_id = 4;
}
// The required permission level.
string required_permission = 5;
}
message CheckRepoPermissionResponse {
bool allowed = 1;
// The actual resolved permission (may be higher than required).
string resolved_permission = 2;
// If not allowed, a human-readable reason.
string reason = 3;
}
// ── Push Lock ──────────────────────────────────────────────────────────
message PushLock {
string id = 1;
string repo_id = 2;
string pusher_id = 3;
string ref_name = 4;
PushLockStatus status = 5;
int32 queue_position = 6;
google.protobuf.Timestamp queued_at = 7;
google.protobuf.Timestamp started_at = 8;
google.protobuf.Timestamp finished_at = 9;
string storage_node_id = 10;
string lease_token = 11;
string error_message = 12;
}
message AcquirePushLockRequest {
string workspace_name = 1;
string repo_name = 2;
string ref_name = 3;
string pusher_id = 4;
}
message AcquirePushLockResponse {
bool acquired = 1;
PushLock lock = 2;
// If not immediately acquired, estimated wait in seconds.
int32 estimated_wait_seconds = 3;
string error = 4;
}
message ReleasePushLockRequest {
string lock_id = 1;
// Must match the lease_token from AcquirePushLock.
string lease_token = 2;
// Whether the push succeeded.
bool success = 3;
optional string error_message = 4;
}
message ReleasePushLockResponse {
bool released = 1;
string error = 2;
}
message GetPushLockRequest {
string lock_id = 1;
}
message GetPushLockResponse {
PushLock lock = 1;
}
message ListPushLocksRequest {
string workspace_name = 1;
string repo_name = 2;
// Filter by status; if unspecified, returns all active locks.
optional PushLockStatus status = 3;
}
message ListPushLocksResponse {
repeated PushLock locks = 1;
}
// ── Repository Metadata ───────────────────────────────────────────────
message RepoInfo {
string id = 1;
string workspace_id = 2;
string owner_id = 3;
string name = 4;
optional string description = 5;
string default_branch = 6;
string visibility = 7;
string status = 8;
bool is_fork = 9;
optional string forked_from_repo_id = 10;
string storage_path = 11;
string git_service = 12;
google.protobuf.Timestamp archived_at = 13;
google.protobuf.Timestamp created_at = 14;
google.protobuf.Timestamp updated_at = 15;
}
message GetRepoInfoRequest {
string workspace_name = 1;
string repo_name = 2;
}
message GetRepoInfoResponse {
bool found = 1;
RepoInfo repo = 2;
}
// ── Service ────────────────────────────────────────────────────────────
service RepoService {
// ── Branch Protection ──
// Check whether a branch is protected and whether a push is allowed.
rpc CheckBranchProtection(CheckBranchProtectionRequest) returns (CheckBranchProtectionResponse);
// ── Repository Locate ──
// Find which storage node(s) host a repository.
rpc LocateRepository(LocateRepositoryRequest) returns (LocateRepositoryResponse);
// ── Permission Check ──
// Verify that a user or deploy key has the required permission on a repo.
rpc CheckRepoPermission(CheckRepoPermissionRequest) returns (CheckRepoPermissionResponse);
// ── Push Lock ──
// Acquire an exclusive push lock for a ref.
rpc AcquirePushLock(AcquirePushLockRequest) returns (AcquirePushLockResponse);
// Release a previously acquired push lock.
rpc ReleasePushLock(ReleasePushLockRequest) returns (ReleasePushLockResponse);
// Get the current state of a push lock.
rpc GetPushLock(GetPushLockRequest) returns (GetPushLockResponse);
// List active push locks for a repository.
rpc ListPushLocks(ListPushLocksRequest) returns (ListPushLocksResponse);
// ── Repository Metadata ──
// Get lightweight repository metadata (for gitshell to resolve repo names).
rpc GetRepoInfo(GetRepoInfoRequest) returns (GetRepoInfoResponse);
}