eeff292
/**
* 空间「对象清理」:仅空间所有者可见的危险区。
*
* 仓库、问题看板、频道各自持有空间归属,这里把它们拍成一张扁平清单,
* 并提供「删除 / 归档 / 可见性」操作。对象一律按自己的主键寻址。
*/
use ::session::AuthedUser;
use api::http::routing::{delete, get, post};
use api::http::{Json, Path, Router, State};
use api::rest::{AppResponseData, NoData};
use models::issue::issues as boards;
use models::repo::repos;
use models::room::rooms;
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize};
use utoipa::{OpenApi, ToSchema};
use uuid::Uuid;
use git::service::GitService;
use issues::services::service::IssueService;
use space::errors::SpaceError;
use space::services::service::SpaceService;
use space::share::lookup;
api::api_error! {
log: "cleanup",
pub enum ApiError {
BadRequest(400, "request failed validation"),
Unauthorized(401, "authentication required"),
Forbidden(403, "operation not allowed"),
NotFound(404, "resource not found"),
Internal(500, "internal server error"),
}
}
#[derive(Clone)]
pub struct CleanupState {
pub database: std::sync::Arc<db::AppDatabase>,
pub space: SpaceService,
pub chat: chat::services::service::ChatService,
pub issues: IssueService,
pub git: GitService,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct CleanupObjectDto {
/// `repos.id`、`issues.id`(看板)或 `rooms.id`。
pub id: Uuid,
/// `repo | board | text | voice | stage | category | news | guild_room`
pub kind: String,
pub name: String,
/// 仅仓库与频道有可见性开关。
pub is_private: Option<bool>,
pub is_archived: bool,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct SetCleanupVisibilityCmd {
pub is_private: bool,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct SetRepoArchivedCmd {
pub archived: bool,
}
fn room_kind_label(kind: models::room::rooms::RoomKind) -> &'static str {
use models::room::rooms::RoomKind;
match kind {
RoomKind::Text => "text",
RoomKind::Voice => "voice",
RoomKind::Stage => "stage",
RoomKind::Category => "category",
RoomKind::News => "news",
RoomKind::GuildRoom => "guild_room",
_ => "thread",
}
}
pub fn protected(
database: std::sync::Arc<db::AppDatabase>,
space: SpaceService,
chat: chat::services::service::ChatService,
issues: IssueService,
git: GitService,
) -> Router {
let state = CleanupState {
database,
space,
chat,
issues,
git,
};
Router::<()>::new()
.route("/spaces/{space_name}/cleanup", get(list_cleanup_objects))
.route(
"/spaces/{space_name}/cleanup/{kind}/{object_id}/visibility",
post(set_cleanup_visibility),
)
.route(
"/spaces/{space_name}/cleanup/{kind}/{object_id}/archive",
post(set_cleanup_archive),
)
.route(
"/spaces/{space_name}/cleanup/{kind}/{object_id}",
delete(delete_cleanup_object),
)
.with_state(state)
}
fn map_space(error: SpaceError) -> ApiError {
match error {
SpaceError::BadRequest(message) => ApiError::bad_request(message),
SpaceError::Unauthorized => ApiError::unauthorized(),
SpaceError::Forbidden => ApiError::forbidden(),
SpaceError::NotFound => ApiError::not_found("resource not found"),
other => ApiError::internal(other.to_string()),
}
}
fn map_git(error: git::share::errors::GitError) -> ApiError {
use git::share::errors::GitError;
match error {
GitError::InvalidArgument(message) | GitError::Conflict(message) => {
ApiError::bad_request(message)
}
GitError::Unauthorized => ApiError::unauthorized(),
GitError::Forbidden => ApiError::forbidden(),
GitError::NotFound => ApiError::not_found("resource not found"),
other => ApiError::internal(other.to_string()),
}
}
fn map_issue(error: issues::errors::IssueError) -> ApiError {
use issues::errors::IssueError;
match error {
IssueError::BadRequest(message) | IssueError::Conflict(message) => {
ApiError::bad_request(message)
}
IssueError::Unauthorized => ApiError::unauthorized(),
IssueError::Forbidden => ApiError::forbidden(),
IssueError::NotFound => ApiError::not_found("resource not found"),
other => ApiError::internal(other.to_string()),
}
}
fn map_chat(error: chat::errors::ChatError) -> ApiError {
use chat::errors::ChatError;
match error {
ChatError::BadRequest(message) | ChatError::Conflict(message) => {
ApiError::bad_request(message)
}
ChatError::Unauthorized => ApiError::unauthorized(),
ChatError::Forbidden => ApiError::forbidden(),
ChatError::NotFound => ApiError::not_found("resource not found"),
ChatError::RateLimited(message) => ApiError::bad_request(message),
other => ApiError::internal(other.to_string()),
}
}
fn db_internal(error: sea_orm::DbErr) -> ApiError {
ApiError::internal(error.to_string())
}
async fn require_owner(
state: &CleanupState,
space_name: &str,
uid: i64,
) -> Result<models::space::spaces::Model, ApiError> {
let space = lookup::lookup_space_by_name(&state.space, space_name)
.await
.map_err(map_space)?;
if space.owner_user_id != uid {
return Err(ApiError::forbidden());
}
Ok(space)
}
async fn repo_in_space(
db: &impl sea_orm::ConnectionTrait,
space_id: Uuid,
repo_id: Uuid,
) -> Result<repos::Model, ApiError> {
repos::Entity::find_by_id(repo_id)
.filter(repos::Column::SpaceId.eq(space_id))
.one(db)
.await
.map_err(db_internal)?
.ok_or_else(|| ApiError::not_found("repository not found"))
}
async fn room_in_space(
db: &impl sea_orm::ConnectionTrait,
space_id: Uuid,
room_id: Uuid,
) -> Result<rooms::Model, ApiError> {
rooms::Entity::find_by_id(room_id)
.filter(rooms::Column::SpaceId.eq(space_id))
.one(db)
.await
.map_err(db_internal)?
.ok_or_else(|| ApiError::not_found("room not found"))
}
#[utoipa::path(
get,
path = "/spaces/{space_name}/cleanup",
tag = "cleanup",
description = "列出空间内全部仓库、问题看板与频道,仅空间所有者可访问。",
params(("space_name" = String, Path, description = "space name")),
responses(
(status = 200, description = "cleanup objects", body = AppResponseData<Vec<CleanupObjectDto>>)
)
)]
pub async fn list_cleanup_objects(
State(state): State<CleanupState>,
AuthedUser(uid): AuthedUser,
Path(space_name): Path<String>,
) -> Result<AppResponseData<Vec<CleanupObjectDto>>, ApiError> {
let space = require_owner(&state, &space_name, uid).await?;
let db = &state.database;
let repo_rows = repos::Entity::find()
.filter(repos::Column::SpaceId.eq(space.id))
.all(db.read())
.await
.map_err(db_internal)?;
let board_rows = boards::Entity::find()
.filter(boards::Column::SpaceId.eq(space.id))
.all(db.read())
.await
.map_err(db_internal)?;
let room_rows = rooms::Entity::find()
.filter(rooms::Column::SpaceId.eq(space.id))
.all(db.read())
.await
.map_err(db_internal)?;
let mut items = Vec::with_capacity(repo_rows.len() + board_rows.len() + room_rows.len());
for repo in repo_rows {
items.push(CleanupObjectDto {
id: repo.id,
kind: "repo".to_owned(),
name: repo.name,
is_private: Some(repo.is_private),
is_archived: repo.is_archived,
});
}
for board in board_rows {
items.push(CleanupObjectDto {
id: board.id,
kind: "board".to_owned(),
name: board.name,
is_private: None,
is_archived: false,
});
}
for room in room_rows {
items.push(CleanupObjectDto {
id: room.id,
kind: room_kind_label(room.kind).to_owned(),
name: room.name,
is_private: Some(room.is_private),
is_archived: !room.is_active,
});
}
items.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.cmp(&b.name)));
Ok(AppResponseData::ok(items))
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/cleanup/{kind}/{object_id}/visibility",
tag = "cleanup",
description = "切换仓库或频道的可见性(私密 / 空间内公开)。",
params(
("space_name" = String, Path, description = "space name"),
("kind" = String, Path, description = "object kind"),
("object_id" = Uuid, Path, description = "repo id or room id")
),
request_body = SetCleanupVisibilityCmd,
responses((status = 200, description = "updated", body = AppResponseData<NoData>))
)]
pub async fn set_cleanup_visibility(
State(state): State<CleanupState>,
AuthedUser(uid): AuthedUser,
Path((space_name, kind, object_id)): Path<(String, String, Uuid)>,
Json(cmd): Json<SetCleanupVisibilityCmd>,
) -> Result<AppResponseData<NoData>, ApiError> {
let space = require_owner(&state, &space_name, uid).await?;
let db = &state.database;
match kind.as_str() {
"repo" => {
let repo = repo_in_space(db.read(), space.id, object_id).await?;
let update = git::service::repos::UpdateRepoCmd {
is_private: Some(cmd.is_private),
..Default::default()
};
let ctx = state
.git
.context(space_name.clone(), repo.name.clone())
.await
.map_err(map_git)?;
state
.git
.update_repo(&ctx, uid, update)
.await
.map_err(map_git)?;
}
"text" | "voice" | "stage" | "category" | "news" | "guild_room" | "thread" => {
let room = room_in_space(db.read(), space.id, object_id).await?;
let mut am: rooms::ActiveModel = room.into();
am.is_private = Set(cmd.is_private);
am.updated_at = Set(time::OffsetDateTime::now_utc());
am.update(db.write()).await.map_err(db_internal)?;
}
_ => {
return Err(ApiError::bad_request(
"visibility is not adjustable for this object type",
));
}
}
Ok(AppResponseData::empty())
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/cleanup/{kind}/{object_id}/archive",
tag = "cleanup",
description = "归档 / 取消归档仓库;仓库没有硬删除,危险区用归档表达下架。",
params(
("space_name" = String, Path, description = "space name"),
("kind" = String, Path, description = "object kind"),
("object_id" = Uuid, Path, description = "repo id")
),
request_body = SetRepoArchivedCmd,
responses((status = 200, description = "updated", body = AppResponseData<NoData>))
)]
pub async fn set_cleanup_archive(
State(state): State<CleanupState>,
AuthedUser(uid): AuthedUser,
Path((space_name, kind, object_id)): Path<(String, String, Uuid)>,
Json(cmd): Json<SetRepoArchivedCmd>,
) -> Result<AppResponseData<NoData>, ApiError> {
let space = require_owner(&state, &space_name, uid).await?;
if kind != "repo" {
return Err(ApiError::bad_request("only repositories support archiving"));
}
let repo = repo_in_space(state.database.read(), space.id, object_id).await?;
let ctx = state
.git
.context(space_name.clone(), repo.name.clone())
.await
.map_err(map_git)?;
state
.git
.set_repo_archived(&ctx, uid, cmd.archived)
.await
.map_err(map_git)?;
Ok(AppResponseData::empty())
}
#[utoipa::path(
delete,
path = "/spaces/{space_name}/cleanup/{kind}/{object_id}",
tag = "cleanup",
description = "删除对象:问题看板与频道调用各自模块的清理逻辑;仓库只能用归档。",
params(
("space_name" = String, Path, description = "space name"),
("kind" = String, Path, description = "object kind"),
("object_id" = Uuid, Path, description = "board id or room id")
),
responses((status = 200, description = "deleted", body = AppResponseData<NoData>))
)]
pub async fn delete_cleanup_object(
State(state): State<CleanupState>,
AuthedUser(uid): AuthedUser,
Path((space_name, kind, object_id)): Path<(String, String, Uuid)>,
) -> Result<AppResponseData<NoData>, ApiError> {
let space = require_owner(&state, &space_name, uid).await?;
let db = &state.database;
match kind.as_str() {
"repo" => {
return Err(ApiError::bad_request(
"repositories cannot be deleted here; archive them instead",
));
}
"board" => {
let board = boards::Entity::find_by_id(object_id)
.filter(boards::Column::SpaceId.eq(space.id))
.one(db.read())
.await
.map_err(db_internal)?
.ok_or_else(|| ApiError::not_found("board not found"))?;
state
.issues
.delete_board(uid, &space_name, board.id)
.await
.map_err(map_issue)?;
}
"text" | "voice" | "stage" | "category" | "news" | "guild_room" | "thread" => {
let room = room_in_space(db.read(), space.id, object_id).await?;
state
.chat
.delete_room(uid, room.id)
.await
.map_err(map_chat)?;
}
_ => {
return Err(ApiError::bad_request("unsupported object kind"));
}
}
Ok(AppResponseData::empty())
}
#[derive(OpenApi)]
#[openapi(
paths(
crate::cleanup::list_cleanup_objects,
crate::cleanup::set_cleanup_visibility,
crate::cleanup::set_cleanup_archive,
crate::cleanup::delete_cleanup_object,
),
components(schemas(
crate::cleanup::CleanupObjectDto,
crate::cleanup::SetCleanupVisibilityCmd,
crate::cleanup::SetRepoArchivedCmd,
AppResponseData<Vec<crate::cleanup::CleanupObjectDto>>,
AppResponseData<NoData>,
))
)]
pub struct CleanupApiDoc;