Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User invite codes #757

Merged
merged 15 commits into from
Apr 5, 2023
3 changes: 2 additions & 1 deletion lexicons/com/atproto/server/createInviteCode.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"type": "object",
"required": ["useCount"],
"properties": {
"useCount": {"type": "integer"}
"useCount": {"type": "integer"},
"forUser": {"type": "string", "format": "did"}
}
}
},
Expand Down
45 changes: 45 additions & 0 deletions lexicons/com/atproto/server/getUserInviteCodes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"lexicon": 1,
"id": "com.atproto.server.getUserInviteCodes",
"defs": {
"main": {
"type": "query",
"description": "Get all invite codes for a given user",
"parameters": {
"type": "params",
"properties": {
"includeUsed": { "type": "boolean", "default": true },
"createAvailable": { "type": "boolean", "default": true }
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["codes"],
"properties": {
"codes": {
"type": "array",
"items": {
"type": "ref",
"ref": "#invite"
}
}
}
}
},
"errors": [
{"name": "DuplicateCreate"}
]
},
"invite": {
"type": "object",
"required": ["code", "available", "uses"],
"properties": {
"code": { "type": "string" },
"available": { "type": "integer" },
"uses": { "type": "integer" }
}
}
}
}
13 changes: 13 additions & 0 deletions packages/api/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/delet
import * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession'
import * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer'
import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession'
import * as ComAtprotoServerGetUserInviteCodes from './types/com/atproto/server/getUserInviteCodes'
import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession'
import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete'
import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset'
Expand Down Expand Up @@ -116,6 +117,7 @@ export * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/delet
export * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession'
export * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer'
export * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession'
export * as ComAtprotoServerGetUserInviteCodes from './types/com/atproto/server/getUserInviteCodes'
export * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession'
export * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete'
export * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset'
Expand Down Expand Up @@ -580,6 +582,17 @@ export class ServerNS {
})
}

getUserInviteCodes(
params?: ComAtprotoServerGetUserInviteCodes.QueryParams,
opts?: ComAtprotoServerGetUserInviteCodes.CallOptions,
): Promise<ComAtprotoServerGetUserInviteCodes.Response> {
return this._service.xrpc
.call('com.atproto.server.getUserInviteCodes', params, undefined, opts)
.catch((e) => {
throw ComAtprotoServerGetUserInviteCodes.toKnownErr(e)
})
}

refreshSession(
data?: ComAtprotoServerRefreshSession.InputSchema,
opts?: ComAtprotoServerRefreshSession.CallOptions,
Expand Down
64 changes: 64 additions & 0 deletions packages/api/src/client/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1698,6 +1698,10 @@ export const schemaDict = {
useCount: {
type: 'integer',
},
forUser: {
type: 'string',
format: 'did',
},
},
},
},
Expand Down Expand Up @@ -1889,6 +1893,65 @@ export const schemaDict = {
},
},
},
ComAtprotoServerGetUserInviteCodes: {
lexicon: 1,
id: 'com.atproto.server.getUserInviteCodes',
defs: {
main: {
type: 'query',
description: 'Get all invite codes for a given user',
parameters: {
type: 'params',
properties: {
includeUsed: {
type: 'boolean',
default: true,
},
createAvailable: {
type: 'boolean',
default: true,
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['codes'],
properties: {
codes: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:com.atproto.server.getUserInviteCodes#invite',
},
},
},
},
},
errors: [
{
name: 'DuplicateCreate',
},
],
},
invite: {
type: 'object',
required: ['code', 'available', 'uses'],
properties: {
code: {
type: 'string',
},
available: {
type: 'integer',
},
uses: {
type: 'integer',
},
},
},
},
},
ComAtprotoServerRefreshSession: {
lexicon: 1,
id: 'com.atproto.server.refreshSession',
Expand Down Expand Up @@ -4190,6 +4253,7 @@ export const ids = {
ComAtprotoServerDeleteSession: 'com.atproto.server.deleteSession',
ComAtprotoServerDescribeServer: 'com.atproto.server.describeServer',
ComAtprotoServerGetSession: 'com.atproto.server.getSession',
ComAtprotoServerGetUserInviteCodes: 'com.atproto.server.getUserInviteCodes',
ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession',
ComAtprotoServerRequestAccountDelete:
'com.atproto.server.requestAccountDelete',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface QueryParams {}

export interface InputSchema {
useCount: number
forUser?: string
[k: string]: unknown
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@atproto/xrpc'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'

export interface QueryParams {
includeUsed?: boolean
createAvailable?: boolean
}

export type InputSchema = undefined

export interface OutputSchema {
codes: Invite[]
[k: string]: unknown
}

export interface CallOptions {
headers?: Headers
}

export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}

export class DuplicateCreateError extends XRPCError {
constructor(src: XRPCError) {
super(src.status, src.error, src.message)
}
}

export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
if (e.error === 'DuplicateCreate') return new DuplicateCreateError(e)
}
return e
}

