mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
core: make API a service
This commit is contained in:
8 files changed
+388
-357
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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<string, Record<string, string>> = {};
|
||||
const unions: Record<string, string> = {};
|
||||
const descriptions: Record<string, Record<string, string>> = {};
|
||||
const handlers: Record<string, Record<string, any>> = {
|
||||
Query: {},
|
||||
};
|
||||
let root: Record<string, any> = {};
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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<ProblemDoc> = {};
|
||||
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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, Record<string, string>> = {};
|
||||
const unions: Record<string, string> = {};
|
||||
const descriptions: Record<string, Record<string, string>> = {};
|
||||
const handlers: Record<string, Record<string, any>> = {
|
||||
Query: {},
|
||||
};
|
||||
let root: Record<string, any> = {};
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user