feat: add access control to channels with various modes
- Introduced AccessMode enum to define channel access levels: Public, PasswordProtected, AccountRequired, and OwnerOnly. - Updated Channel and ProgrammingBlock entities to include access_mode and access_password_hash fields. - Enhanced create and update channel functionality to handle access mode and password. - Implemented access checks in channel routes based on the defined access modes. - Modified frontend components to support channel creation and editing with access control options. - Added ChannelPasswordModal for handling password input when accessing restricted channels. - Updated API calls to include channel and block passwords as needed. - Created database migrations to add access_mode and access_password_hash columns to channels table.
This commit is contained in:
@@ -58,6 +58,9 @@ pub struct CreateChannelRequest {
|
||||
pub description: Option<String>,
|
||||
/// IANA timezone, e.g. "UTC" or "America/New_York"
|
||||
pub timezone: String,
|
||||
pub access_mode: Option<domain::AccessMode>,
|
||||
/// Plain-text password; hashed before storage.
|
||||
pub access_password: Option<String>,
|
||||
}
|
||||
|
||||
/// All fields are optional — only provided fields are updated.
|
||||
@@ -70,6 +73,9 @@ pub struct UpdateChannelRequest {
|
||||
pub schedule_config: Option<domain::ScheduleConfig>,
|
||||
pub recycle_policy: Option<domain::RecyclePolicy>,
|
||||
pub auto_schedule: Option<bool>,
|
||||
pub access_mode: Option<domain::AccessMode>,
|
||||
/// Empty string clears the password; non-empty re-hashes.
|
||||
pub access_password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -82,6 +88,7 @@ pub struct ChannelResponse {
|
||||
pub schedule_config: domain::ScheduleConfig,
|
||||
pub recycle_policy: domain::RecyclePolicy,
|
||||
pub auto_schedule: bool,
|
||||
pub access_mode: domain::AccessMode,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -97,6 +104,7 @@ impl From<domain::Channel> for ChannelResponse {
|
||||
schedule_config: c.schedule_config,
|
||||
recycle_policy: c.recycle_policy,
|
||||
auto_schedule: c.auto_schedule,
|
||||
access_mode: c.access_mode,
|
||||
created_at: c.created_at,
|
||||
updated_at: c.updated_at,
|
||||
}
|
||||
@@ -147,6 +155,8 @@ pub struct ScheduledSlotResponse {
|
||||
pub end_at: DateTime<Utc>,
|
||||
pub item: MediaItemResponse,
|
||||
pub source_block_id: Uuid,
|
||||
#[serde(default)]
|
||||
pub block_access_mode: domain::AccessMode,
|
||||
}
|
||||
|
||||
impl From<domain::ScheduledSlot> for ScheduledSlotResponse {
|
||||
@@ -157,6 +167,27 @@ impl From<domain::ScheduledSlot> for ScheduledSlotResponse {
|
||||
end_at: s.end_at,
|
||||
item: s.item.into(),
|
||||
source_block_id: s.source_block_id,
|
||||
block_access_mode: domain::AccessMode::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScheduledSlotResponse {
|
||||
pub fn with_block_access(slot: domain::ScheduledSlot, channel: &domain::Channel) -> Self {
|
||||
let block_access_mode = channel
|
||||
.schedule_config
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|b| b.id == slot.source_block_id)
|
||||
.map(|b| b.access_mode.clone())
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
id: slot.id,
|
||||
start_at: slot.start_at,
|
||||
end_at: slot.end_at,
|
||||
item: slot.item.into(),
|
||||
source_block_id: slot.source_block_id,
|
||||
block_access_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +200,8 @@ pub struct CurrentBroadcastResponse {
|
||||
/// Seconds elapsed since the start of the current item — use this as the
|
||||
/// initial seek position for the player.
|
||||
pub offset_secs: u32,
|
||||
/// Access mode of the block currently playing. The stream is gated by this.
|
||||
pub block_access_mode: domain::AccessMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
@@ -29,6 +29,12 @@ pub enum ApiError {
|
||||
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("password_required")]
|
||||
PasswordRequired,
|
||||
|
||||
#[error("auth_required")]
|
||||
AuthRequired,
|
||||
}
|
||||
|
||||
/// Error response body
|
||||
@@ -110,6 +116,22 @@ impl IntoResponse for ApiError {
|
||||
details: Some(msg.clone()),
|
||||
},
|
||||
),
|
||||
|
||||
ApiError::PasswordRequired => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
ErrorResponse {
|
||||
error: "password_required".to_string(),
|
||||
details: None,
|
||||
},
|
||||
),
|
||||
|
||||
ApiError::AuthRequired => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
ErrorResponse {
|
||||
error: "auth_required".to_string(),
|
||||
details: None,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
(status, Json(error_response)).into_response()
|
||||
|
||||
@@ -38,6 +38,29 @@ impl FromRequestParts<AppState> for CurrentUser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional current user — returns None instead of error when auth is missing/invalid.
|
||||
pub struct OptionalCurrentUser(pub Option<User>);
|
||||
|
||||
impl FromRequestParts<AppState> for OptionalCurrentUser {
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
{
|
||||
return Ok(OptionalCurrentUser(try_jwt_auth(parts, state).await.ok()));
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "auth-jwt"))]
|
||||
{
|
||||
let _ = (parts, state);
|
||||
Ok(OptionalCurrentUser(None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate using JWT Bearer token
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> {
|
||||
|
||||
@@ -6,7 +6,9 @@ use std::net::SocketAddr;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use axum::Router;
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
|
||||
|
||||
use domain::{ChannelService, IMediaProvider, ScheduleEngineService, UserService};
|
||||
use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository};
|
||||
@@ -102,6 +104,24 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let app = apply_standard_middleware(app, &server_config);
|
||||
|
||||
// Wrap with an outer CorsLayer that includes the custom password headers.
|
||||
// Being outermost it handles OPTIONS preflights before k_core's inner layer.
|
||||
let origins: Vec<HeaderValue> = config
|
||||
.cors_allowed_origins
|
||||
.iter()
|
||||
.filter_map(|o| o.parse().ok())
|
||||
.collect();
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list(origins))
|
||||
.allow_methods(AllowMethods::any())
|
||||
.allow_headers(AllowHeaders::list([
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
HeaderName::from_static("x-channel-password"),
|
||||
HeaderName::from_static("x-block-password"),
|
||||
]));
|
||||
let app = app.layer(cors);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
@@ -13,18 +13,41 @@ use domain::{DomainError, ScheduleEngineService};
|
||||
use crate::{
|
||||
dto::{CurrentBroadcastResponse, ScheduledSlotResponse},
|
||||
error::ApiError,
|
||||
extractors::OptionalCurrentUser,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use super::parse_optional_dt;
|
||||
use super::{check_access, parse_optional_dt};
|
||||
|
||||
fn channel_password(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get("X-Channel-Password")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
fn block_password(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get("X-Block-Password")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
/// What is currently playing right now on this channel.
|
||||
/// Returns 204 No Content when the channel is in a gap between blocks (no-signal).
|
||||
pub(super) async fn get_current_broadcast(
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
OptionalCurrentUser(user): OptionalCurrentUser,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiError> {
|
||||
let _channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
let channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
|
||||
check_access(
|
||||
&channel.access_mode,
|
||||
channel.access_password_hash.as_deref(),
|
||||
user.as_ref(),
|
||||
channel.owner_id,
|
||||
channel_password(&headers),
|
||||
)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let schedule = state
|
||||
@@ -35,11 +58,21 @@ pub(super) async fn get_current_broadcast(
|
||||
|
||||
match ScheduleEngineService::get_current_broadcast(&schedule, now) {
|
||||
None => Ok(StatusCode::NO_CONTENT.into_response()),
|
||||
Some(broadcast) => Ok(Json(CurrentBroadcastResponse {
|
||||
slot: broadcast.slot.into(),
|
||||
offset_secs: broadcast.offset_secs,
|
||||
})
|
||||
.into_response()),
|
||||
Some(broadcast) => {
|
||||
let block_access_mode = channel
|
||||
.schedule_config
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|b| b.id == broadcast.slot.source_block_id)
|
||||
.map(|b| b.access_mode.clone())
|
||||
.unwrap_or_default();
|
||||
Ok(Json(CurrentBroadcastResponse {
|
||||
block_access_mode: block_access_mode.clone(),
|
||||
slot: ScheduledSlotResponse::with_block_access(broadcast.slot, &channel),
|
||||
offset_secs: broadcast.offset_secs,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +90,19 @@ pub(super) struct EpgQuery {
|
||||
pub(super) async fn get_epg(
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
OptionalCurrentUser(user): OptionalCurrentUser,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<EpgQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let _channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
let channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
|
||||
check_access(
|
||||
&channel.access_mode,
|
||||
channel.access_password_hash.as_deref(),
|
||||
user.as_ref(),
|
||||
channel.owner_id,
|
||||
channel_password(&headers),
|
||||
)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let from = parse_optional_dt(params.from, now)?;
|
||||
@@ -78,7 +121,7 @@ pub(super) async fn get_epg(
|
||||
let slots: Vec<ScheduledSlotResponse> = ScheduleEngineService::get_epg(&schedule, from, until)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.map(Into::into)
|
||||
.map(|slot| ScheduledSlotResponse::with_block_access(slot, &channel))
|
||||
.collect();
|
||||
|
||||
Ok(Json(slots))
|
||||
@@ -90,8 +133,18 @@ pub(super) async fn get_epg(
|
||||
pub(super) async fn get_stream(
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
OptionalCurrentUser(user): OptionalCurrentUser,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiError> {
|
||||
let _channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
let channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
|
||||
check_access(
|
||||
&channel.access_mode,
|
||||
channel.access_password_hash.as_deref(),
|
||||
user.as_ref(),
|
||||
channel.owner_id,
|
||||
channel_password(&headers),
|
||||
)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let schedule = state
|
||||
@@ -105,6 +158,22 @@ pub(super) async fn get_stream(
|
||||
Some(b) => b,
|
||||
};
|
||||
|
||||
// Block-level access check
|
||||
if let Some(block) = channel
|
||||
.schedule_config
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|b| b.id == broadcast.slot.source_block_id)
|
||||
{
|
||||
check_access(
|
||||
&block.access_mode,
|
||||
block.access_password_hash.as_deref(),
|
||||
user.as_ref(),
|
||||
channel.owner_id,
|
||||
block_password(&headers),
|
||||
)?;
|
||||
}
|
||||
|
||||
let url = state
|
||||
.schedule_engine
|
||||
.get_stream_url(&broadcast.slot.item.id)
|
||||
|
||||
@@ -34,8 +34,20 @@ pub(super) async fn create_channel(
|
||||
.create(user.id, &payload.name, &payload.timezone)
|
||||
.await?;
|
||||
|
||||
let mut changed = false;
|
||||
if let Some(desc) = payload.description {
|
||||
channel.description = Some(desc);
|
||||
changed = true;
|
||||
}
|
||||
if let Some(mode) = payload.access_mode {
|
||||
channel.access_mode = mode;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(pw) = payload.access_password.as_deref().filter(|p| !p.is_empty()) {
|
||||
channel.access_password_hash = Some(infra::auth::hash_password(pw));
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
channel = state.channel_service.update(channel).await?;
|
||||
}
|
||||
|
||||
@@ -79,6 +91,16 @@ pub(super) async fn update_channel(
|
||||
if let Some(auto) = payload.auto_schedule {
|
||||
channel.auto_schedule = auto;
|
||||
}
|
||||
if let Some(mode) = payload.access_mode {
|
||||
channel.access_mode = mode;
|
||||
}
|
||||
if let Some(pw) = payload.access_password {
|
||||
if pw.is_empty() {
|
||||
channel.access_password_hash = None;
|
||||
} else {
|
||||
channel.access_password_hash = Some(infra::auth::hash_password(&pw));
|
||||
}
|
||||
}
|
||||
channel.updated_at = Utc::now();
|
||||
|
||||
let channel = state.channel_service.update(channel).await?;
|
||||
|
||||
@@ -8,6 +8,8 @@ use axum::{Router, routing::{get, post}};
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{AccessMode, User};
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
mod broadcast;
|
||||
@@ -42,6 +44,40 @@ pub(super) fn require_owner(channel: &domain::Channel, user_id: Uuid) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
/// Gate access to a channel or block based on its `AccessMode`.
|
||||
pub(super) fn check_access(
|
||||
mode: &AccessMode,
|
||||
password_hash: Option<&str>,
|
||||
user: Option<&User>,
|
||||
owner_id: Uuid,
|
||||
supplied_password: Option<&str>,
|
||||
) -> Result<(), ApiError> {
|
||||
match mode {
|
||||
AccessMode::Public => Ok(()),
|
||||
AccessMode::PasswordProtected => {
|
||||
let hash = password_hash.ok_or(ApiError::PasswordRequired)?;
|
||||
let supplied = supplied_password.unwrap_or("").trim();
|
||||
if supplied.is_empty() {
|
||||
return Err(ApiError::PasswordRequired);
|
||||
}
|
||||
if !infra::auth::verify_password(supplied, hash) {
|
||||
return Err(ApiError::PasswordRequired);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
AccessMode::AccountRequired => {
|
||||
if user.is_some() { Ok(()) } else { Err(ApiError::AuthRequired) }
|
||||
}
|
||||
AccessMode::OwnerOnly => {
|
||||
if user.map(|u| u.id) == Some(owner_id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::Forbidden("owner only".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_optional_dt(
|
||||
s: Option<String>,
|
||||
default: DateTime<Utc>,
|
||||
|
||||
@@ -9,7 +9,8 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::value_objects::{
|
||||
BlockId, ChannelId, ContentType, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy, SlotId,
|
||||
AccessMode, BlockId, ChannelId, ContentType, FillStrategy, MediaFilter, MediaItemId,
|
||||
RecyclePolicy, SlotId,
|
||||
};
|
||||
|
||||
/// A user in the system.
|
||||
@@ -82,6 +83,8 @@ pub struct Channel {
|
||||
pub schedule_config: ScheduleConfig,
|
||||
pub recycle_policy: RecyclePolicy,
|
||||
pub auto_schedule: bool,
|
||||
pub access_mode: AccessMode,
|
||||
pub access_password_hash: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -102,6 +105,8 @@ impl Channel {
|
||||
schedule_config: ScheduleConfig::default(),
|
||||
recycle_policy: RecyclePolicy::default(),
|
||||
auto_schedule: false,
|
||||
access_mode: AccessMode::default(),
|
||||
access_password_hash: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
@@ -176,6 +181,14 @@ pub struct ProgrammingBlock {
|
||||
/// regardless of what other blocks aired.
|
||||
#[serde(default)]
|
||||
pub ignore_recycle_policy: bool,
|
||||
|
||||
/// Who can watch the stream during this block. Gates only /stream, not /now.
|
||||
#[serde(default)]
|
||||
pub access_mode: AccessMode,
|
||||
|
||||
/// Bcrypt/argon2 hash of the block password (when access_mode = PasswordProtected).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub access_password_hash: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -198,6 +211,8 @@ impl ProgrammingBlock {
|
||||
content: BlockContent::Algorithmic { filter, strategy },
|
||||
loop_on_finish: true,
|
||||
ignore_recycle_policy: false,
|
||||
access_mode: AccessMode::default(),
|
||||
access_password_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +230,8 @@ impl ProgrammingBlock {
|
||||
content: BlockContent::Manual { items },
|
||||
loop_on_finish: true,
|
||||
ignore_recycle_policy: false,
|
||||
access_mode: AccessMode::default(),
|
||||
access_password_hash: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// Controls who can view a channel's broadcast and stream.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccessMode {
|
||||
#[default]
|
||||
Public,
|
||||
PasswordProtected,
|
||||
AccountRequired,
|
||||
OwnerOnly,
|
||||
}
|
||||
|
||||
/// Opaque media item identifier — format is provider-specific internally.
|
||||
/// The domain never inspects the string; it just passes it back to the provider.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
|
||||
@@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{Channel, ChannelId, DomainError, RecyclePolicy, ScheduleConfig, UserId};
|
||||
use domain::{AccessMode, Channel, ChannelId, DomainError, RecyclePolicy, ScheduleConfig, UserId};
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
pub(super) struct ChannelRow {
|
||||
@@ -14,6 +14,8 @@ pub(super) struct ChannelRow {
|
||||
pub schedule_config: String,
|
||||
pub recycle_policy: String,
|
||||
pub auto_schedule: i64,
|
||||
pub access_mode: String,
|
||||
pub access_password_hash: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -44,6 +46,11 @@ impl TryFrom<ChannelRow> for Channel {
|
||||
DomainError::RepositoryError(format!("Invalid recycle_policy JSON: {}", e))
|
||||
})?;
|
||||
|
||||
let access_mode: AccessMode = serde_json::from_value(
|
||||
serde_json::Value::String(row.access_mode),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Channel {
|
||||
id,
|
||||
owner_id,
|
||||
@@ -53,6 +60,8 @@ impl TryFrom<ChannelRow> for Channel {
|
||||
schedule_config,
|
||||
recycle_policy,
|
||||
auto_schedule: row.auto_schedule != 0,
|
||||
access_mode,
|
||||
access_password_hash: row.access_password_hash,
|
||||
created_at: parse_dt(&row.created_at)?,
|
||||
updated_at: parse_dt(&row.updated_at)?,
|
||||
})
|
||||
@@ -60,4 +69,4 @@ impl TryFrom<ChannelRow> for Channel {
|
||||
}
|
||||
|
||||
pub(super) const SELECT_COLS: &str =
|
||||
"id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, created_at, updated_at";
|
||||
"id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, created_at, updated_at";
|
||||
|
||||
@@ -58,19 +58,26 @@ impl ChannelRepository for PostgresChannelRepository {
|
||||
DomainError::RepositoryError(format!("Failed to serialize recycle_policy: {}", e))
|
||||
})?;
|
||||
|
||||
let access_mode = serde_json::to_value(&channel.access_mode)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
.unwrap_or_else(|| "public".to_owned());
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO channels
|
||||
(id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
(id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
timezone = EXCLUDED.timezone,
|
||||
schedule_config = EXCLUDED.schedule_config,
|
||||
recycle_policy = EXCLUDED.recycle_policy,
|
||||
auto_schedule = EXCLUDED.auto_schedule,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
timezone = EXCLUDED.timezone,
|
||||
schedule_config = EXCLUDED.schedule_config,
|
||||
recycle_policy = EXCLUDED.recycle_policy,
|
||||
auto_schedule = EXCLUDED.auto_schedule,
|
||||
access_mode = EXCLUDED.access_mode,
|
||||
access_password_hash = EXCLUDED.access_password_hash,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(channel.id.to_string())
|
||||
@@ -81,6 +88,8 @@ impl ChannelRepository for PostgresChannelRepository {
|
||||
.bind(&schedule_config)
|
||||
.bind(&recycle_policy)
|
||||
.bind(channel.auto_schedule as i64)
|
||||
.bind(&access_mode)
|
||||
.bind(&channel.access_password_hash)
|
||||
.bind(channel.created_at.to_rfc3339())
|
||||
.bind(channel.updated_at.to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
|
||||
@@ -58,19 +58,26 @@ impl ChannelRepository for SqliteChannelRepository {
|
||||
DomainError::RepositoryError(format!("Failed to serialize recycle_policy: {}", e))
|
||||
})?;
|
||||
|
||||
let access_mode = serde_json::to_value(&channel.access_mode)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
.unwrap_or_else(|| "public".to_owned());
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO channels
|
||||
(id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
timezone = excluded.timezone,
|
||||
schedule_config = excluded.schedule_config,
|
||||
recycle_policy = excluded.recycle_policy,
|
||||
auto_schedule = excluded.auto_schedule,
|
||||
updated_at = excluded.updated_at
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
timezone = excluded.timezone,
|
||||
schedule_config = excluded.schedule_config,
|
||||
recycle_policy = excluded.recycle_policy,
|
||||
auto_schedule = excluded.auto_schedule,
|
||||
access_mode = excluded.access_mode,
|
||||
access_password_hash = excluded.access_password_hash,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(channel.id.to_string())
|
||||
@@ -81,6 +88,8 @@ impl ChannelRepository for SqliteChannelRepository {
|
||||
.bind(&schedule_config)
|
||||
.bind(&recycle_policy)
|
||||
.bind(channel.auto_schedule as i64)
|
||||
.bind(&access_mode)
|
||||
.bind(&channel.access_password_hash)
|
||||
.bind(channel.created_at.to_rfc3339())
|
||||
.bind(channel.updated_at.to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE channels ADD COLUMN access_mode TEXT NOT NULL DEFAULT 'public';
|
||||
ALTER TABLE channels ADD COLUMN access_password_hash TEXT;
|
||||
Reference in New Issue
Block a user