Dwayne Harris 5 years ago
parent
commit
f66791d5ec
  1. 7
      src/lib/authentication.ts
  2. 2
      src/lib/database.ts
  3. 12
      src/lib/util.ts
  4. 21
      src/plugins/api/authentication.ts
  5. 1
      src/plugins/api/index.ts
  6. 146
      src/plugins/api/posts.ts
  7. 115
      src/types/collections.ts

7
src/lib/authentication.ts

@ -1,15 +1,16 @@
import { v1 } from 'uuid'
import { JWT } from '../lib/crypto'
import { IRefreshToken } from '../types/collections'
import { IUserToken } from '../types/collections'
export async function createAccessToken(userId: string): Promise<string> {
return await JWT.sign({ sub: userId }, { expiresIn: process.env.TOKEN_EXPIRATION })
}
export function createRefreshToken(userId: string, userAgent: string, ip: string): IRefreshToken {
export function createRefreshToken(userId: string, userAgent: string, ip: string): IUserToken {
return {
id: 'r' + v1().replace(/-/g, ''),
userId,
partitionKey: userId,
type: 'token',
userAgent,
ip,
expires: Date.now() + (1000 * 60 * 60 * 24 * 365),

2
src/lib/database.ts

@ -11,7 +11,7 @@ export async function containerFor(client: CosmosClient, containerId: string): P
return container
}
export function createQuerySpec(query: string, params: IQueryParams): SqlQuerySpec {
export function createQuerySpec(query: string, params: IQueryParams = {}): SqlQuerySpec {
return {
query,
parameters: Object.entries(params).map(([key, value]) => {

12
src/lib/util.ts

@ -0,0 +1,12 @@
import { v1 } from 'uuid'
export function trimContent(content: string, length: number = 128): string {
if (!content) return ''
if (content.length < length) return content.trim()
return content.slice(0, length).trim()
}
export function createPostId(): string {
return 'p' + v1().replace('/-/g', '')
}

21
src/plugins/api/authentication.ts

@ -17,7 +17,7 @@ import { containerFor, createQuerySpec, normalize } from '../../lib/database'
import { errorSchema, badRequestError, serverError, unauthorizedError } from '../../lib/errors'
import { tokenFromHeader } from '../../lib/http'
import { IUser, IRefreshToken } from '../../types/collections'
import { IUser, IUserToken } from '../../types/collections'
interface PluginOptions {
@ -73,14 +73,16 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
if (!server.database) return serverError(reply, 'Database error')
const userContainer = await containerFor(server.database.client, 'Users')
const existingUserItem = await userContainer.item(id, undefined)
const container = await containerFor(server.database.client, 'Users')
const existingUserItem = await container.item(id, id)
const { resource: existingUser } = await existingUserItem.read<IUser>()
if (existingUser) return badRequestError(reply, 'User id already taken', 'id')
const user: IUser = {
id,
partitionKey: id,
type: 'user',
name,
email,
emailVerified: false,
@ -92,12 +94,10 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
created: Date.now(),
}
await userContainer.items.create(user)
const tokenContainer = await containerFor(server.database.client, 'Tokens')
const refreshToken = createRefreshToken(id, request.headers['user-agent'], request.ip)
await tokenContainer.items.create(refreshToken)
await container.items.create(user)
await container.items.create(refreshToken)
return {
id,
@ -143,10 +143,9 @@ function authenticateRoute(server: FastifyInstance<Server, IncomingMessage, Serv
const result = await verifyPassword(user.passwordHash, password)
if (!result) return badRequestError(reply, 'Incorrect credentials')
const tokenContainer = await containerFor(server.database.client, 'Tokens')
const refreshToken = createRefreshToken(user.id, request.headers['user-agent'], request.ip)
await tokenContainer.items.create(refreshToken)
await container.items.create(refreshToken)
return {
id,
@ -205,11 +204,11 @@ function refreshRoute(server: FastifyInstance<Server, IncomingMessage, ServerRes
return badRequestError(reply, 'Invalid token')
}
const container = await containerFor(server.database.client, 'Tokens')
const container = await containerFor(server.database.client, 'User')
const tokenItem = await container.item(request.body.refresh, userId)
if (!tokenItem) return badRequestError(reply, 'Invalid refresh token')
const { resource: token } = await tokenItem.read<IRefreshToken>()
const { resource: token } = await tokenItem.read<IUserToken>()
if (token.expires < Date.now()) return badRequestError(reply, 'Refresh token expired')

1
src/plugins/api/index.ts

@ -3,6 +3,7 @@ import { Plugin } from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { JWT } from '../../lib/crypto'
import { containerFor } from '../../lib/database'
import { tokenFromHeader } from '../../lib/http'
import authentication from './authentication'

146
src/plugins/api/posts.ts

@ -0,0 +1,146 @@
import fastify, {
Plugin,
DefaultQuery,
DefaultParams,
DefaultHeaders,
RouteShorthandOptions,
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { unauthorizedError, badRequestError } from '../../lib/errors'
import { containerFor, createQuerySpec } from '../../lib/database'
import { trimContent, createPostId } from '../../lib/util'
import { IPost, IPostAttachment, IUserPost, IUserFollow, IStatus, IUserTimelinePost, IPostRelationship } from '../../types/collections'
interface PluginOptions {
}
function postRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
text?: string
cover?: string
visible: boolean
status?: IStatus
attachments: IPostAttachment[]
parent: string
}
const options: RouteShorthandOptions = {
schema: {
body: {
type: 'object',
required: ['visible'],
properties: {
text: { type: 'string' },
cover: { type: 'string' },
visible: { type: 'boolean' },
status: {
type: 'object',
required: ['date'],
properties: {
imageUrl: 'string',
text: 'string',
date: 'number',
},
},
attachments: {
type: 'array',
items: {
type: 'object',
required: ['imageUrl'],
properties: {
imageUrl: 'string',
caption: 'string',
cover: 'string',
},
},
},
parent: { type: 'string' },
},
},
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
},
},
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/post', options, async (request, reply) => {
if (!request.user) return unauthorizedError(reply)
let newPostRelationship: IPostRelationship = null
const postContainer = await containerFor(server.database.client, 'Posts')
const postRelationshipContainer = await containerFor(server.database.client, 'PostRelationships')
const userContainer = await containerFor(server.database.client, 'User')
const postId = createPostId()
if (request.body.parent) {
const parentItem = postContainer.item(request.body.parent, request.body.parent)
const { resource: parent } = await parentItem.read<IPost>()
if (!parent) return badRequestError(reply, 'Invalid parent', 'parent')
const parentRelationshipItem = postRelationshipContainer.item(request.body.parent, parent.root)
const { resource: parentRelationship } = await parentRelationshipItem.read<IPostRelationship>()
newPostRelationship = {
id: postId,
partitionKey: parent.root,
parents: [
...parentRelationship.parents,
parent.id,
]
}
}
const post: IPost = {
id: postId,
userId: request.user.id,
root: newPostRelationship ? newPostRelationship.partitionKey : postId,
text: trimContent(request.body.text, 1000),
cover: trimContent(request.body.cover),
visible: request.body.visible,
attachments: [],
awards: [],
created: Date.now(),
}
const userPost: IUserPost = {
id: postId,
partitionKey: request.user.id,
type: 'post',
created: Date.now(),
}
await postContainer.items.create<IPost>(post)
await userContainer.items.create<IUserPost>(userPost)
await postRelationshipContainer.items.create<IPostRelationship>(newPostRelationship)
const query = createQuerySpec(`SELECT u.id FROM Users u WHERE u.partitionKey = @partitionKey AND type = 'follow'`, { partitionKey: request.user.id })
const { resources: followers } = await userContainer.items.query<IUserFollow>(query, {}).fetchAll()
if (followers.length < 500) {
for (let follower in followers) {
await userContainer.items.create<IUserTimelinePost>({
id: postId,
partitionKey: followers[follower].id,
type: 'timeline',
created: Date.now(),
})
}
}
})
}
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
postRoute(server)
}
export default plugin

115
src/types/collections.ts

@ -1,22 +1,19 @@
// Containers
//
// Users
// - Partition Key: id
// Tokens
// - Parition Key: userId
// - Partition Key: partitionKey (userId)
// Posts
// - Partition Key: id
// UserPosts
// - Partition Key: by (userId)
// ForUserPosts
// - Partition Key: for (userId)
// PostPosts
// - Partition Key: root (postId)
// PostRelationships
// - Partition Key: partitionKey (postId)
export type IAccountStatus = 'default' | 'platinum'
export type IUserItemType = 'user' | 'token' | 'post' | 'follow' | 'timeline'
export interface IUser {
id: string // Partition Key
id: string
partitionKey: string // id
type: 'user'
name: string
email: string
emailVerified: boolean
@ -28,19 +25,34 @@ export interface IUser {
created: number // Timestamp
}
export interface IRefreshToken {
export interface IUserToken {
id: string
userId: string
partitionKey: string // userId
type: 'token'
userAgent: string
ip: string
created: number
expires: number
created: number
}
export interface IInstallation {
id: string
appId: string
settings: object
export interface IUserPost {
id: string // postId
partitionKey: string // userId
type: 'post'
created: number
}
export interface IUserFollow {
id: string // userId (followerId)
partitionKey: string // userId (followedId)
type: 'follow'
created: number
}
export interface IUserTimelinePost {
id: string // postId
partitionKey: string // userId
type: 'timeline'
created: number
}
@ -51,6 +63,44 @@ interface IAward {
points: number
}
export interface IPostAttachment {
imageUrl: string
caption?: string
cover?: string
}
export interface IStatus {
imageUrl: string
text: string
created: number
}
export interface IPost {
id: string
userId: string
root: string
text?: string
cover?: string
attachments: IPostAttachment[]
status?: IStatus
visible: boolean
awards: IAward[]
created: number
}
export interface IPostRelationship {
id: string
parents: string[] // Post IDs
partitionKey: string // root post ID
}
export interface IInstallation {
id: string
appId: string
settings: object
created: number
}
interface IAppRevision {
version: string
displayName: string
@ -79,34 +129,3 @@ interface IApp {
privateKey: string
created: number
}
interface PostAttachment {
imageUrl: string
caption?: string
cover?: string
}
interface IPartialUser {
id: string
name: string
}
interface IStatus {
imageUrl: string
text: string
created: number
}
interface IPost {
id: string
user: IPartialUser
parents: string[] // Post IDs
text?: string
cover?: string
attachments: PostAttachment[]
status?: IStatus
visible: boolean
category: string // Content, Badge, Profile Attribute
awards: IAward[]
created: number
}
Loading…
Cancel
Save