export interface Invite {
code: string
available: number
uses: number
[k: string]: unknown
}

export function isInvite(v: unknown): v is Invite {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.server.getUserInviteCodes#invite'
)
}

export function validateInvite(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.server.getUserInviteCodes#invite', v)
}
1 change: 1 addition & 0 deletions packages/dev-env/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export class DevEnvServer {
emailNoReplyAddress: 'noreply@blueskyweb.xyz',
adminPassword: 'password',
inviteRequired: false,
userInviteInterval: null,
imgUriSalt: '9dd04221f5755bce5f55f47464c27e1e',
imgUriKey:
'f23ecd142835025f42c3db2cf25dd813956c178392760256211f9d315f8ab4d8',
Expand Down
16 changes: 4 additions & 12 deletions packages/pds/src/api/com/atproto/server/createInviteCode.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,22 @@
import * as crypto from '@atproto/crypto'
import * as uint8arrays from 'uint8arrays'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { genInvCode } from './util'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.server.createInviteCode({
auth: ctx.adminVerifier,
handler: async ({ input, req }) => {
const { useCount } = input.body
const { useCount, forUser = 'admin' } = input.body

// generate a 7 char b32 invite code - preceded by the hostname
// with '.'s replaced by '-'s so it is not mistakable for a link
// ex: bsky-app-abc2345
// regex: bsky-app-[a-z2-7]{7}
const code =
ctx.cfg.publicHostname.replaceAll('.', '-') +
'-' +
uint8arrays.toString(await crypto.randomBytes(7), 'base32').slice(0, 7)
const code = genInvCode(ctx.cfg)

await ctx.db.db
.insertInto('invite_code')
.values({
code: code,
availableUses: useCount,
disabled: 0,
forUser: 'admin',
forUser,
createdBy: 'admin',
createdAt: new Date().toISOString(),
})
Expand Down
95 changes: 95 additions & 0 deletions packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { genInvCodes } from './util'
import { sql } from 'kysely'
import { InvalidRequestError } from '@atproto/xrpc-server'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.server.getUserInviteCodes({
auth: ctx.accessVerifierCheckTakedown,
handler: async ({ params, auth }) => {
const requester = auth.credentials.did
const { includeUsed, createAvailable } = params

const [user, userCodesRes] = await Promise.all([
ctx.db.db
.selectFrom('user_account')
.where('did', '=', requester)
.select('createdAt')
.executeTakeFirstOrThrow(),
ctx.db.db
.with('use_count', (qb) =>
qb
.selectFrom('invite_code_use')
.groupBy('code')
.select(['code', sql<number>`count(usedBy)`.as('uses')]),
)
.selectFrom('invite_code')
.leftJoin('use_count', 'use_count.code', 'invite_code.code')
.where('forUser', '=', requester)
.groupBy('invite_code.code')
.select([
'invite_code.code as code',
'invite_code.availableUses as available',
'use_count.uses as uses',
])
.execute(),
])
const userCodes = userCodesRes.map((row) => ({
...row,
uses: row.uses ?? 0,
}))
const unusedCodes = userCodes.filter((row) => row.available > row.uses)

let created: string[] = []

// if the user wishes to create available codes & the server allows that,
// we determine the number to create by dividing their account lifetime by the interval at which they can create codes
// we allow a max of 5 open codes at a given time
if (createAvailable && ctx.cfg.userInviteInterval !== null) {
const accountLifespan = Date.now() - new Date(user.createdAt).getTime()
const couldCreate = Math.floor(
accountLifespan / ctx.cfg.userInviteInterval,
)
const toCreate = Math.min(5 - unusedCodes.length, couldCreate)
if (toCreate > 0) {
created = genInvCodes(ctx.cfg, toCreate)
const rows = created.map((code) => ({
code: code,
availableUses: 1,
disabled: 0 as const,
forUser: requester,
createdBy: requester,
createdAt: new Date().toISOString(),
}))
await ctx.db.transaction(async (dbTxn) => {
await dbTxn.db.insertInto('invite_code').values(rows).execute()
const forUser = await dbTxn.db
.selectFrom('invite_code')
.where('forUser', '=', requester)
.selectAll()
.execute()
if (forUser.length > userCodes.length + toCreate) {
throw new InvalidRequestError(
'attempted to create additional codes in another request',
'DuplicateCreate',
)
}
})
}
}

const toReturn = [
...(includeUsed ? userCodes : unusedCodes),
...created.map((code) => ({ code: code, available: 1, uses: 0 })),
]

return {
encoding: 'application/json',
body: {
codes: toReturn,
},
}
},
})
}
Loading