mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
show in/out schema
This commit is contained in:
@@ -117,9 +117,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
moderation::tech_review::global::get_global_issue_detail,
|
||||
moderation::tech_review::rules::get_rules,
|
||||
moderation::tech_review::rules::test_rule,
|
||||
moderation::tech_review::rules::get_rule_affected_details,
|
||||
moderation::tech_review::rules::create_rule,
|
||||
moderation::tech_review::rules::update_rule,
|
||||
moderation::tech_review::rules::delete_rule,
|
||||
moderation::tech_review::rules_scan::get_rule_schema,
|
||||
moderation::tech_review::rules_scan::scan_rules,
|
||||
moderation::tech_review::get_project_report,
|
||||
moderation::tech_review::submit_report,
|
||||
|
||||
@@ -5,13 +5,23 @@ use chrono::{DateTime, Utc};
|
||||
use eyre::eyre;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::rules_scan::{
|
||||
RuleArtifact, RuleInput, RuleScan, RuleScope, RuleTrace,
|
||||
};
|
||||
use crate::{
|
||||
auth::check_is_moderator_from_headers,
|
||||
database::{
|
||||
PgPool, ReadOnlyPgPool, models::delphi_report_item::DelphiSeverity,
|
||||
PgPool, ReadOnlyPgPool,
|
||||
models::{
|
||||
DBProjectId, DBVersionId, DelphiReportIssueDetailsId,
|
||||
DelphiReportIssueId, delphi_report_item::DelphiSeverity,
|
||||
},
|
||||
redis::RedisPool,
|
||||
},
|
||||
models::pats::Scopes,
|
||||
models::{
|
||||
ids::{ProjectId, VersionId},
|
||||
pats::Scopes,
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
@@ -24,6 +34,7 @@ const MAX_RULE_TEST_TRACES: usize = 10;
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(get_rules)
|
||||
.service(test_rule)
|
||||
.service(get_rule_affected_details)
|
||||
.service(create_rule)
|
||||
.service(update_rule)
|
||||
.service(delete_rule);
|
||||
@@ -39,6 +50,27 @@ pub struct DelphiRule {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub created_by: Option<i64>,
|
||||
pub updated_by: Option<i64>,
|
||||
pub affected_details_count: i64,
|
||||
pub affected_details: Vec<DelphiRuleAffectedDetail>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct DelphiRuleAffectedDetail {
|
||||
pub detail_id: DelphiReportIssueDetailsId,
|
||||
pub issue_id: DelphiReportIssueId,
|
||||
pub project_id: Option<ProjectId>,
|
||||
pub project_name: Option<String>,
|
||||
pub project_icon_url: Option<String>,
|
||||
pub version_id: Option<VersionId>,
|
||||
pub version_name: Option<String>,
|
||||
pub version_number: Option<String>,
|
||||
pub issue_type: String,
|
||||
pub key: String,
|
||||
pub jar: Option<String>,
|
||||
pub file_path: String,
|
||||
pub original_severity: DelphiSeverity,
|
||||
pub severity: Option<DelphiSeverity>,
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
@@ -77,33 +109,6 @@ pub struct DelphiRuleEffect {
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TestRuleInput<'a> {
|
||||
schema_version: u32,
|
||||
trace: &'a TestDelphiRuleTrace,
|
||||
scan: TestRuleScan,
|
||||
artifact: TestRuleArtifact,
|
||||
scope: TestRuleScope,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TestRuleScan {
|
||||
delphi_version: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TestRuleArtifact {
|
||||
size: u32,
|
||||
hashes: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TestRuleScope {
|
||||
project_id: String,
|
||||
version_id: String,
|
||||
file_id: String,
|
||||
}
|
||||
|
||||
struct ValidatedRule {
|
||||
name: String,
|
||||
rule: String,
|
||||
@@ -203,22 +208,29 @@ pub async fn test_rule(
|
||||
Ok(web::Json(TestDelphiRuleResponse { effects }))
|
||||
}
|
||||
|
||||
fn test_rule_input(trace: &TestDelphiRuleTrace) -> TestRuleInput<'_> {
|
||||
TestRuleInput {
|
||||
fn test_rule_input(trace: &TestDelphiRuleTrace) -> RuleInput {
|
||||
RuleInput {
|
||||
schema_version: 1,
|
||||
trace,
|
||||
scan: TestRuleScan { delphi_version: 17 },
|
||||
artifact: TestRuleArtifact {
|
||||
size: 412_892,
|
||||
trace: RuleTrace {
|
||||
key: trace.key.clone(),
|
||||
issue_type: trace.issue_type.clone(),
|
||||
severity: trace.severity,
|
||||
jar: trace.jar.clone(),
|
||||
file_path: trace.file_path.clone(),
|
||||
data: trace.data.clone(),
|
||||
},
|
||||
scan: RuleScan { delphi_version: 17 },
|
||||
artifact: RuleArtifact {
|
||||
size: Some(412_892),
|
||||
hashes: BTreeMap::from([
|
||||
("sha1".to_string(), "0123456789abcdef".to_string()),
|
||||
("sha512".to_string(), "fedcba9876543210".to_string()),
|
||||
]),
|
||||
},
|
||||
scope: TestRuleScope {
|
||||
project_id: "example-project".to_string(),
|
||||
version_id: "example-version".to_string(),
|
||||
file_id: "example-file".to_string(),
|
||||
scope: RuleScope {
|
||||
project_id: Some("example-project".to_string()),
|
||||
version_id: Some("example-version".to_string()),
|
||||
file_id: Some("example-file".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -250,27 +262,81 @@ pub async fn get_rules(
|
||||
let rules = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
rule,
|
||||
revision,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by
|
||||
FROM delphi_rules
|
||||
WHERE NOT delete_on_next_revision
|
||||
ORDER BY id
|
||||
delphi_rule.id,
|
||||
delphi_rule.name,
|
||||
delphi_rule.rule,
|
||||
delphi_rule.revision,
|
||||
delphi_rule.created_at,
|
||||
delphi_rule.updated_at,
|
||||
delphi_rule.created_by,
|
||||
delphi_rule.updated_by,
|
||||
COALESCE(preview.affected_details_count, 0)
|
||||
AS "affected_details_count!",
|
||||
preview.detail_id AS "detail_id?: DelphiReportIssueDetailsId",
|
||||
preview.issue_id AS "issue_id?: DelphiReportIssueId",
|
||||
preview.project_id AS "project_id?: DBProjectId",
|
||||
preview.project_name AS "project_name?",
|
||||
preview.project_icon_url AS "project_icon_url?",
|
||||
preview.version_id AS "version_id?: DBVersionId",
|
||||
preview.version_name AS "version_name?",
|
||||
preview.version_number AS "version_number?",
|
||||
preview.issue_type AS "issue_type?",
|
||||
preview.key AS "key?",
|
||||
preview.jar AS "jar?",
|
||||
preview.file_path AS "file_path?",
|
||||
preview.original_severity AS "original_severity?: DelphiSeverity",
|
||||
preview.severity AS "effect_severity?: DelphiSeverity",
|
||||
preview.hidden AS "hidden?"
|
||||
FROM delphi_rules delphi_rule
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
effect.detail_id,
|
||||
detail.issue_id,
|
||||
version.mod_id AS project_id,
|
||||
project.name AS project_name,
|
||||
project.icon_url AS project_icon_url,
|
||||
version.id AS version_id,
|
||||
version.name AS version_name,
|
||||
version.version_number,
|
||||
issue.issue_type,
|
||||
detail.key,
|
||||
detail.jar,
|
||||
detail.file_path,
|
||||
detail.severity AS original_severity,
|
||||
effect.severity,
|
||||
effect.hidden,
|
||||
COUNT(*) OVER () AS affected_details_count
|
||||
FROM delphi_rule_effects effect
|
||||
INNER JOIN delphi_rule_revisions published
|
||||
ON published.revision = effect.revision
|
||||
INNER JOIN delphi_report_issue_details detail
|
||||
ON detail.id = effect.detail_id
|
||||
INNER JOIN delphi_report_issues issue
|
||||
ON issue.id = detail.issue_id
|
||||
INNER JOIN delphi_reports report
|
||||
ON report.id = issue.report_id
|
||||
LEFT JOIN files file ON file.id = report.file_id
|
||||
LEFT JOIN versions version ON version.id = file.version_id
|
||||
LEFT JOIN mods project ON project.id = version.mod_id
|
||||
WHERE effect.rule_id = delphi_rule.id
|
||||
ORDER BY effect.detail_id DESC
|
||||
LIMIT 3
|
||||
) preview ON TRUE
|
||||
WHERE NOT delphi_rule.delete_on_next_revision
|
||||
ORDER BY delphi_rule.id, preview.detail_id DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&***ro_pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch delphi rules")?;
|
||||
|
||||
Ok(web::Json(
|
||||
rules
|
||||
.into_iter()
|
||||
.map(|rule| DelphiRule {
|
||||
let mut response = Vec::<DelphiRule>::new();
|
||||
for rule in rules {
|
||||
if response
|
||||
.last()
|
||||
.is_none_or(|existing| existing.id != rule.id)
|
||||
{
|
||||
response.push(DelphiRule {
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
rule: rule.rule,
|
||||
@@ -279,6 +345,137 @@ pub async fn get_rules(
|
||||
updated_at: rule.updated_at,
|
||||
created_by: rule.created_by,
|
||||
updated_by: rule.updated_by,
|
||||
affected_details_count: rule.affected_details_count,
|
||||
affected_details: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if let (
|
||||
Some(detail_id),
|
||||
Some(issue_id),
|
||||
Some(issue_type),
|
||||
Some(key),
|
||||
Some(file_path),
|
||||
Some(original_severity),
|
||||
Some(hidden),
|
||||
) = (
|
||||
rule.detail_id,
|
||||
rule.issue_id,
|
||||
rule.issue_type,
|
||||
rule.key,
|
||||
rule.file_path,
|
||||
rule.original_severity,
|
||||
rule.hidden,
|
||||
) {
|
||||
response
|
||||
.last_mut()
|
||||
.expect("a delphi rule was inserted above")
|
||||
.affected_details
|
||||
.push(DelphiRuleAffectedDetail {
|
||||
detail_id,
|
||||
issue_id,
|
||||
project_id: rule.project_id.map(ProjectId::from),
|
||||
project_name: rule.project_name,
|
||||
project_icon_url: rule.project_icon_url,
|
||||
version_id: rule.version_id.map(VersionId::from),
|
||||
version_name: rule.version_name,
|
||||
version_number: rule.version_number,
|
||||
issue_type,
|
||||
key,
|
||||
jar: rule.jar,
|
||||
file_path,
|
||||
original_severity,
|
||||
severity: rule.effect_severity,
|
||||
hidden,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(web::Json(response))
|
||||
}
|
||||
|
||||
/// List all details affected by a Delphi rule in the published revision.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
security(("bearer_auth" = [])),
|
||||
responses((status = OK, body = Vec<DelphiRuleAffectedDetail>))
|
||||
)]
|
||||
#[get("/rules/{id}/effects")]
|
||||
pub async fn get_rule_affected_details(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
ro_pool: web::Data<ReadOnlyPgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
path: web::Path<(i64,)>,
|
||||
) -> Result<web::Json<Vec<DelphiRuleAffectedDetail>>, ApiError> {
|
||||
check_is_moderator_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_READ,
|
||||
)
|
||||
.await?;
|
||||
let (rule_id,) = path.into_inner();
|
||||
|
||||
let details = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
effect.detail_id AS "detail_id!: DelphiReportIssueDetailsId",
|
||||
detail.issue_id AS "issue_id!: DelphiReportIssueId",
|
||||
version.mod_id AS "project_id?: DBProjectId",
|
||||
project.name AS "project_name?",
|
||||
project.icon_url AS "project_icon_url?",
|
||||
version.id AS "version_id?: DBVersionId",
|
||||
version.name AS "version_name?",
|
||||
version.version_number AS "version_number?",
|
||||
issue.issue_type,
|
||||
detail.key,
|
||||
detail.jar,
|
||||
detail.file_path,
|
||||
detail.severity AS "original_severity!: DelphiSeverity",
|
||||
effect.severity AS "effect_severity: DelphiSeverity",
|
||||
effect.hidden
|
||||
FROM delphi_rule_effects effect
|
||||
INNER JOIN delphi_rule_revisions published
|
||||
ON published.revision = effect.revision
|
||||
INNER JOIN delphi_report_issue_details detail
|
||||
ON detail.id = effect.detail_id
|
||||
INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id
|
||||
INNER JOIN delphi_reports report ON report.id = issue.report_id
|
||||
LEFT JOIN files file ON file.id = report.file_id
|
||||
LEFT JOIN versions version ON version.id = file.version_id
|
||||
LEFT JOIN mods project ON project.id = version.mod_id
|
||||
WHERE effect.rule_id = $1
|
||||
ORDER BY effect.detail_id DESC
|
||||
"#,
|
||||
rule_id,
|
||||
)
|
||||
.fetch_all(&***ro_pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch details affected by delphi rule")?;
|
||||
|
||||
Ok(web::Json(
|
||||
details
|
||||
.into_iter()
|
||||
.map(|detail| DelphiRuleAffectedDetail {
|
||||
detail_id: detail.detail_id,
|
||||
issue_id: detail.issue_id,
|
||||
project_id: detail.project_id.map(ProjectId::from),
|
||||
project_name: detail.project_name,
|
||||
project_icon_url: detail.project_icon_url,
|
||||
version_id: detail.version_id.map(VersionId::from),
|
||||
version_name: detail.version_name,
|
||||
version_number: detail.version_number,
|
||||
issue_type: detail.issue_type,
|
||||
key: detail.key,
|
||||
jar: detail.jar,
|
||||
file_path: detail.file_path,
|
||||
original_severity: detail.original_severity,
|
||||
severity: detail.effect_severity,
|
||||
hidden: detail.hidden,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
@@ -354,6 +551,8 @@ pub async fn create_rule(
|
||||
updated_at: rule.updated_at,
|
||||
created_by: rule.created_by,
|
||||
updated_by: rule.updated_by,
|
||||
affected_details_count: 0,
|
||||
affected_details: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -427,6 +626,8 @@ pub async fn update_rule(
|
||||
updated_at: rule.updated_at,
|
||||
created_by: rule.created_by,
|
||||
updated_by: rule.updated_by,
|
||||
affected_details_count: 0,
|
||||
affected_details: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use actix_web::{HttpRequest, HttpResponse, post, web};
|
||||
use actix_web::{HttpRequest, HttpResponse, get, post, web};
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use bytes::Bytes;
|
||||
use eyre::{Context as _, Result, eyre};
|
||||
@@ -9,6 +9,7 @@ use serde::Serialize;
|
||||
use sqlx::types::Json;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||
use utoipa::{PartialSchema, ToSchema};
|
||||
|
||||
use super::rules::DelphiRuleEffect;
|
||||
use crate::{
|
||||
@@ -26,7 +27,7 @@ const RULE_SCAN_LOCK_ID: i64 = 0x6465_6c70_6869_7275;
|
||||
const PROGRESS_INTERVAL: usize = 50;
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(scan_rules);
|
||||
cfg.service(get_rule_schema).service(scan_rules);
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -43,41 +44,41 @@ struct RuleScanErrorEvent<'a> {
|
||||
message: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RuleInput {
|
||||
schema_version: u32,
|
||||
trace: RuleTrace,
|
||||
scan: RuleScan,
|
||||
artifact: RuleArtifact,
|
||||
scope: RuleScope,
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub(super) struct RuleInput {
|
||||
pub(super) schema_version: u32,
|
||||
pub(super) trace: RuleTrace,
|
||||
pub(super) scan: RuleScan,
|
||||
pub(super) artifact: RuleArtifact,
|
||||
pub(super) scope: RuleScope,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RuleTrace {
|
||||
key: String,
|
||||
issue_type: String,
|
||||
severity: DelphiSeverity,
|
||||
jar: Option<String>,
|
||||
file_path: String,
|
||||
data: HashMap<String, serde_json::Value>,
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub(super) struct RuleTrace {
|
||||
pub(super) key: String,
|
||||
pub(super) issue_type: String,
|
||||
pub(super) severity: DelphiSeverity,
|
||||
pub(super) jar: Option<String>,
|
||||
pub(super) file_path: String,
|
||||
pub(super) data: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RuleScan {
|
||||
delphi_version: i32,
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub(super) struct RuleScan {
|
||||
pub(super) delphi_version: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RuleArtifact {
|
||||
size: Option<i32>,
|
||||
hashes: BTreeMap<String, String>,
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub(super) struct RuleArtifact {
|
||||
pub(super) size: Option<i32>,
|
||||
pub(super) hashes: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RuleScope {
|
||||
project_id: Option<String>,
|
||||
version_id: Option<String>,
|
||||
file_id: Option<String>,
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub(super) struct RuleScope {
|
||||
pub(super) project_id: Option<String>,
|
||||
pub(super) version_id: Option<String>,
|
||||
pub(super) file_id: Option<String>,
|
||||
}
|
||||
|
||||
struct CompiledRule {
|
||||
@@ -98,6 +99,62 @@ struct ScanSummary {
|
||||
effects: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct DelphiRuleSchemaResponse {
|
||||
pub input: serde_json::Value,
|
||||
pub output: serde_json::Value,
|
||||
pub components: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Get the schemas for the CEL input and output values.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
security(("bearer_auth" = [])),
|
||||
responses((status = OK, body = DelphiRuleSchemaResponse))
|
||||
)]
|
||||
#[get("/rules/schema")]
|
||||
pub async fn get_rule_schema(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<DelphiRuleSchemaResponse>, ApiError> {
|
||||
check_is_moderator_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_READ,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut schemas = Vec::new();
|
||||
<RuleInput as ToSchema>::schemas(&mut schemas);
|
||||
<Option<DelphiRuleEffect> as ToSchema>::schemas(&mut schemas);
|
||||
|
||||
Ok(web::Json(DelphiRuleSchemaResponse {
|
||||
input: schema_to_value(<RuleInput as PartialSchema>::schema())?,
|
||||
output: schema_to_value(
|
||||
<Option<DelphiRuleEffect> as PartialSchema>::schema(),
|
||||
)?,
|
||||
components: schemas
|
||||
.into_iter()
|
||||
.map(|(name, schema)| Ok((name, schema_to_value(schema)?)))
|
||||
.collect::<Result<_, ApiError>>()?,
|
||||
}))
|
||||
}
|
||||
|
||||
fn schema_to_value<T: Serialize>(
|
||||
schema: T,
|
||||
) -> Result<serde_json::Value, ApiError> {
|
||||
serde_json::to_value(schema).map_err(|error| {
|
||||
ApiError::Internal(
|
||||
eyre!(error).wrap_err("failed to serialize Delphi rule schema"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-evaluate every Delphi issue detail and atomically publish a new rule revision.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
@@ -316,12 +373,13 @@ async fn run_scan(
|
||||
};
|
||||
|
||||
for rule in &rules {
|
||||
let effect = evaluate_rule(&rule.program, &input).wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to evaluate delphi rule {} for detail {detail_id}",
|
||||
rule.id
|
||||
)
|
||||
})?;
|
||||
let effect = evaluate_rule(&rule.program, &input)
|
||||
.wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to evaluate delphi rule {} for detail {detail_id}",
|
||||
rule.id
|
||||
)
|
||||
})?;
|
||||
if let Some(effect) = effect {
|
||||
effects.push(MaterializedEffect {
|
||||
detail_id,
|
||||
|
||||
Reference in New Issue
Block a user