Merge branch 'admin' into 'develop'
Let users be admins See merge request soapbox-pub/ditto!33
This commit is contained in:
commit
2f645920f5
|
@ -72,14 +72,15 @@ const Conf = {
|
||||||
},
|
},
|
||||||
/** Merges the path with the localDomain. */
|
/** Merges the path with the localDomain. */
|
||||||
local(path: string): string {
|
local(path: string): string {
|
||||||
if (path.startsWith('/')) {
|
const url = new URL(path.startsWith('/') ? path : new URL(path).pathname, Conf.localDomain);
|
||||||
// Path is a path.
|
|
||||||
return new URL(path, Conf.localDomain).toString();
|
if (!path.startsWith('/')) {
|
||||||
} else {
|
// Copy query parameters from the original URL to the new URL
|
||||||
// Path is possibly a full URL. Replace the domain.
|
const originalUrl = new URL(path);
|
||||||
const { pathname } = new URL(path);
|
url.search = originalUrl.search;
|
||||||
return new URL(pathname, Conf.localDomain).toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,11 @@ import { AppController } from '@/app.ts';
|
||||||
import * as eventsDB from '@/db/events.ts';
|
import * as eventsDB from '@/db/events.ts';
|
||||||
import { type Event, type Filter, nip19, z } from '@/deps.ts';
|
import { type Event, type Filter, nip19, z } from '@/deps.ts';
|
||||||
import * as mixer from '@/mixer.ts';
|
import * as mixer from '@/mixer.ts';
|
||||||
import { lookupNip05Cached } from '@/nip05.ts';
|
|
||||||
import { booleanParamSchema } from '@/schema.ts';
|
import { booleanParamSchema } from '@/schema.ts';
|
||||||
import { nostrIdSchema } from '@/schemas/nostr.ts';
|
import { nostrIdSchema } from '@/schemas/nostr.ts';
|
||||||
import { toAccount, toStatus } from '@/transformers/nostr-to-mastoapi.ts';
|
import { toAccount, toStatus } from '@/transformers/nostr-to-mastoapi.ts';
|
||||||
import { dedupeEvents, Time } from '@/utils.ts';
|
import { dedupeEvents, Time } from '@/utils.ts';
|
||||||
|
import { lookupNip05Cached } from '@/utils/nip05.ts';
|
||||||
|
|
||||||
/** Matches NIP-05 names with or without an @ in front. */
|
/** Matches NIP-05 names with or without an @ in front. */
|
||||||
const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/;
|
const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/;
|
||||||
|
|
|
@ -39,6 +39,7 @@ interface UserRow {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
username: string;
|
username: string;
|
||||||
inserted_at: Date;
|
inserted_at: Date;
|
||||||
|
admin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RelayRow {
|
interface RelayRow {
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Kysely } from '@/deps.ts';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('users')
|
||||||
|
.addColumn('admin', 'boolean', (col) => col.defaultTo(false))
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('relays').execute();
|
||||||
|
}
|
|
@ -1,69 +1,48 @@
|
||||||
import { type AppMiddleware } from '@/app.ts';
|
import { type AppContext, type AppMiddleware } from '@/app.ts';
|
||||||
import { Conf } from '@/config.ts';
|
import { HTTPException } from '@/deps.ts';
|
||||||
import { type Event, HTTPException } from '@/deps.ts';
|
import { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts } from '@/utils/nip98.ts';
|
||||||
import { decode64Schema, jsonSchema } from '@/schema.ts';
|
import { localRequest } from '@/utils/web.ts';
|
||||||
import { signedEventSchema } from '@/schemas/nostr.ts';
|
import { signNostrConnect } from '@/sign.ts';
|
||||||
import { eventAge, findTag, sha256, Time } from '@/utils.ts';
|
import { findUser } from '@/db/users.ts';
|
||||||
|
|
||||||
const decodeEventSchema = decode64Schema.pipe(jsonSchema).pipe(signedEventSchema);
|
|
||||||
|
|
||||||
interface Auth98Opts {
|
|
||||||
timeout?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-98 auth.
|
* NIP-98 auth.
|
||||||
* https://github.com/nostr-protocol/nips/blob/master/98.md
|
* https://github.com/nostr-protocol/nips/blob/master/98.md
|
||||||
*/
|
*/
|
||||||
function auth98(opts: Auth98Opts = {}): AppMiddleware {
|
function auth98(opts: ParseAuthRequestOpts = {}): AppMiddleware {
|
||||||
return async (c, next) => {
|
return async (c, next) => {
|
||||||
const authHeader = c.req.headers.get('authorization');
|
const req = localRequest(c);
|
||||||
const base64 = authHeader?.match(/^Nostr (.+)$/)?.[1];
|
const result = await parseAuthRequest(req, opts);
|
||||||
const { timeout = Time.minutes(1) } = opts;
|
|
||||||
|
|
||||||
const schema = decodeEventSchema
|
|
||||||
.refine((event) => event.kind === 27235)
|
|
||||||
.refine((event) => eventAge(event) < timeout)
|
|
||||||
.refine((event) => findTag(event.tags, 'method')?.[1] === c.req.method)
|
|
||||||
.refine((event) => {
|
|
||||||
const url = findTag(event.tags, 'u')?.[1];
|
|
||||||
try {
|
|
||||||
return url === Conf.local(c.req.url);
|
|
||||||
} catch (_e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.refine(async (event) => {
|
|
||||||
const body = await c.req.raw.clone().text();
|
|
||||||
if (!body) return true;
|
|
||||||
const hash = findTag(event.tags, 'payload')?.[1];
|
|
||||||
return hash === await sha256(body);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await schema.safeParseAsync(base64);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
c.set('pubkey', result.data.pubkey);
|
c.set('pubkey', result.data.pubkey);
|
||||||
c.set('proof', result.data as Event<27235>);
|
c.set('proof', result.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const requireProof: AppMiddleware = async (c, next) => {
|
/** Require the user to prove they're an admin before invoking the controller. */
|
||||||
const pubkey = c.get('pubkey');
|
const requireAdmin: AppMiddleware = async (c, next) => {
|
||||||
const proof = c.get('proof');
|
const header = c.req.headers.get('x-nostr-sign');
|
||||||
|
const proof = c.get('proof') || header ? await obtainProof(c) : undefined;
|
||||||
|
const user = proof ? await findUser({ pubkey: proof.pubkey }) : undefined;
|
||||||
|
|
||||||
// if (!proof && hasWebsocket(c.req)) {
|
if (proof && user?.admin) {
|
||||||
// // TODO: attempt to sign nip98 event through websocket
|
c.set('pubkey', proof.pubkey);
|
||||||
// }
|
c.set('proof', proof);
|
||||||
|
await next();
|
||||||
if (!pubkey || !proof || proof.pubkey !== pubkey) {
|
} else {
|
||||||
throw new HTTPException(401);
|
throw new HTTPException(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export { auth98, requireProof };
|
/** Get the proof over Nostr Connect. */
|
||||||
|
async function obtainProof(c: AppContext) {
|
||||||
|
const req = localRequest(c);
|
||||||
|
const event = await buildAuthEventTemplate(req);
|
||||||
|
return signNostrConnect(event, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { auth98, requireAdmin };
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { verifySignature, z } from '@/deps.ts';
|
import { getEventHash, verifySignature, z } from '@/deps.ts';
|
||||||
|
|
||||||
import { jsonSchema, safeUrlSchema } from '../schema.ts';
|
import { jsonSchema, safeUrlSchema } from '../schema.ts';
|
||||||
|
|
||||||
|
@ -19,7 +19,9 @@ const eventSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Nostr event schema that also verifies the event's signature. */
|
/** Nostr event schema that also verifies the event's signature. */
|
||||||
const signedEventSchema = eventSchema.refine(verifySignature);
|
const signedEventSchema = eventSchema
|
||||||
|
.refine((event) => event.id === getEventHash(event), 'Event ID does not match hash')
|
||||||
|
.refine(verifySignature, 'Event signature is invalid');
|
||||||
|
|
||||||
/** Nostr relay filter schema. */
|
/** Nostr relay filter schema. */
|
||||||
const filterSchema = z.object({
|
const filterSchema = z.object({
|
||||||
|
|
51
src/sign.ts
51
src/sign.ts
|
@ -5,14 +5,14 @@ import { type Event, type EventTemplate, finishEvent, HTTPException } from '@/de
|
||||||
import { connectResponseSchema } from '@/schemas/nostr.ts';
|
import { connectResponseSchema } from '@/schemas/nostr.ts';
|
||||||
import { jsonSchema } from '@/schema.ts';
|
import { jsonSchema } from '@/schema.ts';
|
||||||
import { Sub } from '@/subs.ts';
|
import { Sub } from '@/subs.ts';
|
||||||
import { Time } from '@/utils.ts';
|
import { eventMatchesTemplate, Time } from '@/utils.ts';
|
||||||
import { createAdminEvent } from '@/utils/web.ts';
|
import { createAdminEvent } from '@/utils/web.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign Nostr event using the app context.
|
* Sign Nostr event using the app context.
|
||||||
*
|
*
|
||||||
* - If a secret key is provided, it will be used to sign the event.
|
* - If a secret key is provided, it will be used to sign the event.
|
||||||
* - If `X-Nostr-Sign` is passed, it will use a NIP-46 to sign the event.
|
* - If `X-Nostr-Sign` is passed, it will use NIP-46 to sign the event.
|
||||||
*/
|
*/
|
||||||
async function signEvent<K extends number = number>(event: EventTemplate<K>, c: AppContext): Promise<Event<K>> {
|
async function signEvent<K extends number = number>(event: EventTemplate<K>, c: AppContext): Promise<Event<K>> {
|
||||||
const seckey = c.get('seckey');
|
const seckey = c.get('seckey');
|
||||||
|
@ -54,13 +54,14 @@ async function signNostrConnect<K extends number = number>(event: EventTemplate<
|
||||||
tags: [['p', pubkey]],
|
tags: [['p', pubkey]],
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
return awaitSignedEvent<K>(pubkey, messageId, c);
|
return awaitSignedEvent<K>(pubkey, messageId, event, c);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wait for signed event to be sent through Nostr relay. */
|
/** Wait for signed event to be sent through Nostr relay. */
|
||||||
function awaitSignedEvent<K extends number = number>(
|
async function awaitSignedEvent<K extends number = number>(
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
|
template: EventTemplate<K>,
|
||||||
c: AppContext,
|
c: AppContext,
|
||||||
): Promise<Event<K>> {
|
): Promise<Event<K>> {
|
||||||
const sub = Sub.sub(messageId, '1', [{ kinds: [24133], authors: [pubkey], '#p': [Conf.pubkey] }]);
|
const sub = Sub.sub(messageId, '1', [{ kinds: [24133], authors: [pubkey], '#p': [Conf.pubkey] }]);
|
||||||
|
@ -69,30 +70,26 @@ function awaitSignedEvent<K extends number = number>(
|
||||||
Sub.close(messageId);
|
Sub.close(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
const timeout = setTimeout(close, Time.minutes(1));
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
|
for await (const event of sub) {
|
||||||
|
const decrypted = await decryptAdmin(event.pubkey, event.content);
|
||||||
|
|
||||||
|
const result = jsonSchema
|
||||||
|
.pipe(connectResponseSchema)
|
||||||
|
.refine((msg) => msg.id === messageId, 'Message ID mismatch')
|
||||||
|
.refine((msg) => eventMatchesTemplate(msg.result, template), 'Event template mismatch')
|
||||||
|
.safeParse(decrypted);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
close();
|
close();
|
||||||
reject(
|
clearTimeout(timeout);
|
||||||
new HTTPException(408, {
|
return result.data.result as Event<K>;
|
||||||
res: c.json({ id: 'ditto.timeout', error: 'Signing timeout' }),
|
}
|
||||||
}),
|
}
|
||||||
);
|
|
||||||
}, Time.minutes(1));
|
|
||||||
|
|
||||||
(async () => {
|
throw new HTTPException(408, {
|
||||||
for await (const event of sub) {
|
res: c.json({ id: 'ditto.timeout', error: 'Signing timeout' }),
|
||||||
if (event.kind === 24133) {
|
|
||||||
const decrypted = await decryptAdmin(event.pubkey, event.content);
|
|
||||||
const msg = jsonSchema.pipe(connectResponseSchema).parse(decrypted);
|
|
||||||
|
|
||||||
if (msg.id === messageId) {
|
|
||||||
close();
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(msg.result as Event<K>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,4 +99,4 @@ async function signAdminEvent<K extends number = number>(event: EventTemplate<K>
|
||||||
return finishEvent(event, Conf.seckey);
|
return finishEvent(event, Conf.seckey);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { signAdminEvent, signEvent };
|
export { signAdminEvent, signEvent, signNostrConnect };
|
||||||
|
|
|
@ -3,12 +3,12 @@ import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ad
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import * as eventsDB from '@/db/events.ts';
|
import * as eventsDB from '@/db/events.ts';
|
||||||
import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, z } from '@/deps.ts';
|
import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, z } from '@/deps.ts';
|
||||||
import { verifyNip05Cached } from '@/nip05.ts';
|
|
||||||
import { getMediaLinks, type MediaLink, parseNoteContent } from '@/note.ts';
|
import { getMediaLinks, type MediaLink, parseNoteContent } from '@/note.ts';
|
||||||
import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.ts';
|
import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.ts';
|
||||||
import { emojiTagSchema, filteredArray } from '@/schema.ts';
|
import { emojiTagSchema, filteredArray } from '@/schema.ts';
|
||||||
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
||||||
import { isFollowing, type Nip05, nostrDate, parseNip05, Time } from '@/utils.ts';
|
import { isFollowing, type Nip05, nostrDate, parseNip05, Time } from '@/utils.ts';
|
||||||
|
import { verifyNip05Cached } from '@/utils/nip05.ts';
|
||||||
|
|
||||||
const DEFAULT_AVATAR = 'https://gleasonator.com/images/avi.png';
|
const DEFAULT_AVATAR = 'https://gleasonator.com/images/avi.png';
|
||||||
const DEFAULT_BANNER = 'https://gleasonator.com/images/banner.png';
|
const DEFAULT_BANNER = 'https://gleasonator.com/images/banner.png';
|
||||||
|
|
12
src/utils.ts
12
src/utils.ts
|
@ -1,6 +1,6 @@
|
||||||
import { type Event, nip19, z } from '@/deps.ts';
|
import { type Event, type EventTemplate, getEventHash, nip19, z } from '@/deps.ts';
|
||||||
import { lookupNip05Cached } from '@/nip05.ts';
|
|
||||||
import { getAuthor } from '@/queries.ts';
|
import { getAuthor } from '@/queries.ts';
|
||||||
|
import { lookupNip05Cached } from '@/utils/nip05.ts';
|
||||||
|
|
||||||
/** Get the current time in Nostr format. */
|
/** Get the current time in Nostr format. */
|
||||||
const nostrNow = (): number => Math.floor(Date.now() / 1000);
|
const nostrNow = (): number => Math.floor(Date.now() / 1000);
|
||||||
|
@ -67,7 +67,7 @@ async function lookupAccount(value: string): Promise<Event<0> | undefined> {
|
||||||
|
|
||||||
/** Return the event's age in milliseconds. */
|
/** Return the event's age in milliseconds. */
|
||||||
function eventAge(event: Event): number {
|
function eventAge(event: Event): number {
|
||||||
return new Date().getTime() - nostrDate(event.created_at).getTime();
|
return Date.now() - nostrDate(event.created_at).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
function findTag(tags: string[][], name: string): string[] | undefined {
|
function findTag(tags: string[][], name: string): string[] | undefined {
|
||||||
|
@ -106,11 +106,17 @@ function dedupeEvents<K extends number>(events: Event<K>[]): Event<K>[] {
|
||||||
return [...new Map(events.map((event) => [event.id, event])).values()];
|
return [...new Map(events.map((event) => [event.id, event])).values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ensure the template and event match on their shared keys. */
|
||||||
|
function eventMatchesTemplate(event: Event, template: EventTemplate): boolean {
|
||||||
|
return getEventHash(event) === getEventHash({ pubkey: event.pubkey, ...template });
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
bech32ToPubkey,
|
bech32ToPubkey,
|
||||||
dedupeEvents,
|
dedupeEvents,
|
||||||
eventAge,
|
eventAge,
|
||||||
eventDateComparator,
|
eventDateComparator,
|
||||||
|
eventMatchesTemplate,
|
||||||
findTag,
|
findTag,
|
||||||
isFollowing,
|
isFollowing,
|
||||||
isRelay,
|
isRelay,
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { type Event, type EventTemplate } from '@/deps.ts';
|
||||||
|
import { decode64Schema, jsonSchema } from '@/schema.ts';
|
||||||
|
import { signedEventSchema } from '@/schemas/nostr.ts';
|
||||||
|
import { eventAge, findTag, nostrNow, sha256 } from '@/utils.ts';
|
||||||
|
import { Time } from '@/utils/time.ts';
|
||||||
|
|
||||||
|
/** Decode a Nostr event from a base64 encoded string. */
|
||||||
|
const decode64EventSchema = decode64Schema.pipe(jsonSchema).pipe(signedEventSchema);
|
||||||
|
|
||||||
|
interface ParseAuthRequestOpts {
|
||||||
|
/** Max event age (in ms). */
|
||||||
|
maxAge?: number;
|
||||||
|
/** Whether to validate the request body of the request with the payload of the auth event. (default: `true`) */
|
||||||
|
validatePayload?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the auth event from a Request, returning a zod SafeParse type. */
|
||||||
|
function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) {
|
||||||
|
const { maxAge = Time.minutes(1), validatePayload = true } = opts;
|
||||||
|
|
||||||
|
const header = req.headers.get('authorization');
|
||||||
|
const base64 = header?.match(/^Nostr (.+)$/)?.[1];
|
||||||
|
|
||||||
|
const schema = decode64EventSchema
|
||||||
|
.refine((event): event is Event<27235> => event.kind === 27235, 'Event must be kind 27235')
|
||||||
|
.refine((event) => eventAge(event) < maxAge, 'Event expired')
|
||||||
|
.refine((event) => tagValue(event, 'method') === req.method, 'Event method does not match HTTP request method')
|
||||||
|
.refine((event) => tagValue(event, 'u') === req.url, 'Event URL does not match request URL')
|
||||||
|
.refine(validateBody, 'Event payload does not match request body');
|
||||||
|
|
||||||
|
function validateBody(event: Event<27235>) {
|
||||||
|
if (!validatePayload) return true;
|
||||||
|
return req.clone().text()
|
||||||
|
.then(sha256)
|
||||||
|
.then((hash) => hash === tagValue(event, 'payload'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema.safeParseAsync(base64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create an auth EventTemplate from a Request. */
|
||||||
|
async function buildAuthEventTemplate(req: Request): Promise<EventTemplate<27235>> {
|
||||||
|
const { method, url } = req;
|
||||||
|
const payload = await req.clone().text().then(sha256);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 27235,
|
||||||
|
content: '',
|
||||||
|
tags: [
|
||||||
|
['method', method],
|
||||||
|
['u', url],
|
||||||
|
['payload', payload],
|
||||||
|
],
|
||||||
|
created_at: nostrNow(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the value for the first matching tag name in the event. */
|
||||||
|
function tagValue(event: Event, tagName: string): string | undefined {
|
||||||
|
return findTag(event.tags, tagName)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts };
|
|
@ -123,4 +123,20 @@ function activityJson<T, P extends string>(c: Context<any, P>, object: T) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { activityJson, createAdminEvent, createEvent, paginated, type PaginationParams, paginationSchema, parseBody };
|
/** Rewrite the URL of the request object to use the local domain. */
|
||||||
|
function localRequest(c: Context): Request {
|
||||||
|
return Object.create(c.req.raw, {
|
||||||
|
url: { value: Conf.local(c.req.url) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
activityJson,
|
||||||
|
createAdminEvent,
|
||||||
|
createEvent,
|
||||||
|
localRequest,
|
||||||
|
paginated,
|
||||||
|
type PaginationParams,
|
||||||
|
paginationSchema,
|
||||||
|
parseBody,
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue