From d4116c502eb6d33593fb5eb26e4f41f8329b4662 Mon Sep 17 00:00:00 2001 From: undefined Date: Sat, 25 May 2024 02:14:07 +0800 Subject: [PATCH] core: make API a service --- packages/hydrooj/src/entry/worker.ts | 7 +- packages/hydrooj/src/handler/api.ts | 178 -------------------- packages/hydrooj/src/handler/contest.ts | 57 ++++--- packages/hydrooj/src/handler/domain.ts | 67 ++++---- packages/hydrooj/src/handler/problem.ts | 126 +++++++------- packages/hydrooj/src/handler/user.ts | 97 ++++++----- packages/hydrooj/src/plugin-api.ts | 2 +- packages/hydrooj/src/service/api.ts | 211 ++++++++++++++++++++++++ 8 files changed, 388 insertions(+), 357 deletions(-) delete mode 100644 packages/hydrooj/src/handler/api.ts create mode 100644 packages/hydrooj/src/service/api.ts diff --git a/packages/hydrooj/src/entry/worker.ts b/packages/hydrooj/src/entry/worker.ts index f44cef15f..90e0bd79c 100644 --- a/packages/hydrooj/src/entry/worker.ts +++ b/packages/hydrooj/src/entry/worker.ts @@ -38,11 +38,14 @@ export async function apply(ctx: Context) { await modelSystem.runConfig(); const storage = require('../service/storage'); await storage.loadStorageService(); - await require('../service/worker').apply(ctx); - await require('../service/server').apply(ctx); // Make sure everything is ready and then start main entry if (argv.options.watch) ctx.plugin(require('../service/watcher').default, {}); await ctx.root.start(); + await ctx.lifecycle.flush(); + await require('../service/worker').apply(ctx); + await require('../service/server').apply(ctx); + await require('../service/api').apply(ctx); + await ctx.lifecycle.flush(); require('../lib/index'); await lib(pending, fail, ctx); await ctx.lifecycle.flush(); diff --git a/packages/hydrooj/src/handler/api.ts b/packages/hydrooj/src/handler/api.ts deleted file mode 100644 index e162e0dea..000000000 --- a/packages/hydrooj/src/handler/api.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { makeExecutableSchema } from '@graphql-tools/schema'; -import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils'; -import { defaultFieldResolver, graphql, GraphQLSchema } from 'graphql'; -import { resolvers, typeDefs } from 'graphql-scalars'; -import { debounce } from 'lodash'; -import { Context as PluginContext } from '../context'; -import { PERM, PRIV } from '../model/builtin'; -import * as bus from '../service/bus'; -import { Handler } from '../service/server'; - -const types: Record> = {}; -const unions: Record = {}; -const descriptions: Record> = {}; -const handlers: Record> = { - Query: {}, -}; -let root: Record = {}; - -export function registerValue(typeName: string, key: string, value: string, description?: string): void; -export function registerValue(typeName: string, vals: [string, string, string?][]): void; -export function registerValue(typeName: string, arg1: [string, string, string?][] | string, value?: string, description?: string) { - if (typeof arg1 === 'string') arg1 = [[arg1, value!, description]]; - for (const [k, v, d] of arg1) { - if (!types[typeName]) types[typeName] = { [k]: v }; - else types[typeName][k] = v; - if (d) { - if (!descriptions[typeName]) descriptions[typeName] = { [k]: d }; - else descriptions[typeName][k] = d; - } - } - bus.emit('api/update'); -} - -interface Context extends ApiHandler { - [key: string]: any; -} - -export function registerResolver( - typeName: string, key: string, value: string, - func: (args: any, ctx: Context, info: any) => any, - description?: string, -) { - registerValue(typeName, key, value, description); - const wrappedFunc = async (arg, ctx, info) => { - const res = await func(arg, ctx, info); - if (typeof res !== 'object' || res === null) return res; - let node = value.includes('!') ? value.split('!')[0] : value; - const isArray = node.includes('['); - node = node.replace('[', '').replace(']', ''); - if (handlers[node]) { - if (!isArray) Object.assign(res, handlers[node]); - else if (res instanceof Array) for (const i of res) { Object.assign(i, handlers[node]); } - } - ctx.parent = res; - return res; - }; - handlers[typeName] ||= {}; - handlers[typeName][key.split('(')[0].trim()] = wrappedFunc; - bus.emit('api/update'); -} - -export function registerUnion(typeName: string, ...unionTypes: string[]) { - unions[typeName] = unionTypes.join(' | '); - bus.emit('api/update'); -} - -function setDescription(desc: string) { - if (desc.includes('\n')) return ['"""', desc, '"""'].join('\n'); - return JSON.stringify(desc); -} - -let schema: GraphQLSchema; -let schemaStr = ''; -root = handlers.Query; - -const applyAuthDirective = (s) => mapSchema(s, { - // eslint-disable-next-line consistent-return - [MapperKind.OBJECT_FIELD]: (fieldConfig) => { - const authDirective = getDirective(s, fieldConfig, 'auth')?.[0]; - if (authDirective) { - const { resolve = defaultFieldResolver } = fieldConfig; - return { - ...fieldConfig, - async resolve(source, args, context, info) { - if (authDirective.perm) { - const perm = PERM[authDirective.perm]; - if (!context.user.hasPerm(perm)) throw new Error(`Permission denied: ${authDirective.perm}`); - } - if (authDirective.priv) { - const priv = PRIV[authDirective.priv]; - if (!context.user.hasPriv(priv)) throw new Error(`Permission denied: ${authDirective.priv}`); - } - return await resolve(source, args, context, info); - }, - }; - } - const ifDirective = getDirective(s, fieldConfig, 'if')?.[0]; - if (ifDirective) { - const { resolve = defaultFieldResolver } = fieldConfig; - return { - ...fieldConfig, - async resolve(source, args, context, info) { - if (ifDirective.perm && !context.user.hasPerm(PERM[ifDirective.perm])) return null; - if (ifDirective.priv && !context.user.hasPriv(PRIV[ifDirective.priv])) return null; - return await resolve(source, args, context, info); - }, - }; - } - }, -}); - -export function rebuild() { - try { - const defs = [ - ...Object.keys(unions).map((i) => `union ${i} = ${unions[i]}`), - 'directive @auth(perm: String, priv: String) on FIELD_DEFINITION', - 'directive @if(perm: String, priv: String) on FIELD_DEFINITION', - ...typeDefs, - ...Object.keys(types).map((key) => { - let def = ''; - if (descriptions[key]?._description) def += `${setDescription(descriptions[key]._description)}\n`; - def += `type ${key} {\n`; - for (const k in types[key]) { - if (descriptions[key]?.[k]) def += ` ${setDescription(descriptions[key][k])}\n`; - def += ` ${k}: ${types[key][k]}\n`; - } - def += '}\n'; - return def; - }), - ]; - schema = makeExecutableSchema({ - typeDefs: defs, - resolvers, - }); - schema = applyAuthDirective(schema); - schemaStr = defs.join('\n'); - } catch (e) { - console.error(e); - } -} - -class ApiHandler extends Handler { - category = '#api'; - noCheckPermView = true; - - query(q: string, variables: any) { - return graphql({ - schema, - source: q, - rootValue: root, - contextValue: this, - variableValues: variables, - }); - } - - async get() { - const q = decodeURIComponent(this.request.querystring); - if (q === 'schema') { - this.response.type = 'application/json'; - this.response.body = { schema: schemaStr }; - } else if (q) { - this.response.type = 'application/json'; - this.response.body = await this.query(q, {}); - } else this.response.template = 'api.html'; - } - - async post() { - this.response.type = 'application/json'; - this.response.body = await this.query(this.args.query, this.args.variables); - } -} - -export const sideEffect = true; -export function apply(ctx: PluginContext) { - ctx.Route('api', '/api', ApiHandler); - ctx.on('ready', rebuild); - ctx.on('api/update', debounce(rebuild)); -} diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 49fef3459..927a646b5 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -6,7 +6,7 @@ import { ObjectId } from 'mongodb'; import { Counter, sortFiles, streamToBuffer, Time, yaml, } from '@hydrooj/utils/lib/utils'; -import { Context } from '../context'; +import type { Context } from '../context'; import { BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, @@ -28,34 +28,6 @@ import user from '../model/user'; import { Handler, param, post, Types, } from '../service/server'; -import { registerResolver, registerValue } from './api'; - -registerValue('Contest', [ - ['_id', 'ObjectID!'], - ['domainId', 'String!'], - ['docId', 'ObjectID!'], - ['owner', 'Int!'], - ['beginAt', 'Date!'], - ['title', 'String!'], - ['content', 'String!'], - ['beginAt', 'Date!'], - ['endAt', 'Date!'], - ['attend', 'Int!'], - ['pids', '[Int]!'], - ['rated', 'Boolean!'], -]); - -registerResolver( - 'Query', 'contest(id: ObjectID!)', 'Contest', - async (arg, ctx) => { - ctx.checkPerm(PERM.PERM_VIEW); - arg.id = new ObjectId(arg.id); - ctx.tdoc = await contest.get(ctx.args.domainId, new ObjectId(arg.id)); - if (!ctx.tdoc) throw new ContestNotFoundError(ctx.args.domainId, arg.id); - return ctx.tdoc; - }, - 'Get a contest by ID', -); export class ContestListHandler extends Handler { @param('rule', Types.Range(contest.RULES), true) @@ -793,4 +765,31 @@ export async function apply(ctx: Context) { } await Promise.all(tasks); }); + ctx.inject(['api'], ({ api }) => { + api.value('Contest', [ + ['_id', 'ObjectID!'], + ['domainId', 'String!'], + ['docId', 'ObjectID!'], + ['owner', 'Int!'], + ['beginAt', 'Date!'], + ['title', 'String!'], + ['content', 'String!'], + ['beginAt', 'Date!'], + ['endAt', 'Date!'], + ['attend', 'Int!'], + ['pids', '[Int]!'], + ['rated', 'Boolean!'], + ]); + api.resolver( + 'Query', 'contest(id: ObjectID!)', 'Contest', + async (arg, c) => { + c.checkPerm(PERM.PERM_VIEW); + arg.id = new ObjectId(arg.id); + c.tdoc = await contest.get(c.args.domainId, new ObjectId(arg.id)); + if (!c.tdoc) throw new ContestNotFoundError(c.args.domainId, arg.id); + return c.tdoc; + }, + 'Get a contest by ID', + ); + }); } diff --git a/packages/hydrooj/src/handler/domain.ts b/packages/hydrooj/src/handler/domain.ts index 5bea75e44..6bc1adcb5 100644 --- a/packages/hydrooj/src/handler/domain.ts +++ b/packages/hydrooj/src/handler/domain.ts @@ -1,7 +1,7 @@ import { load } from 'js-yaml'; import { Dictionary } from 'lodash'; import moment from 'moment-timezone'; -import { Context } from '../context'; +import type { Context } from '../context'; import { CannotDeleteSystemDomainError, DomainJoinAlreadyMemberError, DomainJoinForbiddenError, ForbiddenError, InvalidJoinInvitationCodeError, OnlyOwnerCanDeleteDomainError, PermissionError, RoleAlreadyExistError, ValidationError, @@ -20,40 +20,6 @@ import { Handler, param, post, query, requireSudo, Types, } from '../service/server'; import { log2 } from '../utils'; -import { registerResolver, registerValue } from './api'; - -registerValue('GroupInfo', [ - ['name', 'String!'], - ['uids', '[Int]!'], -]); - -registerResolver('Query', 'domain(id: String)', 'Domain', async (args, ctx) => { - const ddoc = args.id ? await domain.get(args.id) : ctx.domain; - if (!ddoc) return null; - const udoc = await user.getById(ddoc._id, ctx.user._id); - if (!udoc.hasPerm(PERM.PERM_VIEW) && !udoc.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) return null; - ctx.udoc = udoc; - return ddoc; -}); - -registerResolver('Domain', 'manage', 'DomainManage', async (args, ctx) => { - if (!ctx.udoc.hasPerm(PERM.PERM_EDIT_DOMAIN)) throw new PermissionError(PERM.PERM_EDIT_DOMAIN); - return ctx.parent; -}); - -registerResolver('DomainManage', 'group', 'DomainGroup', (args, ctx) => ctx.parent); -registerResolver( - 'DomainGroup', 'list(uid: Int)', '[GroupInfo]', - (args, ctx) => user.listGroup(ctx.parent._id, args.uid), -); -registerResolver( - 'DomainGroup', 'update(name: String!, uids: [Int]!)', 'Boolean', - async (args, ctx) => !!(await user.updateGroup(ctx.parent._id, args.name, args.uids)).upsertedCount, -); -registerResolver( - 'DomainGroup', 'del(name: String!)', 'Boolean', - async (args, ctx) => !!(await user.delGroup(ctx.parent._id, args.name)).deletedCount, -); class DomainRankHandler extends Handler { @query('page', Types.PositiveInt, true) @@ -369,4 +335,35 @@ export async function apply(ctx: Context) { ctx.Route('domain_join_applications', '/domain/join_applications', DomainJoinApplicationsHandler); ctx.Route('domain_join', '/domain/join', DomainJoinHandler, PRIV.PRIV_USER_PROFILE); ctx.Route('domain_search', '/domain/search', DomainSearchHandler, PRIV.PRIV_USER_PROFILE); + ctx.inject(['api'], ({ api }) => { + api.value('GroupInfo', [ + ['name', 'String!'], + ['uids', '[Int]!'], + ]); + api.resolver('Query', 'domain(id: String)', 'Domain', async (args, c) => { + const ddoc = args.id ? await domain.get(args.id) : c.domain; + if (!ddoc) return null; + const udoc = await user.getById(ddoc._id, c.user._id); + if (!udoc.hasPerm(PERM.PERM_VIEW) && !udoc.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) return null; + c.udoc = udoc; + return ddoc; + }); + api.resolver('Domain', 'manage', 'DomainManage', async (args, c) => { + if (!c.udoc.hasPerm(PERM.PERM_EDIT_DOMAIN)) throw new PermissionError(PERM.PERM_EDIT_DOMAIN); + return c.parent; + }); + api.resolver('DomainManage', 'group', 'DomainGroup', (args, c) => c.parent); + api.resolver( + 'DomainGroup', 'list(uid: Int)', '[GroupInfo]', + (args, c) => user.listGroup(c.parent._id, args.uid), + ); + api.resolver( + 'DomainGroup', 'update(name: String!, uids: [Int]!)', 'Boolean', + async (args, c) => !!(await user.updateGroup(c.parent._id, args.name, args.uids)).upsertedCount, + ); + api.resolver( + 'DomainGroup', 'del(name: String!)', 'Boolean', + async (args, c) => !!(await user.delGroup(c.parent._id, args.name)).deletedCount, + ); + }); } diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 135bd8fed..a43b26f92 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -8,6 +8,7 @@ import { nanoid } from 'nanoid'; import sanitize from 'sanitize-filename'; import parser from '@hydrooj/utils/lib/search'; import { sortFiles, streamToBuffer } from '@hydrooj/utils/lib/utils'; +import type { Context } from '../context'; import { BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, FileLimitExceededError, HackFailedError, NoProblemError, NotFoundError, @@ -34,72 +35,10 @@ import { Handler, param, post, query, route, Types, } from '../service/server'; import { buildProjection } from '../utils'; -import { registerResolver, registerValue } from './api'; import { ContestDetailBaseHandler } from './contest'; export const parseCategory = (value: string) => value.replace(/,/g, ',').split(',').map((e) => e.trim()); -registerValue('FileInfo', [ - ['_id', 'String!'], - ['name', 'String!'], - ['size', 'Int'], - ['lastModified', 'Date'], -]); -registerValue('Problem', [ - ['_id', 'ObjectID!'], - ['owner', 'Int!'], - ['domainId', 'String!'], - ['docId', 'Int!'], - ['docType', 'Int!'], - ['pid', 'String'], - ['title', 'String!'], - ['content', 'String!'], - ['data', '[FileInfo]'], - ['additional_file', '[FileInfo]'], - ['nSubmit', 'Int'], - ['nAccept', 'Int'], - ['difficulty', 'Int'], - ['tag', '[String]'], - ['hidden', 'Boolean'], -]); - -registerResolver( - 'Query', 'problem(id: Int, pid: String)', 'Problem', - async (arg, ctx) => { - ctx.checkPerm(PERM.PERM_VIEW); - const pdoc = await problem.get(ctx.args.domainId, arg.pid || arg.id); - if (!pdoc) return null; - if (pdoc.hidden) ctx.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); - ctx.pdoc = pdoc; - return pdoc; - }, -); -registerResolver('Query', 'problems(ids: [Int])', '[Problem]', async (arg, ctx) => { - ctx.checkPerm(PERM.PERM_VIEW); - const res = await problem.getList(ctx.args.domainId, arg.ids, ctx.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || ctx.user._id, - undefined, undefined, true); - return Object.keys(res).map((id) => res[+id]); -}, 'Get a list of problem by ids'); -registerResolver( - 'Problem', 'manage', 'ProblemManage', - (arg, ctx) => { - if (!ctx.user.own(ctx.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) ctx.checkPerm(PERM.PERM_EDIT_PROBLEM); - return {}; - }, -); -registerResolver( - 'ProblemManage', 'delete', 'Boolean!', - async (arg, ctx) => { - const tdocs = await contest.getRelated(ctx.args.domainId, ctx.pdoc.docId); - if (tdocs.length) throw new ProblemAlreadyUsedByContestError(ctx.pdoc.docId, tdocs[0]._id); - return problem.del(ctx.pdoc.domainId, ctx.pdoc.docId); - }, -); -registerResolver( - 'ProblemManage', 'edit(title: String, content: String, tag: [String], hidden: Boolean)', 'Problem!', - (arg, ctx) => problem.edit(ctx.args.domainId, ctx.pdoc.docId, arg), -); - function buildQuery(udoc: User) { const q: Filter = {}; if (!udoc.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN)) { @@ -1078,7 +1017,7 @@ export class ProblemPrefixListHandler extends Handler { } } -export async function apply(ctx) { +export async function apply(ctx: Context) { ctx.Route('problem_main', '/p', ProblemMainHandler, PERM.PERM_VIEW_PROBLEM); ctx.Route('problem_random', '/problem/random', ProblemRandomHandler, PERM.PERM_VIEW_PROBLEM); ctx.Route('problem_detail', '/p/:pid', ProblemDetailHandler); @@ -1095,4 +1034,65 @@ export async function apply(ctx) { ctx.Route('problem_statistics', '/p/:pid/stat', ProblemStatisticsHandler, PERM.PERM_VIEW_PROBLEM); ctx.Route('problem_create', '/problem/create', ProblemCreateHandler, PERM.PERM_CREATE_PROBLEM); ctx.Route('problem_prefix_list', '/problem/list', ProblemPrefixListHandler, PERM.PERM_VIEW_PROBLEM); + ctx.inject(['api'], ({ api }) => { + api.value('FileInfo', [ + ['_id', 'String!'], + ['name', 'String!'], + ['size', 'Int'], + ['lastModified', 'Date'], + ]); + api.value('Problem', [ + ['_id', 'ObjectID!'], + ['owner', 'Int!'], + ['domainId', 'String!'], + ['docId', 'Int!'], + ['docType', 'Int!'], + ['pid', 'String'], + ['title', 'String!'], + ['content', 'String!'], + ['data', '[FileInfo]'], + ['additional_file', '[FileInfo]'], + ['nSubmit', 'Int'], + ['nAccept', 'Int'], + ['difficulty', 'Int'], + ['tag', '[String]'], + ['hidden', 'Boolean'], + ]); + api.resolver( + 'Query', 'problem(id: Int, pid: String)', 'Problem', + async (arg, c) => { + c.checkPerm(PERM.PERM_VIEW); + const pdoc = await problem.get(c.args.domainId, arg.pid || arg.id); + if (!pdoc) return null; + if (pdoc.hidden) c.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); + c.pdoc = pdoc; + return pdoc; + }, + ); + api.resolver('Query', 'problems(ids: [Int])', '[Problem]', async (arg, c) => { + c.checkPerm(PERM.PERM_VIEW); + const res = await problem.getList(c.args.domainId, arg.ids, c.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || c.user._id, + undefined, undefined, true); + return Object.keys(res).map((id) => res[+id]); + }, 'Get a list of problem by ids'); + api.resolver( + 'Problem', 'manage', 'ProblemManage', + (arg, c) => { + if (!c.user.own(c.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) c.checkPerm(PERM.PERM_EDIT_PROBLEM); + return {}; + }, + ); + api.resolver( + 'ProblemManage', 'delete', 'Boolean!', + async (arg, c) => { + const tdocs = await contest.getRelated(c.args.domainId, c.pdoc.docId); + if (tdocs.length) throw new ProblemAlreadyUsedByContestError(c.pdoc.docId, tdocs[0]._id); + return problem.del(c.pdoc.domainId, c.pdoc.docId); + }, + ); + api.resolver( + 'ProblemManage', 'edit(title: String, content: String, tag: [String], hidden: Boolean)', 'Problem!', + (arg, c) => problem.edit(c.args.domainId, c.pdoc.docId, arg), + ); + }); } diff --git a/packages/hydrooj/src/handler/user.ts b/packages/hydrooj/src/handler/user.ts index bc60f561d..b6f035b79 100644 --- a/packages/hydrooj/src/handler/user.ts +++ b/packages/hydrooj/src/handler/user.ts @@ -2,6 +2,7 @@ import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@si import moment from 'moment-timezone'; import type { Binary } from 'mongodb'; import { Time } from '@hydrooj/utils'; +import type { Context } from '../context'; import { AuthOperationError, BlacklistedError, BuiltinLoginError, ForbiddenError, InvalidTokenError, SystemError, UserAlreadyExistError, UserFacingError, @@ -26,54 +27,6 @@ import user, { deleteUserCache } from '../model/user'; import { Handler, param, post, Types, } from '../service/server'; -import { registerResolver, registerValue } from './api'; - -registerValue('User', [ - ['_id', 'Int!'], - ['uname', 'String!'], - ['mail', 'String!'], - ['perm', 'String'], - ['role', 'String'], - ['loginat', 'Date'], - ['regat', 'Date!'], - ['priv', 'Int!', 'User Privilege'], - ['avatarUrl', 'String'], - ['tfa', 'Boolean!'], - ['authn', 'Boolean!'], - ['displayName', 'String @if(perm: "PERM_VIEW_DISPLAYNAME")'], - ['rpInfo', 'JSONObject'], -]); - -registerResolver('Query', 'user(id: Int, uname: String, mail: String)', 'User', (arg, ctx) => { - if (arg.id) return user.getById(ctx.args.domainId, arg.id); - if (arg.mail) return user.getByEmail(ctx.args.domainId, arg.mail); - if (arg.uname) return user.getByUname(ctx.args.domainId, arg.uname); - return ctx.user; -}, `Get a user by id, uname, or mail. -Returns current user if no argument is provided.`); - -registerResolver('Query', 'users(ids: [Int], search: String, limit: Int, exact: Boolean)', '[User]', async (arg, ctx) => { - if (arg.ids?.length) { - const res = await user.getList(ctx.args.domainId, arg.ids); - for (const i in res) res[i].avatarUrl = avatar(res[i].avatar); - return Object.keys(res).map((id) => res[+id]); - } - if (!arg.search) return []; - const udoc = await user.getById(ctx.args.domainId, +arg.search) - || await user.getByUname(ctx.args.domainId, arg.search) - || await user.getByEmail(ctx.args.domainId, arg.search); - const udocs: User[] = arg.exact - ? [] - : await user.getPrefixList(ctx.args.domainId, arg.search, Math.min(arg.limit || 10, 10)); - if (udoc && !udocs.find((i) => i._id === udoc._id)) { - udocs.pop(); - udocs.unshift(udoc); - } - for (const i in udocs) { - udocs[i].avatarUrl = avatar(udocs[i].avatar); - } - return udocs; -}, 'Get a list of user by ids, or search users with the prefix.'); class UserLoginHandler extends Handler { noCheckPermView = true; @@ -548,7 +501,7 @@ class ContestModeHandler extends Handler { } } -export async function apply(ctx) { +export async function apply(ctx: Context) { ctx.Route('user_login', '/login', UserLoginHandler); ctx.Route('user_oauth', '/oauth/:type', OauthHandler); ctx.Route('user_sudo', '/user/sudo', UserSudoHandler, PRIV.PRIV_USER_PROFILE); @@ -564,4 +517,50 @@ export async function apply(ctx) { if (system.get('server.contestmode')) { ctx.Route('contest_mode', '/contestmode', ContestModeHandler, PRIV.PRIV_EDIT_SYSTEM); } + ctx.inject(['api'], ({ api }) => { + api.value('User', [ + ['_id', 'Int!'], + ['uname', 'String!'], + ['mail', 'String!'], + ['perm', 'String'], + ['role', 'String'], + ['loginat', 'Date'], + ['regat', 'Date!'], + ['priv', 'Int!', 'User Privilege'], + ['avatarUrl', 'String'], + ['tfa', 'Boolean!'], + ['authn', 'Boolean!'], + ['displayName', 'String @if(perm: "PERM_VIEW_DISPLAYNAME")'], + ['rpInfo', 'JSONObject'], + ]); + api.resolver('Query', 'user(id: Int, uname: String, mail: String)', 'User', (arg, c) => { + if (arg.id) return user.getById(c.args.domainId, arg.id); + if (arg.mail) return user.getByEmail(c.args.domainId, arg.mail); + if (arg.uname) return user.getByUname(c.args.domainId, arg.uname); + return c.user; + }, `Get a user by id, uname, or mail. +Returns current user if no argument is provided.`); + api.resolver('Query', 'users(ids: [Int], search: String, limit: Int, exact: Boolean)', '[User]', async (arg, c) => { + if (arg.ids?.length) { + const res = await user.getList(c.args.domainId, arg.ids); + for (const i in res) res[i].avatarUrl = avatar(res[i].avatar); + return Object.keys(res).map((id) => res[+id]); + } + if (!arg.search) return []; + const udoc = await user.getById(c.args.domainId, +arg.search) + || await user.getByUname(c.args.domainId, arg.search) + || await user.getByEmail(c.args.domainId, arg.search); + const udocs: User[] = arg.exact + ? [] + : await user.getPrefixList(c.args.domainId, arg.search, Math.min(arg.limit || 10, 10)); + if (udoc && !udocs.find((i) => i._id === udoc._id)) { + udocs.pop(); + udocs.unshift(udoc); + } + for (const i in udocs) { + udocs[i].avatarUrl = avatar(udocs[i].avatar); + } + return udocs; + }, 'Get a list of user by ids, or search users with the prefix.'); + }); } diff --git a/packages/hydrooj/src/plugin-api.ts b/packages/hydrooj/src/plugin-api.ts index 3f16accbc..a7bb1fa99 100644 --- a/packages/hydrooj/src/plugin-api.ts +++ b/packages/hydrooj/src/plugin-api.ts @@ -30,7 +30,7 @@ export { default as StorageModel } from './model/storage'; export { default as TaskModel } from './model/task'; export * from './model/builtin'; export * as JudgeHandler from './handler/judge'; -export { registerResolver, registerValue, registerUnion } from './handler/api'; +export { registerResolver, registerValue, registerUnion } from './service/api'; export { postJudge } from './handler/judge'; export { Collections } from './service/db'; export { Service, Context } from './context'; diff --git a/packages/hydrooj/src/service/api.ts b/packages/hydrooj/src/service/api.ts new file mode 100644 index 000000000..8f66a322b --- /dev/null +++ b/packages/hydrooj/src/service/api.ts @@ -0,0 +1,211 @@ +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils'; +import { defaultFieldResolver, graphql, GraphQLSchema } from 'graphql'; +import { resolvers, typeDefs } from 'graphql-scalars'; +import { debounce } from 'lodash'; +import { Context, Service } from '../context'; +import { PERM, PRIV } from '../model/builtin'; +import { Handler } from './server'; + +const types: Record> = {}; +const unions: Record = {}; +const descriptions: Record> = {}; +const handlers: Record> = { + Query: {}, +}; +let root: Record = {}; + +interface ApiContext extends ApiHandler { + [key: string]: any; +} +declare module '../context' { + interface Context { + api: ApiService; + } +} + +function setDescription(desc: string) { + if (desc.includes('\n')) return ['"""', desc, '"""'].join('\n'); + return JSON.stringify(desc); +} + +let schema: GraphQLSchema; +let schemaStr = ''; +root = handlers.Query; + +const applyAuthDirective = (s) => mapSchema(s, { + // eslint-disable-next-line consistent-return + [MapperKind.OBJECT_FIELD]: (fieldConfig) => { + const authDirective = getDirective(s, fieldConfig, 'auth')?.[0]; + if (authDirective) { + const { resolve = defaultFieldResolver } = fieldConfig; + return { + ...fieldConfig, + async resolve(source, args, context, info) { + if (authDirective.perm) { + const perm = PERM[authDirective.perm]; + if (!context.user.hasPerm(perm)) throw new Error(`Permission denied: ${authDirective.perm}`); + } + if (authDirective.priv) { + const priv = PRIV[authDirective.priv]; + if (!context.user.hasPriv(priv)) throw new Error(`Permission denied: ${authDirective.priv}`); + } + return await resolve(source, args, context, info); + }, + }; + } + const ifDirective = getDirective(s, fieldConfig, 'if')?.[0]; + if (ifDirective) { + const { resolve = defaultFieldResolver } = fieldConfig; + return { + ...fieldConfig, + async resolve(source, args, context, info) { + if (ifDirective.perm && !context.user.hasPerm(PERM[ifDirective.perm])) return null; + if (ifDirective.priv && !context.user.hasPriv(PRIV[ifDirective.priv])) return null; + return await resolve(source, args, context, info); + }, + }; + } + }, +}); + +class ApiHandler extends Handler { + category = '#api'; + noCheckPermView = true; + + query(q: string, variables: any) { + return graphql({ + schema, + source: q, + rootValue: root, + contextValue: this, + variableValues: variables, + }); + } + + async get() { + const q = decodeURIComponent(this.request.querystring); + if (q === 'schema') { + this.response.type = 'application/json'; + this.response.body = { schema: schemaStr }; + } else if (q) { + this.response.type = 'application/json'; + this.response.body = await this.query(q, {}); + } else this.response.template = 'api.html'; + } + + async post() { + this.response.type = 'application/json'; + this.response.body = await this.query(this.args.query, this.args.variables); + } +} + +/** @deprecated use ctx.api.value() instead */ +export function registerValue(...args: any[]) { + // @ts-ignore + return app.api.value(...args); +} + +/** @deprecated use ctx.api.resolver() instead */ +export function registerResolver(...args: any[]) { + // @ts-ignore + return app.api.resolver(...args); +} + +/** @deprecated use ctx.api.union() instead */ +export function registerUnion(...args: any[]) { + // @ts-ignore + return app.api.resolver(...args); +} + +// TODO support dispose +class ApiService extends Service { + constructor(ctx: Context) { + super(ctx, 'api', true); + this.rebuild = debounce(this.rebuild.bind(this), 500, { trailing: true }); + ctx.on('ready', this.rebuild); + } + + private rebuild() { + try { + const defs = [ + ...Object.keys(unions).map((i) => `union ${i} = ${unions[i]}`), + 'directive @auth(perm: String, priv: String) on FIELD_DEFINITION', + 'directive @if(perm: String, priv: String) on FIELD_DEFINITION', + ...typeDefs, + ...Object.keys(types).map((key) => { + let def = ''; + if (descriptions[key]?._description) def += `${setDescription(descriptions[key]._description)}\n`; + def += `type ${key} {\n`; + for (const k in types[key]) { + if (descriptions[key]?.[k]) def += ` ${setDescription(descriptions[key][k])}\n`; + def += ` ${k}: ${types[key][k]}\n`; + } + def += '}\n'; + return def; + }), + ]; + schema = makeExecutableSchema({ + typeDefs: defs, + resolvers, + }); + schema = applyAuthDirective(schema); + schemaStr = defs.join('\n'); + } catch (e) { + console.error(e); + } + this.ctx.emit('api/update'); + } + + value(typeName: string, key: string, value: string, description?: string): void; + value(typeName: string, vals: [string, string, string?][]): void; + value(typeName: string, arg1: [string, string, string?][] | string, value?: string, description?: string) { + if (typeof arg1 === 'string') arg1 = [[arg1, value!, description]]; + for (const [k, v, d] of arg1) { + if (!types[typeName]) types[typeName] = { [k]: v }; + else types[typeName][k] = v; + if (d) { + if (!descriptions[typeName]) descriptions[typeName] = { [k]: d }; + else descriptions[typeName][k] = d; + } + } + this.rebuild(); + } + + resolver( + typeName: string, key: string, value: string, + func: (args: any, ctx: ApiContext, info: any) => any, + description?: string, + ) { + this.value(typeName, key, value, description); + const wrappedFunc = async (arg, ctx, info) => { + const res = await func(arg, ctx, info); + if (typeof res !== 'object' || res === null) return res; + let node = value.includes('!') ? value.split('!')[0] : value; + const isArray = node.includes('['); + node = node.replace('[', '').replace(']', ''); + if (handlers[node]) { + if (!isArray) Object.assign(res, handlers[node]); + else if (res instanceof Array) for (const i of res) { Object.assign(i, handlers[node]); } + } + ctx.parent = res; + return res; + }; + handlers[typeName] ||= {}; + handlers[typeName][key.split('(')[0].trim()] = wrappedFunc; + this.rebuild(); + } + + union(typeName: string, ...unionTypes: string[]) { + unions[typeName] = unionTypes.join(' | '); + this.rebuild(); + } +} + +export const sideEffect = true; +export const using = ['server']; + +export function apply(ctx: Context) { + ctx.plugin(ApiService); + ctx.Route('api', '/api', ApiHandler); +}