Dwayne Harris
5 years ago
11 changed files with 624 additions and 381 deletions
-
42src/lib/collections.ts
-
17src/lib/database.ts
-
4src/lib/errors.ts
-
54src/plugins/api/authentication.ts
-
146src/plugins/api/groups.ts
-
6src/plugins/api/index.ts
-
137src/plugins/api/posts.ts
-
213src/plugins/api/profile.ts
-
339src/plugins/api/users.ts
-
43src/schemas.ts
-
4src/types/collections.ts
@ -1,21 +1,45 @@ |
|||
import { CosmosClient } from '@azure/cosmos' |
|||
import uniq from 'lodash/uniq' |
|||
|
|||
import { containerFor, IDatabaseItem } from './database' |
|||
import { IUser } from '../types/collections' |
|||
import { containerFor, createQuerySpec, IDatabaseItem } from './database' |
|||
import { IUser, IUserSubscription, IUserBlock } from '../types/collections' |
|||
|
|||
export async function getUsers(client: CosmosClient, ids: string[]): Promise<IUser[]> { |
|||
const container = await containerFor(client, 'Users') |
|||
const { resources: users } = await container.items.query<IUser>({ |
|||
query: 'SELECT u.id, u.name, u.created FROM Users u WHERE ARRAY_CONTAINS(@ids, u.id)', |
|||
parameters: [{ |
|||
name: '@ids', |
|||
value: uniq(ids), |
|||
}], |
|||
}, {}).fetchAll() |
|||
|
|||
const { resources: users } = await container.items.query<IUser>(createQuerySpec( |
|||
'SELECT u.id, u.name, u.imageUrl, u.coverImageUrl, u.group, u.created FROM Users u WHERE ARRAY_CONTAINS(@ids, u.id)', { |
|||
ids: uniq(ids), |
|||
}), {}).fetchAll() |
|||
|
|||
return users |
|||
} |
|||
|
|||
export async function getUsersFromItems<T extends IDatabaseItem>(client: CosmosClient, items: T[]): Promise<IUser[]> { |
|||
return await getUsers(client, items.map(i => i.id)) |
|||
} |
|||
|
|||
export async function getSubscriptions(client: CosmosClient, from: string, to: string): Promise<IUserSubscription[]> { |
|||
const query = createQuerySpec(`
|
|||
SELECT u.id FROM Users u WHERE |
|||
u.subscriberId = @to |
|||
u.partitionKey = @from AND |
|||
u.type = 'subscription' AND |
|||
u.pending = false |
|||
`, { from, to })
|
|||
|
|||
const { resources: subscriptions } = await containerFor(client, 'Users').items.query<IUserSubscription>(query, {}).fetchAll() |
|||
return subscriptions |
|||
} |
|||
|
|||
export async function getUserBlocks(client: CosmosClient, from: string, to: string[]): Promise<IUserBlock[]> { |
|||
const query = createQuerySpec(`
|
|||
SELECT u.id FROM Users u WHERE |
|||
u.partitionKey = @from |
|||
AND u.type = 'block' |
|||
AND ARRAY_CONTAINS(@to, u.blockedId) |
|||
`, { from, to })
|
|||
|
|||
const { resources: blocks } = await containerFor(client, 'Users').items.query<IUserBlock>(query, {}).fetchAll() |
|||
return blocks |
|||
} |
@ -1,213 +0,0 @@ |
|||
import fastify, { |
|||
Plugin, |
|||
DefaultQuery, |
|||
DefaultParams, |
|||
RouteShorthandOptions, |
|||
DefaultHeaders, |
|||
DefaultBody, |
|||
} from 'fastify' |
|||
|
|||
import { Server, IncomingMessage, ServerResponse } from 'http' |
|||
import { unauthorizedError, serverError, notFoundError, badRequestError } from '../../lib/errors' |
|||
import { containerFor, createQuerySpec } from '../../lib/database' |
|||
import { IUser, IUserSubscription, IGroupBlock } from '../../types/collections' |
|||
|
|||
interface PluginOptions { |
|||
|
|||
} |
|||
|
|||
function updateRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) { |
|||
interface Headers { |
|||
authorization: string |
|||
} |
|||
|
|||
interface Body { |
|||
name?: string |
|||
about?: string |
|||
} |
|||
|
|||
const options: RouteShorthandOptions = { |
|||
schema: { |
|||
headers: { |
|||
type: 'object', |
|||
properties: { |
|||
authorization: { type: 'string' }, |
|||
}, |
|||
}, |
|||
body: { |
|||
type: 'object', |
|||
properties: { |
|||
name: { type: 'string' }, |
|||
about: { type: 'string' }, |
|||
}, |
|||
}, |
|||
response: { |
|||
200: { |
|||
type: 'object', |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
name: { type: 'string' }, |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
server.put<DefaultQuery, DefaultParams, Headers, Body>('/api/self', options, async (request, reply) => { |
|||
if (!server.database) return serverError(reply) |
|||
if (!request.viewer) return unauthorizedError(reply) |
|||
|
|||
const container = await containerFor(server.database.client, 'Users') |
|||
const viewerItem = container.item(request.viewer.id, request.viewer.id) |
|||
const { resource: viewer } = await viewerItem.read<IUser>() |
|||
|
|||
if (!viewer) return serverError(reply) |
|||
|
|||
if (request.body.name) { |
|||
const name = request.body.name.trim() |
|||
if (name !== '') { |
|||
viewer.name = name |
|||
} |
|||
} |
|||
|
|||
if (request.body.about) { |
|||
const about = request.body.about.trim() |
|||
if (about !== '') { |
|||
viewer.about = about |
|||
} |
|||
} |
|||
|
|||
await viewerItem.replace<IUser>(viewer) |
|||
|
|||
return { |
|||
id: viewer.id, |
|||
name: viewer.name, |
|||
} |
|||
}) |
|||
} |
|||
|
|||
function subscribeRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) { |
|||
interface Params { |
|||
id: string |
|||
} |
|||
|
|||
const options: RouteShorthandOptions = { |
|||
schema: { |
|||
params: { |
|||
type: 'object', |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
}, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/api/user/:id/subscribe', options, async (request, reply) => { |
|||
if (!server.database) return serverError(reply) |
|||
if (!request.viewer) return unauthorizedError(reply) |
|||
|
|||
const userContainer = await containerFor(server.database.client, 'Users') |
|||
const groupContainer = await containerFor(server.database.client, 'Groups') |
|||
|
|||
if (request.viewer.id === request.params.id) return badRequestError(reply, 'Invalid operation') |
|||
|
|||
const { resource: user } = await userContainer.item(request.params.id, request.params.id).read<IUser>() |
|||
const { resource: viewer } = await userContainer.item(request.viewer.id, request.viewer.id).read<IUser>() |
|||
|
|||
if (!user) return notFoundError(reply) |
|||
if (!viewer.group) return unauthorizedError(reply) |
|||
|
|||
const subscriptionQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.subscriberId = @user AND u.partitionKey = @viewer AND u.type = 'subscription'`, { |
|||
user: user.id, |
|||
viewer: viewer.id, |
|||
}) |
|||
|
|||
const { resources: subscriptions } = await userContainer.items.query<IUserSubscription>(subscriptionQuery, {}).fetchAll() |
|||
if (subscriptions.length > 0) return badRequestError(reply, 'Already subscribed') |
|||
|
|||
let pending = false |
|||
|
|||
switch (user.privacy) { |
|||
case 'private': |
|||
return unauthorizedError(reply) |
|||
case 'group': |
|||
if (user.group !== viewer.group) return unauthorizedError(reply) |
|||
case 'approve': |
|||
pending = true |
|||
break |
|||
} |
|||
|
|||
const blockQuery = createQuerySpec(`
|
|||
SELECT g.id FROM Groups g WHERE |
|||
g.partitionKey = @viewerGroup AND |
|||
g.type = 'block' AND |
|||
g.userId = @user AND |
|||
(g.blockedId = @viewer OR g.blockedId = @viewerGroup) |
|||
`, {
|
|||
user: user.id, |
|||
viewerGroup: viewer.group.id, |
|||
}) |
|||
|
|||
const { resources: blocks } = await groupContainer.items.query<IGroupBlock>(blockQuery, {}).fetchAll() |
|||
if (blocks.length > 0) return badRequestError(reply, 'Invalid operation') |
|||
|
|||
await userContainer.items.create<IUserSubscription>({ |
|||
subscriberId: user.id, |
|||
partitionKey: request.viewer.id, |
|||
type: 'subscription', |
|||
pending, |
|||
created: Date.now(), |
|||
}) |
|||
|
|||
reply.code(204) |
|||
}) |
|||
} |
|||
|
|||
function unsubscribeRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) { |
|||
interface Params { |
|||
id: string |
|||
} |
|||
|
|||
const options: RouteShorthandOptions = { |
|||
schema: { |
|||
params: { |
|||
type: 'object', |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
}, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/api/user/:id/unsubscribe', options, async (request, reply) => { |
|||
if (!server.database) return serverError(reply) |
|||
if (!request.viewer) return unauthorizedError(reply) |
|||
|
|||
const userContainer = await containerFor(server.database.client, 'Users') |
|||
|
|||
const { resource: user } = await userContainer.item(request.params.id, request.params.id).read<IUser>() |
|||
const { resource: viewer } = await userContainer.item(request.viewer.id, request.viewer.id).read<IUser>() |
|||
|
|||
if (!user) return notFoundError(reply) |
|||
|
|||
const subscriptionQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.subscriberId = @user AND u.partitionKey = @viewer AND u.type = 'subscription'`, { |
|||
user: user.id, |
|||
viewer: viewer.id, |
|||
}) |
|||
|
|||
const { resources: subscriptions } = await userContainer.items.query<IUserSubscription>(subscriptionQuery, {}).fetchAll() |
|||
for (const subscription of subscriptions) { |
|||
await userContainer.item(subscription.id!, viewer.id).delete() |
|||
} |
|||
|
|||
reply.code(204) |
|||
}) |
|||
} |
|||
|
|||
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => { |
|||
updateRoute(server) |
|||
subscribeRoute(server) |
|||
unsubscribeRoute(server) |
|||
} |
|||
|
|||
export default plugin |
@ -0,0 +1,339 @@ |
|||
import { |
|||
FastifyInstance, |
|||
Plugin, |
|||
DefaultQuery, |
|||
DefaultParams, |
|||
RouteShorthandOptions, |
|||
DefaultHeaders, |
|||
DefaultBody, |
|||
} from 'fastify' |
|||
|
|||
import { Server, IncomingMessage, ServerResponse } from 'http' |
|||
|
|||
import { userSchema } from '../../schemas' |
|||
import { unauthorizedError, serverError, notFoundError, badRequestError } from '../../lib/errors' |
|||
import { containerFor, createQuerySpec, queryItems, getItem } from '../../lib/database' |
|||
import { IUser, IUserSubscription, IUserBlock, IGroupBlock } from '../../types/collections' |
|||
|
|||
interface PluginOptions {} |
|||
|
|||
function updateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { |
|||
interface Headers { |
|||
authorization: string |
|||
} |
|||
|
|||
interface Body { |
|||
name?: string |
|||
about?: string |
|||
} |
|||
|
|||
const options: RouteShorthandOptions = { |
|||
schema: { |
|||
headers: { |
|||
type: 'object', |
|||
properties: { |
|||
authorization: { type: 'string' }, |
|||
}, |
|||
}, |
|||
body: { |
|||
type: 'object', |
|||
properties: { |
|||
name: { type: 'string' }, |
|||
about: { type: 'string' }, |
|||
}, |
|||
}, |
|||
response: { |
|||
200: userSchema, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
server.put<DefaultQuery, DefaultParams, Headers, Body>('/api/self', options, async (request, reply) => { |
|||
if (!server.database) return serverError(reply) |
|||
if (!request.viewer) return unauthorizedError(reply) |
|||
|
|||
const viewerItem = containerFor(server.database.client, 'Users').item(request.viewer.id, request.viewer.id) |
|||
const { resource: viewer } = await viewerItem.read<IUser>() |
|||
|
|||
if (!viewer) return serverError(reply) |
|||
|
|||
if (request.body.name) { |
|||
const name = request.body.name.trim() |
|||
if (name !== '') { |
|||
viewer.name = name |
|||
} |
|||
} |
|||
|
|||
if (request.body.about) { |
|||
const about = request.body.about.trim() |
|||
if (about !== '') { |
|||
viewer.about = about |
|||
} |
|||
} |
|||
|
|||
await viewerItem.replace<IUser>(viewer) |
|||
|
|||
return viewer |
|||
}) |
|||
} |
|||
|
|||
function getRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { |
|||
interface Params { |
|||
id: string |
|||
} |
|||
|
|||
const options: RouteShorthandOptions = { |
|||
schema: { |
|||
params: { |
|||
type: 'object', |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
}, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/api/user/:id', options, async (request, reply) => { |
|||
|
|||
}) |
|||
} |
|||
|
|||
function subscribeRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { |
|||
interface Params { |
|||
id: string |
|||
} |
|||
|
|||
const options: RouteShorthandOptions = { |
|||
schema: { |
|||
params: { |
|||
type: 'object', |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
}, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/api/user/:id/subscribe', options, async (request, reply) => { |
|||
if (!server.database) return serverError(reply) |
|||
if (!request.viewer) return unauthorizedError(reply) |
|||
|
|||
if (request.viewer.id === request.params.id) return badRequestError(reply) |
|||
|
|||
const userContainer = containerFor(server.database.client, 'Users') |
|||
const user = await getItem<IUser>(userContainer, request.params.id) |
|||
const viewer = await getItem<IUser>(userContainer, request.viewer.id) |
|||
|
|||
if (!user) return notFoundError(reply) |
|||
if (!viewer) return serverError(reply) |
|||
if (!viewer.group) return unauthorizedError(reply) |
|||
|
|||
const subscriptionQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.subscriberId = @user AND u.partitionKey = @viewer AND u.type = 'subscription'`, { |
|||
user: user.id, |
|||
viewer: viewer.id, |
|||
}) |
|||
|
|||
const subscriptions = await queryItems<IUserSubscription>(userContainer, subscriptionQuery) |
|||
if (subscriptions.length > 0) return badRequestError(reply, 'Already subscribed') |
|||
|
|||
let pending = false |
|||
|
|||
switch (user.privacy) { |
|||
case 'private': |
|||
return unauthorizedError(reply) |
|||
case 'group': |
|||
if (user.group !== viewer.group) return unauthorizedError(reply) |
|||
case 'approve': |
|||
pending = true |
|||
break |
|||
} |
|||
|
|||
const blockQuery = createQuerySpec(`
|
|||
SELECT g.id FROM Groups g WHERE |
|||
g.partitionKey = @viewerGroup AND |
|||
g.type = 'block' AND |
|||
g.userId = @user AND |
|||
(g.blockedId = @viewer OR g.blockedId = @viewerGroup) |
|||
`, {
|
|||
user: user.id, |
|||
viewerGroup: viewer.group.id, |
|||
}) |
|||
|
|||
const blocks = await queryItems<IGroupBlock>(containerFor(server.database.client, 'Groups'), blockQuery) |
|||
if (blocks.length > 0) return badRequestError(reply, 'Invalid operation') |
|||
|
|||
await userContainer.items.create<IUserSubscription>({ |
|||
subscriberId: user.id, |
|||
partitionKey: request.viewer.id, |
|||
type: 'subscription', |
|||
pending, |
|||
created: Date.now(), |
|||
}) |
|||
|
|||
reply.code(204) |
|||
}) |
|||
} |
|||
|
|||
function unsubscribeRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { |
|||
interface Params { |
|||
id: string |
|||
} |
|||
|
|||
const options: RouteShorthandOptions = { |
|||
schema: { |
|||
params: { |
|||
type: 'object', |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
}, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/api/user/:id/unsubscribe', options, async (request, reply) => { |
|||
if (!server.database) return serverError(reply) |
|||
if (!request.viewer) return unauthorizedError(reply) |
|||
|
|||
const userContainer = containerFor(server.database.client, 'Users') |
|||
const user = await getItem<IUser>(userContainer, request.params.id) |
|||
const viewer = await getItem<IUser>(userContainer, request.viewer.id) |
|||
|
|||
if (!user) return notFoundError(reply) |
|||
if (!viewer) return serverError(reply) |
|||
|
|||
const subscriptionQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.subscriberId = @user AND u.partitionKey = @viewer AND u.type = 'subscription'`, { |
|||
user: user.id, |
|||
viewer: viewer.id, |
|||
}) |
|||
|
|||
const subscriptions = await queryItems<IUserSubscription>(userContainer, subscriptionQuery) |
|||
for (const subscription of subscriptions) { |
|||
await userContainer.item(subscription.id!, viewer.id).delete() |
|||
} |
|||
|
|||
reply.code(204) |
|||
}) |
|||
} |
|||
|
|||
function blockRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { |
|||
interface Params { |
|||
id: string |
|||
} |
|||
|
|||
interface Body { |
|||
description?: string |
|||
} |
|||
|
|||
const options: RouteShorthandOptions = { |
|||
schema: { |
|||
params: { |
|||
type: 'object', |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
}, |
|||
}, |
|||
body: { |
|||
type: 'object', |
|||
properties: { |
|||
description: { type: 'string' }, |
|||
}, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
server.post<DefaultQuery, Params, DefaultHeaders, Body>('/api/user/:id/block', options, async (request, reply) => { |
|||
if (!server.database) return serverError(reply) |
|||
if (!request.viewer) return unauthorizedError(reply) |
|||
|
|||
const userContainer = containerFor(server.database.client, 'Users') |
|||
const user = await getItem<IUser>(userContainer, request.params.id) |
|||
|
|||
if (!user) return notFoundError(reply) |
|||
if (!user.group) return badRequestError(reply) |
|||
|
|||
await userContainer.items.create<IUserBlock>({ |
|||
blockedId: user.id, |
|||
partitionKey: request.viewer.id, |
|||
type: 'block', |
|||
blockType: 'user', |
|||
description: request.body.description, |
|||
created: Date.now(), |
|||
}) |
|||
|
|||
await containerFor(server.database.client, 'Groups').items.create<IGroupBlock>({ |
|||
partitionKey: user.group.id, |
|||
type: 'block', |
|||
blockedId: user.id, |
|||
userId: request.viewer.id, |
|||
created: Date.now(), |
|||
}) |
|||
|
|||
reply.code(204) |
|||
}) |
|||
} |
|||
|
|||
function unblockRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { |
|||
interface Params { |
|||
id: string |
|||
} |
|||
|
|||
const options: RouteShorthandOptions = { |
|||
schema: { |
|||
params: { |
|||
type: 'object', |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
}, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
server.post<DefaultQuery, Params, DefaultHeaders, Body>('/api/user/:id/unblock', options, async (request, reply) => { |
|||
if (!server.database) return serverError(reply) |
|||
if (!request.viewer) return unauthorizedError(reply) |
|||
|
|||
const userContainer = containerFor(server.database.client, 'Users') |
|||
const groupContainer = containerFor(server.database.client, 'Groups') |
|||
|
|||
const user = await getItem<IUser>(userContainer, request.params.id) |
|||
|
|||
if (!user) return notFoundError(reply) |
|||
if (!user.group) return badRequestError(reply, 'Invalid operation') |
|||
|
|||
const userBlockQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.partitionKey = @partitionKey AND u.blockedId = @blocked AND u.type = 'block'`, { |
|||
partitionKey: request.viewer.id, |
|||
blocked: user.id, |
|||
}) |
|||
|
|||
const userBlocks = await queryItems<IUserBlock>(userContainer, userBlockQuery) |
|||
for (const userBlock of userBlocks) { |
|||
await userContainer.item(userBlock.id!, request.viewer.id).delete() |
|||
} |
|||
|
|||
const groupBlockQuery = createQuerySpec( |
|||
`SELECT g.id FROM Groups g WHERE g.partitionKey = @partitionKey AND u.blockedId = @blocked AND u.userId = @viewer AND u.type = 'block'`, |
|||
{ |
|||
partitionKey: user.group.id, |
|||
blocked: user.id, |
|||
viewer: request.viewer.id, |
|||
} |
|||
) |
|||
|
|||
const groupBlocks = await queryItems<IGroupBlock>(groupContainer, groupBlockQuery) |
|||
for (const groupBlock of groupBlocks) { |
|||
await groupContainer.item(groupBlock.id!, user.group.id).delete() |
|||
} |
|||
|
|||
reply.code(204) |
|||
}) |
|||
} |
|||
|
|||
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => { |
|||
updateRoute(server) |
|||
subscribeRoute(server) |
|||
unsubscribeRoute(server) |
|||
blockRoute(server) |
|||
unblockRoute(server) |
|||
} |
|||
|
|||
export default plugin |
@ -0,0 +1,43 @@ |
|||
import { JSONSchema } from 'fastify' |
|||
|
|||
export const tokenResponseSchema: JSONSchema = { |
|||
type: 'object', |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
access: { type: 'string' }, |
|||
refresh: { type: 'string' }, |
|||
}, |
|||
} |
|||
|
|||
export const postSchema: JSONSchema = { |
|||
type: 'object', |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
userId: { type: 'string' }, |
|||
text: { type: 'string' }, |
|||
cover: { type: 'string' }, |
|||
visible: { type: 'boolean' }, |
|||
created: { type: 'number' }, |
|||
}, |
|||
} |
|||
|
|||
export const userSchema: JSONSchema = { |
|||
type: 'object', |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
name: { type: 'string' }, |
|||
imageUrl: { type: 'string' }, |
|||
coverImageUrl: { type: 'string' }, |
|||
group: { |
|||
type: 'object', |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
name: { type: 'string' }, |
|||
imageUrl: { type: 'string' }, |
|||
coverImageUrl: { type: 'string' }, |
|||
}, |
|||
}, |
|||
created: { type: 'number' }, |
|||
subscription: { type: 'string' }, |
|||
}, |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue