chore (backend-rs): add comments
This commit is contained in:
parent
576a3752b9
commit
27de1ddb70
15 changed files with 166 additions and 138 deletions
200
packages/backend-rs/index.d.ts
vendored
200
packages/backend-rs/index.d.ts
vendored
|
@ -207,6 +207,106 @@ export interface Acct {
|
|||
}
|
||||
export function stringToAcct(acct: string): Acct
|
||||
export function acctToString(acct: Acct): string
|
||||
export function fetchNodeinfo(host: string): Promise<Nodeinfo>
|
||||
export function nodeinfo_2_1(): Promise<any>
|
||||
export function nodeinfo_2_0(): Promise<any>
|
||||
/** NodeInfo schema version 2.0. <https://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.0> */
|
||||
export interface Nodeinfo {
|
||||
/** The schema version, must be 2.0. */
|
||||
version: string
|
||||
/** Metadata about server software in use. */
|
||||
software: Software20
|
||||
/** The protocols supported on this server. */
|
||||
protocols: Array<Protocol>
|
||||
/** The third party sites this server can connect to via their application API. */
|
||||
services: Services
|
||||
/** Whether this server allows open self-registration. */
|
||||
openRegistrations: boolean
|
||||
/** Usage statistics for this server. */
|
||||
usage: Usage
|
||||
/** Free form key value pairs for software specific values. Clients should not rely on any specific key present. */
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
/** Metadata about server software in use (version 2.0). */
|
||||
export interface Software20 {
|
||||
/** The canonical name of this server software. */
|
||||
name: string
|
||||
/** The version of this server software. */
|
||||
version: string
|
||||
}
|
||||
export enum Protocol {
|
||||
Activitypub = 'activitypub',
|
||||
Buddycloud = 'buddycloud',
|
||||
Dfrn = 'dfrn',
|
||||
Diaspora = 'diaspora',
|
||||
Libertree = 'libertree',
|
||||
Ostatus = 'ostatus',
|
||||
Pumpio = 'pumpio',
|
||||
Tent = 'tent',
|
||||
Xmpp = 'xmpp',
|
||||
Zot = 'zot'
|
||||
}
|
||||
/** The third party sites this server can connect to via their application API. */
|
||||
export interface Services {
|
||||
/** The third party sites this server can retrieve messages from for combined display with regular traffic. */
|
||||
inbound: Array<Inbound>
|
||||
/** The third party sites this server can publish messages to on the behalf of a user. */
|
||||
outbound: Array<Outbound>
|
||||
}
|
||||
/** The third party sites this server can retrieve messages from for combined display with regular traffic. */
|
||||
export enum Inbound {
|
||||
Atom1 = 'atom1',
|
||||
Gnusocial = 'gnusocial',
|
||||
Imap = 'imap',
|
||||
Pnut = 'pnut',
|
||||
Pop3 = 'pop3',
|
||||
Pumpio = 'pumpio',
|
||||
Rss2 = 'rss2',
|
||||
Twitter = 'twitter'
|
||||
}
|
||||
/** The third party sites this server can publish messages to on the behalf of a user. */
|
||||
export enum Outbound {
|
||||
Atom1 = 'atom1',
|
||||
Blogger = 'blogger',
|
||||
Buddycloud = 'buddycloud',
|
||||
Diaspora = 'diaspora',
|
||||
Dreamwidth = 'dreamwidth',
|
||||
Drupal = 'drupal',
|
||||
Facebook = 'facebook',
|
||||
Friendica = 'friendica',
|
||||
Gnusocial = 'gnusocial',
|
||||
Google = 'google',
|
||||
Insanejournal = 'insanejournal',
|
||||
Libertree = 'libertree',
|
||||
Linkedin = 'linkedin',
|
||||
Livejournal = 'livejournal',
|
||||
Mediagoblin = 'mediagoblin',
|
||||
Myspace = 'myspace',
|
||||
Pinterest = 'pinterest',
|
||||
Pnut = 'pnut',
|
||||
Posterous = 'posterous',
|
||||
Pumpio = 'pumpio',
|
||||
Redmatrix = 'redmatrix',
|
||||
Rss2 = 'rss2',
|
||||
Smtp = 'smtp',
|
||||
Tent = 'tent',
|
||||
Tumblr = 'tumblr',
|
||||
Twitter = 'twitter',
|
||||
Wordpress = 'wordpress',
|
||||
Xmpp = 'xmpp'
|
||||
}
|
||||
/** Usage statistics for this server. */
|
||||
export interface Usage {
|
||||
users: Users
|
||||
localPosts: number | null
|
||||
localComments: number | null
|
||||
}
|
||||
/** statistics about the users of this server. */
|
||||
export interface Users {
|
||||
total: number | null
|
||||
activeHalfyear: number | null
|
||||
activeMonth: number | null
|
||||
}
|
||||
export function greet(): void
|
||||
export function initializeRustLogger(): void
|
||||
export function showServerInfo(): void
|
||||
|
@ -1179,106 +1279,6 @@ export interface Webhook {
|
|||
latestStatus: number | null
|
||||
}
|
||||
export function updateAntennasOnNewNote(note: Note, noteAuthor: Acct, noteMutedUsers: Array<string>): Promise<void>
|
||||
export function fetchNodeinfo(host: string): Promise<Nodeinfo>
|
||||
export function nodeinfo_2_1(): Promise<any>
|
||||
export function nodeinfo_2_0(): Promise<any>
|
||||
/** NodeInfo schema version 2.0. <https://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.0> */
|
||||
export interface Nodeinfo {
|
||||
/** The schema version, must be 2.0. */
|
||||
version: string
|
||||
/** Metadata about server software in use. */
|
||||
software: Software20
|
||||
/** The protocols supported on this server. */
|
||||
protocols: Array<Protocol>
|
||||
/** The third party sites this server can connect to via their application API. */
|
||||
services: Services
|
||||
/** Whether this server allows open self-registration. */
|
||||
openRegistrations: boolean
|
||||
/** Usage statistics for this server. */
|
||||
usage: Usage
|
||||
/** Free form key value pairs for software specific values. Clients should not rely on any specific key present. */
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
/** Metadata about server software in use (version 2.0). */
|
||||
export interface Software20 {
|
||||
/** The canonical name of this server software. */
|
||||
name: string
|
||||
/** The version of this server software. */
|
||||
version: string
|
||||
}
|
||||
export enum Protocol {
|
||||
Activitypub = 'activitypub',
|
||||
Buddycloud = 'buddycloud',
|
||||
Dfrn = 'dfrn',
|
||||
Diaspora = 'diaspora',
|
||||
Libertree = 'libertree',
|
||||
Ostatus = 'ostatus',
|
||||
Pumpio = 'pumpio',
|
||||
Tent = 'tent',
|
||||
Xmpp = 'xmpp',
|
||||
Zot = 'zot'
|
||||
}
|
||||
/** The third party sites this server can connect to via their application API. */
|
||||
export interface Services {
|
||||
/** The third party sites this server can retrieve messages from for combined display with regular traffic. */
|
||||
inbound: Array<Inbound>
|
||||
/** The third party sites this server can publish messages to on the behalf of a user. */
|
||||
outbound: Array<Outbound>
|
||||
}
|
||||
/** The third party sites this server can retrieve messages from for combined display with regular traffic. */
|
||||
export enum Inbound {
|
||||
Atom1 = 'atom1',
|
||||
Gnusocial = 'gnusocial',
|
||||
Imap = 'imap',
|
||||
Pnut = 'pnut',
|
||||
Pop3 = 'pop3',
|
||||
Pumpio = 'pumpio',
|
||||
Rss2 = 'rss2',
|
||||
Twitter = 'twitter'
|
||||
}
|
||||
/** The third party sites this server can publish messages to on the behalf of a user. */
|
||||
export enum Outbound {
|
||||
Atom1 = 'atom1',
|
||||
Blogger = 'blogger',
|
||||
Buddycloud = 'buddycloud',
|
||||
Diaspora = 'diaspora',
|
||||
Dreamwidth = 'dreamwidth',
|
||||
Drupal = 'drupal',
|
||||
Facebook = 'facebook',
|
||||
Friendica = 'friendica',
|
||||
Gnusocial = 'gnusocial',
|
||||
Google = 'google',
|
||||
Insanejournal = 'insanejournal',
|
||||
Libertree = 'libertree',
|
||||
Linkedin = 'linkedin',
|
||||
Livejournal = 'livejournal',
|
||||
Mediagoblin = 'mediagoblin',
|
||||
Myspace = 'myspace',
|
||||
Pinterest = 'pinterest',
|
||||
Pnut = 'pnut',
|
||||
Posterous = 'posterous',
|
||||
Pumpio = 'pumpio',
|
||||
Redmatrix = 'redmatrix',
|
||||
Rss2 = 'rss2',
|
||||
Smtp = 'smtp',
|
||||
Tent = 'tent',
|
||||
Tumblr = 'tumblr',
|
||||
Twitter = 'twitter',
|
||||
Wordpress = 'wordpress',
|
||||
Xmpp = 'xmpp'
|
||||
}
|
||||
/** Usage statistics for this server. */
|
||||
export interface Usage {
|
||||
users: Users
|
||||
localPosts: number | null
|
||||
localComments: number | null
|
||||
}
|
||||
/** statistics about the users of this server. */
|
||||
export interface Users {
|
||||
total: number | null
|
||||
activeHalfyear: number | null
|
||||
activeMonth: number | null
|
||||
}
|
||||
export function watchNote(watcherId: string, noteAuthorId: string, noteId: string): Promise<void>
|
||||
export function unwatchNote(watcherId: string, noteId: string): Promise<void>
|
||||
export enum PushNotificationKind {
|
||||
|
|
|
@ -310,7 +310,7 @@ if (!nativeBinding) {
|
|||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, greet, initializeRustLogger, showServerInfo, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, isQuote, isSafeUrl, latestVersion, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, cpuInfo, cpuUsage, memoryUsage, storageUsage, AntennaSrc, DriveFileUsageHint, MutedNoteReason, NoteVisibility, NotificationType, PageVisibility, PollNoteVisibility, RelayStatus, UserEmojiModPerm, UserProfileFfvisibility, UserProfileMutingNotificationTypes, updateAntennasOnNewNote, fetchNodeinfo, nodeinfo_2_1, nodeinfo_2_0, Protocol, Inbound, Outbound, watchNote, unwatchNote, PushNotificationKind, sendPushNotification, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, generateSecureRandomString, generateUserToken } = nativeBinding
|
||||
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, fetchNodeinfo, nodeinfo_2_1, nodeinfo_2_0, Protocol, Inbound, Outbound, greet, initializeRustLogger, showServerInfo, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, isQuote, isSafeUrl, latestVersion, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, cpuInfo, cpuUsage, memoryUsage, storageUsage, AntennaSrc, DriveFileUsageHint, MutedNoteReason, NoteVisibility, NotificationType, PageVisibility, PollNoteVisibility, RelayStatus, UserEmojiModPerm, UserProfileFfvisibility, UserProfileMutingNotificationTypes, updateAntennasOnNewNote, watchNote, unwatchNote, PushNotificationKind, sendPushNotification, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, generateSecureRandomString, generateUserToken } = nativeBinding
|
||||
|
||||
module.exports.SECOND = SECOND
|
||||
module.exports.MINUTE = MINUTE
|
||||
|
@ -323,6 +323,12 @@ module.exports.loadEnv = loadEnv
|
|||
module.exports.loadConfig = loadConfig
|
||||
module.exports.stringToAcct = stringToAcct
|
||||
module.exports.acctToString = acctToString
|
||||
module.exports.fetchNodeinfo = fetchNodeinfo
|
||||
module.exports.nodeinfo_2_1 = nodeinfo_2_1
|
||||
module.exports.nodeinfo_2_0 = nodeinfo_2_0
|
||||
module.exports.Protocol = Protocol
|
||||
module.exports.Inbound = Inbound
|
||||
module.exports.Outbound = Outbound
|
||||
module.exports.greet = greet
|
||||
module.exports.initializeRustLogger = initializeRustLogger
|
||||
module.exports.showServerInfo = showServerInfo
|
||||
|
@ -372,12 +378,6 @@ module.exports.UserEmojiModPerm = UserEmojiModPerm
|
|||
module.exports.UserProfileFfvisibility = UserProfileFfvisibility
|
||||
module.exports.UserProfileMutingNotificationTypes = UserProfileMutingNotificationTypes
|
||||
module.exports.updateAntennasOnNewNote = updateAntennasOnNewNote
|
||||
module.exports.fetchNodeinfo = fetchNodeinfo
|
||||
module.exports.nodeinfo_2_1 = nodeinfo_2_1
|
||||
module.exports.nodeinfo_2_0 = nodeinfo_2_0
|
||||
module.exports.Protocol = Protocol
|
||||
module.exports.Inbound = Inbound
|
||||
module.exports.Outbound = Outbound
|
||||
module.exports.watchNote = watchNote
|
||||
module.exports.unwatchNote = unwatchNote
|
||||
module.exports.PushNotificationKind = PushNotificationKind
|
||||
|
|
|
@ -46,13 +46,13 @@ fn wildcard(category: Category) -> String {
|
|||
///
|
||||
/// This overwrites the exsisting cache with the same key.
|
||||
///
|
||||
/// ## Arguments
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - key (will be prefixed automatically)
|
||||
/// * `value` - (de)serializable value
|
||||
/// * `expire_seconds` - TTL
|
||||
/// - `key` : key (prefixed automatically)
|
||||
/// - `value` : (de)serializable value
|
||||
/// - `expire_seconds` : TTL
|
||||
///
|
||||
/// ## Example
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// # use backend_rs::database::cache;
|
||||
|
@ -89,11 +89,11 @@ pub async fn set<V: for<'a> Deserialize<'a> + Serialize>(
|
|||
/// If the Redis connection is fine, this returns `Ok(data)` where `data`
|
||||
/// is the cached value. Returns `Ok(None)` if there is no value corresponding to `key`.
|
||||
///
|
||||
/// ## Arguments
|
||||
/// # Argument
|
||||
///
|
||||
/// * `key` - key (will be prefixed automatically)
|
||||
/// - `key` : key (will be prefixed automatically)
|
||||
///
|
||||
/// ## Example
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// # use backend_rs::database::cache;
|
||||
|
@ -126,9 +126,9 @@ pub async fn get<V: for<'a> Deserialize<'a> + Serialize>(key: &str) -> Result<Op
|
|||
/// If the Redis connection is fine, this returns `Ok(())`
|
||||
/// regardless of whether the cache exists.
|
||||
///
|
||||
/// ## Arguments
|
||||
/// # Argument
|
||||
///
|
||||
/// * `key` - key (will be prefixed automatically)
|
||||
/// - `key` : key (prefixed automatically)
|
||||
///
|
||||
/// ## Example
|
||||
///
|
||||
|
@ -159,12 +159,12 @@ pub async fn delete(key: &str) -> Result<(), Error> {
|
|||
/// The usage is the same as [set], except that you need to
|
||||
/// use [get_one] and [delete_one] to get/delete the cache.
|
||||
///
|
||||
/// ## Arguments
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `category` - one of [Category]
|
||||
/// * `key` - key (will be prefixed automatically)
|
||||
/// * `value` - (de)serializable value
|
||||
/// * `expire_seconds` - TTL
|
||||
/// - `category` : one of [Category]
|
||||
/// - `key` : key (prefixed automatically)
|
||||
/// - `value` : (de)serializable value
|
||||
/// - `expire_seconds` : TTL
|
||||
pub async fn set_one<V: for<'a> Deserialize<'a> + Serialize>(
|
||||
category: Category,
|
||||
key: &str,
|
||||
|
@ -178,10 +178,10 @@ pub async fn set_one<V: for<'a> Deserialize<'a> + Serialize>(
|
|||
///
|
||||
/// The usage is basically the same as [get].
|
||||
///
|
||||
/// ## Arguments
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `category` - one of [Category]
|
||||
/// * `key` - key (will be prefixed automatically)
|
||||
/// - `category` : one of [Category]
|
||||
/// - `key` : key (prefixed automatically)
|
||||
pub async fn get_one<V: for<'a> Deserialize<'a> + Serialize>(
|
||||
category: Category,
|
||||
key: &str,
|
||||
|
@ -193,19 +193,19 @@ pub async fn get_one<V: for<'a> Deserialize<'a> + Serialize>(
|
|||
///
|
||||
/// The usage is basically the same as [delete].
|
||||
///
|
||||
/// ## Arguments
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `category` - one of [Category]
|
||||
/// * `key` - key (will be prefixed automatically)
|
||||
/// - `category` : one of [Category]
|
||||
/// - `key` : key (prefixed automatically)
|
||||
pub async fn delete_one(category: Category, key: &str) -> Result<(), Error> {
|
||||
delete(&categorize(category, key)).await
|
||||
}
|
||||
|
||||
/// Deletes all Redis caches under a `category`.
|
||||
///
|
||||
/// ## Arguments
|
||||
/// # Argument
|
||||
///
|
||||
/// * `category` - one of [Category]
|
||||
/// - `category` : one of [Category]
|
||||
pub async fn delete_all(category: Category) -> Result<(), Error> {
|
||||
let mut redis = redis_conn().await?;
|
||||
let keys: Vec<Vec<u8>> = redis.keys(wildcard(category)).await?;
|
||||
|
|
|
@ -3,6 +3,9 @@ pub use redis::key as redis_key;
|
|||
pub use redis::redis_conn;
|
||||
pub use redis::RedisConnError;
|
||||
|
||||
/// Utilities for using Redis cache
|
||||
pub mod cache;
|
||||
/// PostgreSQL interface
|
||||
pub mod postgresql;
|
||||
/// Redis interface
|
||||
pub mod redis;
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
pub mod acct;
|
||||
pub mod nodeinfo;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use crate::service::nodeinfo::schema::*;
|
||||
use crate::federation::nodeinfo::schema::*;
|
||||
use crate::util::http_client;
|
||||
use isahc::AsyncReadResponseExt;
|
||||
use serde::{Deserialize, Serialize};
|
|
@ -1,8 +1,8 @@
|
|||
use crate::config::CONFIG;
|
||||
use crate::database::{cache, db_conn};
|
||||
use crate::federation::nodeinfo::schema::*;
|
||||
use crate::misc::meta::fetch_meta;
|
||||
use crate::model::entity::{note, user};
|
||||
use crate::service::nodeinfo::schema::*;
|
||||
use sea_orm::{ColumnTrait, DbErr, EntityTrait, PaginatorTrait, QueryFilter};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
|
@ -1,10 +1,18 @@
|
|||
pub use macro_rs::{export, ts_export};
|
||||
use macro_rs::{export, ts_export};
|
||||
|
||||
/// Server configurations and environment variables
|
||||
pub mod config;
|
||||
/// Interfaces for accessing PostgreSQL and Redis
|
||||
pub mod database;
|
||||
/// Services used to federate with other servers
|
||||
pub mod federation;
|
||||
/// Initializers
|
||||
pub mod init;
|
||||
/// Miscellaneous utilities
|
||||
pub mod misc;
|
||||
/// Database structure, auto-generated by [sea_orm]
|
||||
pub mod model;
|
||||
/// Services provided for local users
|
||||
pub mod service;
|
||||
/// Basic utilities such as ID generator and HTTP client
|
||||
pub mod util;
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
pub mod antenna;
|
||||
pub mod nodeinfo;
|
||||
pub mod note;
|
||||
pub mod push_notification;
|
||||
pub mod stream;
|
||||
|
|
|
@ -4,7 +4,7 @@ use crate::misc::meta::fetch_meta;
|
|||
use crate::model::entity::sw_subscription;
|
||||
use crate::util::http_client;
|
||||
use once_cell::sync::OnceCell;
|
||||
use sea_orm::{prelude::*, DbErr};
|
||||
use sea_orm::prelude::*;
|
||||
use web_push::{
|
||||
ContentEncoding, IsahcWebPushClient, SubscriptionInfo, SubscriptionKeys, VapidSignatureBuilder,
|
||||
WebPushClient, WebPushError, WebPushMessageBuilder,
|
||||
|
@ -102,7 +102,7 @@ fn compact_content(
|
|||
}
|
||||
|
||||
async fn handle_web_push_failure(
|
||||
db: &DatabaseConnection,
|
||||
db: &DbConn,
|
||||
err: WebPushError,
|
||||
subscription_id: &str,
|
||||
error_message: &str,
|
||||
|
|
|
@ -13,6 +13,22 @@ pub enum Error {
|
|||
|
||||
static CLIENT: OnceCell<HttpClient> = OnceCell::new();
|
||||
|
||||
/// Returns an [HttpClient] that takes the proxy configuration into account.
|
||||
///
|
||||
/// # Example
|
||||
/// ```no_run
|
||||
/// # use backend_rs::util::http_client::client;
|
||||
/// use isahc::ReadResponseExt;
|
||||
///
|
||||
/// # fn f() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut response = client()?.get("https://example.com/")?;
|
||||
///
|
||||
/// if response.status().is_success() {
|
||||
/// println!("{}", response.text()?);
|
||||
/// }
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn client() -> Result<HttpClient, Error> {
|
||||
CLIENT
|
||||
.get_or_try_init(|| {
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
//! ID generation utility based on [cuid2]
|
||||
|
||||
use crate::config::CONFIG;
|
||||
use basen::BASE36;
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
/// Shared [isahc] HTTP client
|
||||
pub mod http_client;
|
||||
/// ID generation utility based on [cuid2]
|
||||
pub mod id;
|
||||
/// Secure random string generator
|
||||
pub mod random;
|
||||
|
|
Loading…
Reference in a new issue