feat: consolidate local-first mnote web runtime
This commit is contained in:
@@ -26,6 +26,10 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
||||
"v5-sidebar-shortcut-root-uri",
|
||||
include_str!("../migrations/005-sidebar-shortcut-root-uri.sql"),
|
||||
),
|
||||
(
|
||||
"v6-navigation-recent",
|
||||
include_str!("../migrations/006-navigation-recent.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
/// Create the `_migrations` meta-table if it does not exist.
|
||||
@@ -108,6 +112,7 @@ mod tests {
|
||||
"ai_runtime_events",
|
||||
"sidebar_shortcuts",
|
||||
"user_ui_preferences",
|
||||
"user_navigation_recent",
|
||||
] {
|
||||
assert!(table_exists(&conn, table), "table {table} should exist");
|
||||
}
|
||||
|
||||
@@ -243,6 +243,40 @@ pub struct UpsertUserUiPreferenceInput {
|
||||
pub value_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NavigationRecentRecord {
|
||||
pub id: EntityId,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub kind: String,
|
||||
pub source_kind: String,
|
||||
pub root_uri: String,
|
||||
pub target_key: String,
|
||||
pub relative_path: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub title: String,
|
||||
pub status: String,
|
||||
pub metadata_json: String,
|
||||
pub visited_at: Timestamp,
|
||||
pub created_at: Timestamp,
|
||||
pub updated_at: Timestamp,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpsertNavigationRecentInput {
|
||||
pub id: Option<EntityId>,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub kind: String,
|
||||
pub source_kind: String,
|
||||
pub root_uri: String,
|
||||
pub relative_path: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub title: String,
|
||||
pub metadata_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AiPolicyRecord {
|
||||
pub id: EntityId,
|
||||
|
||||
@@ -11,10 +11,11 @@ use crate::model::{
|
||||
AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord,
|
||||
AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput,
|
||||
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup,
|
||||
DirectoryGrantRecord, OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession,
|
||||
ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput,
|
||||
UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
|
||||
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
|
||||
DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord,
|
||||
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
|
||||
UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput,
|
||||
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput,
|
||||
UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
|
||||
};
|
||||
use crate::store::ControlPlaneStore;
|
||||
|
||||
@@ -230,6 +231,56 @@ fn row_to_user_ui_preference(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserUi
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_navigation_recent(row: &rusqlite::Row<'_>) -> rusqlite::Result<NavigationRecentRecord> {
|
||||
Ok(NavigationRecentRecord {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
workspace_id: empty_string_to_option(row.get(2)?),
|
||||
kind: row.get(3)?,
|
||||
source_kind: row.get(4)?,
|
||||
root_uri: row.get(5)?,
|
||||
target_key: row.get(6)?,
|
||||
relative_path: row.get(7)?,
|
||||
document_id: row.get(8)?,
|
||||
title: row.get(9)?,
|
||||
status: row.get(10)?,
|
||||
metadata_json: row.get(11)?,
|
||||
visited_at: row.get(12)?,
|
||||
created_at: row.get(13)?,
|
||||
updated_at: row.get(14)?,
|
||||
revision: row.get(15)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn navigation_recent_target_key(
|
||||
kind: &str,
|
||||
root_uri: &str,
|
||||
relative_path: Option<&str>,
|
||||
document_id: Option<&str>,
|
||||
) -> Result<String, ControlPlaneError> {
|
||||
match kind {
|
||||
"folder" => Ok(format!(
|
||||
"folder:{}:{}",
|
||||
root_uri.trim(),
|
||||
relative_path.map(str::trim).unwrap_or_default()
|
||||
)),
|
||||
"page" => {
|
||||
let document_id = document_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
ControlPlaneError::InvalidInput(
|
||||
"navigation recent page 需要 document_id".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(format!("page:{}:{document_id}", root_uri.trim()))
|
||||
}
|
||||
_ => Err(ControlPlaneError::InvalidInput(
|
||||
"navigation recent kind 只能是 folder 或 page".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_ai_policy(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiPolicyRecord> {
|
||||
Ok(AiPolicyRecord {
|
||||
id: row.get(0)?,
|
||||
@@ -313,6 +364,33 @@ fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
|
||||
title.chars().take(80).collect()
|
||||
}
|
||||
|
||||
fn prune_navigation_recent_for_kind(
|
||||
conn: &Connection,
|
||||
user_id: &str,
|
||||
kind: &str,
|
||||
) -> Result<(), ControlPlaneError> {
|
||||
let keep = match kind {
|
||||
"folder" => 10_i64,
|
||||
"page" => 20_i64,
|
||||
_ => 20_i64,
|
||||
};
|
||||
conn.execute(
|
||||
"UPDATE user_navigation_recent
|
||||
SET status = 'removed', updated_at = ?1, revision = revision + 1
|
||||
WHERE user_id = ?2
|
||||
AND kind = ?3
|
||||
AND status = 'active'
|
||||
AND id NOT IN (
|
||||
SELECT id FROM user_navigation_recent
|
||||
WHERE user_id = ?2 AND kind = ?3 AND status = 'active'
|
||||
ORDER BY visited_at DESC, updated_at DESC
|
||||
LIMIT ?4
|
||||
)",
|
||||
params![now_text(), user_id, kind, keep],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError> {
|
||||
if input.username.trim().is_empty() {
|
||||
@@ -864,7 +942,8 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
AND status = 'active'
|
||||
AND (
|
||||
?2 = root_uri
|
||||
OR (recursive = 1 AND ?2 LIKE root_uri || '%')
|
||||
OR (recursive = 1 AND ?2 LIKE root_uri || '/%')
|
||||
OR (recursive = 1 AND root_uri LIKE '%/' AND ?2 LIKE root_uri || '%')
|
||||
OR (?3 != '' AND ?3 = root_path)
|
||||
OR (?3 != '' AND recursive = 1 AND ?3 LIKE root_path || '/%')
|
||||
)",
|
||||
@@ -1382,6 +1461,138 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn upsert_navigation_recent(
|
||||
&self,
|
||||
input: UpsertNavigationRecentInput,
|
||||
) -> Result<NavigationRecentRecord, ControlPlaneError> {
|
||||
let user_id = input.user_id.trim().to_string();
|
||||
let workspace_id = option_to_stored_text(input.workspace_id);
|
||||
let kind = input.kind.trim().to_string();
|
||||
let source_kind = input.source_kind.trim().to_string();
|
||||
let root_uri = input.root_uri.trim().to_string();
|
||||
let relative_path = input
|
||||
.relative_path
|
||||
.map(|value| value.trim().trim_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let document_id = input
|
||||
.document_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let title = input.title.trim().to_string();
|
||||
if user_id.is_empty()
|
||||
|| kind.is_empty()
|
||||
|| source_kind.is_empty()
|
||||
|| root_uri.is_empty()
|
||||
|| title.is_empty()
|
||||
{
|
||||
return Err(ControlPlaneError::InvalidInput(
|
||||
"navigation recent user/kind/source/root/title 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(&input.metadata_json)?;
|
||||
let target_key = navigation_recent_target_key(
|
||||
&kind,
|
||||
&root_uri,
|
||||
relative_path.as_deref(),
|
||||
document_id.as_deref(),
|
||||
)?;
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = now_text();
|
||||
conn.execute(
|
||||
"INSERT INTO user_navigation_recent (
|
||||
id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
|
||||
relative_path, document_id, title, status, metadata_json, visited_at,
|
||||
created_at, updated_at, revision
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active', ?11, ?12, ?13, ?14, 1)
|
||||
ON CONFLICT(user_id, kind, target_key)
|
||||
DO UPDATE SET
|
||||
workspace_id = excluded.workspace_id,
|
||||
source_kind = excluded.source_kind,
|
||||
root_uri = excluded.root_uri,
|
||||
relative_path = excluded.relative_path,
|
||||
document_id = excluded.document_id,
|
||||
title = excluded.title,
|
||||
status = 'active',
|
||||
metadata_json = excluded.metadata_json,
|
||||
visited_at = excluded.visited_at,
|
||||
updated_at = excluded.updated_at,
|
||||
revision = user_navigation_recent.revision + 1",
|
||||
params![
|
||||
input.id.unwrap_or_else(|| new_id("nav_recent")),
|
||||
user_id,
|
||||
workspace_id,
|
||||
kind,
|
||||
source_kind,
|
||||
root_uri,
|
||||
target_key,
|
||||
relative_path,
|
||||
document_id,
|
||||
title,
|
||||
input.metadata_json,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
prune_navigation_recent_for_kind(&conn, &user_id, &kind)?;
|
||||
let record = conn.query_row(
|
||||
"SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
|
||||
relative_path, document_id, title, status, metadata_json, visited_at,
|
||||
created_at, updated_at, revision
|
||||
FROM user_navigation_recent
|
||||
WHERE user_id = ?1 AND kind = ?2 AND target_key = ?3
|
||||
LIMIT 1",
|
||||
params![user_id, kind, target_key],
|
||||
row_to_navigation_recent,
|
||||
)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn list_navigation_recent(
|
||||
&self,
|
||||
user_id: &str,
|
||||
kind: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<NavigationRecentRecord>, ControlPlaneError> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let limit = limit.clamp(1, 100) as i64;
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let rows = if let Some(kind) = kind.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
|
||||
relative_path, document_id, title, status, metadata_json, visited_at,
|
||||
created_at, updated_at, revision
|
||||
FROM user_navigation_recent
|
||||
WHERE user_id = ?1 AND kind = ?2 AND status = 'active'
|
||||
ORDER BY visited_at DESC, updated_at DESC
|
||||
LIMIT ?3",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![user_id, kind, limit], row_to_navigation_recent)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
rows
|
||||
} else {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
|
||||
relative_path, document_id, title, status, metadata_json, visited_at,
|
||||
created_at, updated_at, revision
|
||||
FROM user_navigation_recent
|
||||
WHERE user_id = ?1 AND status = 'active'
|
||||
ORDER BY visited_at DESC, updated_at DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![user_id, limit], row_to_navigation_recent)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
rows
|
||||
};
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn upsert_ai_policy(
|
||||
&self,
|
||||
input: UpsertAiPolicyInput,
|
||||
@@ -1985,7 +2196,8 @@ mod tests {
|
||||
use crate::model::{
|
||||
password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput,
|
||||
CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
|
||||
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserUiPreferenceInput,
|
||||
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput,
|
||||
UpsertUserUiPreferenceInput,
|
||||
};
|
||||
|
||||
fn store() -> SqliteControlPlaneStore {
|
||||
@@ -2158,6 +2370,36 @@ mod tests {
|
||||
assert_eq!(access.grant_ids.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_access_does_not_match_sibling_uri_prefix() {
|
||||
let store = store();
|
||||
create_user(&store, "reader");
|
||||
store
|
||||
.grant_directory_access(DirectoryGrantInput {
|
||||
user_id: "reader".to_string(),
|
||||
workspace_id: None,
|
||||
root_uri: "file:///tmp/mnote-ai-root".to_string(),
|
||||
root_path: "/tmp/mnote-ai-root".to_string(),
|
||||
permission: "write".to_string(),
|
||||
recursive: true,
|
||||
capabilities: vec!["ai".to_string()],
|
||||
source: "admin".to_string(),
|
||||
created_by: None,
|
||||
})
|
||||
.expect("grant root");
|
||||
|
||||
let inside = store
|
||||
.resolve_access("reader", "file:///tmp/mnote-ai-root/page.md")
|
||||
.expect("resolve inside");
|
||||
assert_eq!(inside.permission, "write");
|
||||
|
||||
let sibling = store
|
||||
.resolve_access("reader", "file:///tmp/mnote-ai-root-sibling/page.md")
|
||||
.expect("resolve sibling");
|
||||
assert_eq!(sibling.permission, "none");
|
||||
assert!(sibling.grant_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_link_token_is_hashed_and_revocable() {
|
||||
let store = store();
|
||||
@@ -2474,6 +2716,99 @@ mod tests {
|
||||
assert!(bob_sidebar_preferences.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_recent_is_user_scoped_upserted_and_limited_by_kind() {
|
||||
let store = store();
|
||||
create_user(&store, "alice");
|
||||
create_user(&store, "bob");
|
||||
|
||||
let first = store
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "alice".to_string(),
|
||||
workspace_id: Some("local:_tmp_mnote".to_string()),
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: "file:///tmp/mnote".to_string(),
|
||||
relative_path: Some("design".to_string()),
|
||||
document_id: None,
|
||||
title: "design".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("insert folder recent");
|
||||
let updated = store
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "alice".to_string(),
|
||||
workspace_id: Some("local:_tmp_mnote".to_string()),
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: "file:///tmp/mnote".to_string(),
|
||||
relative_path: Some("design".to_string()),
|
||||
document_id: None,
|
||||
title: "Design".to_string(),
|
||||
metadata_json: "{\"from\":\"test\"}".to_string(),
|
||||
})
|
||||
.expect("upsert folder recent");
|
||||
assert_eq!(updated.id, first.id);
|
||||
assert_eq!(updated.title, "Design");
|
||||
assert_eq!(updated.revision, first.revision + 1);
|
||||
|
||||
store
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "alice".to_string(),
|
||||
workspace_id: Some("local:_tmp_mnote".to_string()),
|
||||
kind: "page".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: "file:///tmp/mnote".to_string(),
|
||||
relative_path: Some("design/Plan.md".to_string()),
|
||||
document_id: Some("local-md:design~2FPlan.md".to_string()),
|
||||
title: "Plan".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("insert page recent");
|
||||
for index in 0..12 {
|
||||
store
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "alice".to_string(),
|
||||
workspace_id: Some("local:_tmp_mnote".to_string()),
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: "file:///tmp/mnote".to_string(),
|
||||
relative_path: Some(format!("folder-{index}")),
|
||||
document_id: None,
|
||||
title: format!("folder-{index}"),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("insert bounded folder recent");
|
||||
}
|
||||
|
||||
let alice_folders = store
|
||||
.list_navigation_recent("alice", Some("folder"), 50)
|
||||
.expect("list alice folders");
|
||||
assert_eq!(alice_folders.len(), 10);
|
||||
assert_eq!(alice_folders[0].kind, "folder");
|
||||
assert_eq!(alice_folders[0].title, "folder-11");
|
||||
assert!(alice_folders.iter().all(|record| record.user_id == "alice"));
|
||||
assert!(!alice_folders.iter().any(|record| record.title == "Design"));
|
||||
|
||||
let alice_pages = store
|
||||
.list_navigation_recent("alice", Some("page"), 20)
|
||||
.expect("list alice pages");
|
||||
assert_eq!(alice_pages.len(), 1);
|
||||
assert_eq!(
|
||||
alice_pages[0].document_id.as_deref(),
|
||||
Some("local-md:design~2FPlan.md")
|
||||
);
|
||||
|
||||
let bob_recent = store
|
||||
.list_navigation_recent("bob", None, 20)
|
||||
.expect("list bob recent");
|
||||
assert!(bob_recent.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_policy_upsert_returns_workspace_policy_before_user_policy() {
|
||||
let store = store();
|
||||
|
||||
@@ -5,11 +5,11 @@ use crate::model::{
|
||||
AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput,
|
||||
AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput,
|
||||
CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink,
|
||||
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, OutboxEventInput,
|
||||
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
|
||||
SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput,
|
||||
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord,
|
||||
UserUiPreferenceRecord, WorkspaceRecord,
|
||||
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord,
|
||||
OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord,
|
||||
SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
|
||||
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
|
||||
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
|
||||
};
|
||||
|
||||
pub trait ControlPlaneStore: Send + Sync {
|
||||
@@ -136,6 +136,18 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||
source_kind: Option<&str>,
|
||||
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError>;
|
||||
|
||||
fn upsert_navigation_recent(
|
||||
&self,
|
||||
input: UpsertNavigationRecentInput,
|
||||
) -> Result<NavigationRecentRecord, ControlPlaneError>;
|
||||
|
||||
fn list_navigation_recent(
|
||||
&self,
|
||||
user_id: &str,
|
||||
kind: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<NavigationRecentRecord>, ControlPlaneError>;
|
||||
|
||||
fn upsert_ai_policy(
|
||||
&self,
|
||||
input: UpsertAiPolicyInput,
|
||||
|
||||
Reference in New Issue
Block a user