core&ui: drop graphql apis

This commit is contained in:
undefined authored and GitHub committed 2025-04-29 15:02:20 +01:00
1 parent 0f7961d675
commit 6e16d03f67
52 files changed
+485 -583

No files matched your search

+1 -4
View File
@@ -18,7 +18,6 @@
"@aws-sdk/s3-request-presigner": "3.726.1",
"@babel/code-frame": "^7.26.2",
"@cordisjs/plugin-timer": "1.1.1",
"@graphql-tools/schema": "^10.0.23",
"@hydrooj/common": "workspace:^",
"@hydrooj/framework": "workspace:^",
"@hydrooj/utils": "workspace:^",
@@ -32,8 +31,6 @@
"emoji-regex": "^10.4.0",
"emojis-list": "2.1.0",
"fs-extra": "^11.3.0",
"graphql": "^16.10.0",
"graphql-scalars": "^1.24.2",
"js-yaml": "^4.1.0",
"koa-proxies": "^0.12.4",
"koa-static-cache": "^5.1.4",
@@ -41,7 +38,7 @@
"lru-cache": "^10.4.3",
"mime-types": "^3.0.1",
"moment-timezone": "^0.5.48",
"mongodb": "^6.15.0",
"mongodb": "^6.16.0",
"mongodb-uri": "^0.9.7",
"nanoid": "^5.1.5",
"nodemailer": "^6.10.1",
-1
View File
@@ -49,7 +49,6 @@ export async function apply(ctx: Context) {
resolve(c);
});
});
await ctx.plugin(require('../service/api'));
require('../lib/index');
ctx.plugin(require('../service/monitor'));
-27
View File
@@ -742,33 +742,6 @@ 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',
);
});
ctx.plugin(ScoreboardService);
ctx.inject(['scoreboard'], ({ Route, scoreboard }) => {
Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
+39 -30
View File
@@ -1,6 +1,7 @@
import { load } from 'js-yaml';
import { Dictionary } from 'lodash';
import moment from 'moment-timezone';
import Schema from 'schemastery';
import type { Context } from '../context';
import {
CannotDeleteSystemDomainError, DomainJoinAlreadyMemberError, DomainJoinForbiddenError, ForbiddenError,
@@ -17,7 +18,7 @@ import { DOMAIN_SETTINGS, DOMAIN_SETTINGS_BY_KEY } from '../model/setting';
import * as system from '../model/system';
import user from '../model/user';
import {
Handler, param, post, query, requireSudo, Types,
Handler, Mutation, param, post, Query, query, requireSudo, Types,
} from '../service/server';
import { log2 } from '../utils';
@@ -412,6 +413,42 @@ class DomainSearchHandler extends Handler {
}
}
export const DomainApi = {
domain: Query(
Schema.object({
id: Schema.string(),
}),
async (ctx, args) => {
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;
return ddoc;
},
),
'domain.group': Mutation(
Schema.object({
name: Schema.string().required(),
uids: Schema.array(Schema.number()),
}),
async (ctx, args) => {
if (!ctx.user.hasPerm(PERM.PERM_EDIT_DOMAIN)) throw new PermissionError(PERM.PERM_EDIT_DOMAIN);
if (args.uids) {
const res = await user.updateGroup(ctx.domain._id, args.name, args.uids);
return res.upsertedCount > 0;
}
const res = await user.delGroup(ctx.domain._id, args.name);
return res.deletedCount > 0;
},
),
} as const;
declare module '@hydrooj/framework' {
interface Apis {
domain: typeof DomainApi;
}
}
export async function apply(ctx: Context) {
ctx.Route('ranking', '/ranking', DomainRankHandler, PERM.PERM_VIEW_RANKING);
ctx.Route('domain_dashboard', '/domain/dashboard', DomainDashboardHandler);
@@ -424,34 +461,6 @@ export async function apply(ctx: Context) {
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,
);
api.provide(DomainApi);
});
}
+5 -2
View File
@@ -321,9 +321,12 @@ const allPriv = Math.sum(Object.values(Priv));
class SystemUserPrivHandler extends SystemHandler {
@requireSudo
async get() {
@param('extraIgnore', Types.NumericArray, true)
async get({ }, extraIgnore: number[] = []) {
const defaultPriv = system.get('default.priv');
const udocs = await user.getMulti({ _id: { $gte: -1000, $ne: 1 }, priv: { $nin: [0, defaultPriv] } }).limit(1000).sort({ _id: 1 }).toArray();
const udocs = await user.getMulti({
_id: { $gte: -1000, $ne: 1 }, priv: { $nin: [0, defaultPriv, ...extraIgnore] },
}).limit(1000).sort({ _id: 1 }).toArray();
const banudocs = await user.getMulti({ _id: { $gte: -1000, $ne: 1 }, priv: 0 }).limit(1000).sort({ _id: 1 }).toArray();
this.response.body = {
udocs: [...udocs, ...banudocs],
+35 -60
View File
@@ -8,6 +8,7 @@ import {
import { Filter, ObjectId } from 'mongodb';
import { nanoid } from 'nanoid';
import sanitize from 'sanitize-filename';
import Schema from 'schemastery';
import parser from '@hydrooj/utils/lib/search';
import { sortFiles, streamToBuffer } from '@hydrooj/utils/lib/utils';
import type { Context } from '../context';
@@ -34,7 +35,7 @@ import storage from '../model/storage';
import * as system from '../model/system';
import user from '../model/user';
import {
Handler, param, post, query, route, Types,
Handler, param, post, Query, query, route, Types,
} from '../service/server';
import { ContestDetailBaseHandler } from './contest';
@@ -1021,6 +1022,38 @@ export class ProblemCreateHandler extends Handler {
}
}
export const ProblemApi = {
problem: Query(
Schema.object({
id: Schema.union([Schema.number().step(1), Schema.string()]).required(),
domainId: Schema.string().required(),
}),
async (ctx, args) => {
const pdoc = await problem.get(args.domainId, args.id);
if (!pdoc) return null;
if (pdoc.hidden) ctx.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN);
return pdoc;
},
),
problems: Query(
Schema.object({
ids: Schema.array(Schema.number().step(1)).required(),
domainId: Schema.string().required(),
}),
async (ctx, args) => {
const pdocs = await problem.getList(args.domainId, args.ids, ctx.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || ctx.user._id,
undefined, undefined, true);
return Object.keys(pdocs).map((id) => pdocs[+id]);
},
),
} as const;
declare module '@hydrooj/framework' {
interface Apis {
problem: typeof ProblemApi;
}
}
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);
@@ -1038,64 +1071,6 @@ export async function apply(ctx: Context) {
ctx.Route('problem_statistics', '/p/:pid/stat', ProblemStatisticsHandler, PERM.PERM_VIEW_PROBLEM);
ctx.Route('problem_create', '/problem/create', ProblemCreateHandler, PERM.PERM_CREATE_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),
);
api.provide(ProblemApi);
});
}
+64 -56
View File
@@ -2,6 +2,7 @@ import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@si
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import moment from 'moment-timezone';
import type { Binary } from 'mongodb';
import Schema from 'schemastery';
import type { Context } from '../context';
import {
AuthOperationError, BadRequestError, BlacklistedError, BuiltinLoginError,
@@ -25,7 +26,7 @@ import * as system from '../model/system';
import token from '../model/token';
import user, { deleteUserCache } from '../model/user';
import {
Handler, param, post, Types,
Handler, param, post, Query, Types,
} from '../service/server';
async function successfulAuth(this: Handler, udoc: User) {
@@ -515,6 +516,67 @@ class ContestModeHandler extends Handler {
export const inject = ['oauth'];
const UserApi = {
user: Query(Schema.object({
id: Schema.number().step(1),
uname: Schema.string(),
mail: Schema.string(),
domainId: Schema.string().required(),
}), (c, arg) => {
if (arg.id) return user.getById(arg.domainId, arg.id);
if (arg.mail) return user.getByEmail(arg.domainId, arg.mail);
if (arg.uname) return user.getByUname(arg.domainId, arg.uname);
return c.user;
}),
users: Query(Schema.object({
ids: Schema.array(Schema.number().step(1)),
auto: Schema.array(Schema.string()),
search: Schema.string(),
limit: Schema.number().step(1),
exact: Schema.boolean(),
}), async (c, arg) => {
const auto = (arg.ids?.length && arg.ids) || arg.auto || [];
if (auto.length) {
const maybeId = auto.filter((i) => !Number.isNaN(+i));
const result = [];
if (maybeId.length) {
const udocs = await user.getList(arg.domainId, maybeId.map((i) => +i));
for (const i in udocs) udocs[i].avatarUrl = avatar(udocs[i].avatar);
result.push(...Object.values(udocs));
}
const notFound = auto.filter((i) => !result.find((j) => j._id === +i));
if (notFound.length > 50) return result; // reject if too many
for (const i of notFound) {
// eslint-disable-next-line no-await-in-loop
const udoc = await user.getByUname(arg.domainId, i.toString()) || await user.getByEmail(arg.domainId, i.toString());
if (udoc) result.push(udoc);
}
return result;
}
if (!arg.search) return [];
const udoc = await user.getById(arg.domainId, +arg.search)
|| await user.getByUname(arg.domainId, arg.search)
|| await user.getByEmail(arg.domainId, arg.search);
const udocs: User[] = arg.exact
? []
: await user.getPrefixList(arg.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;
}),
} as const;
declare module '@hydrooj/framework' {
interface Apis {
user: typeof UserApi;
}
}
export async function apply(ctx: Context) {
ctx.Route('user_login', '/login', UserLoginHandler);
ctx.Route('user_oauth', '/oauth/:type', OauthHandler);
@@ -544,60 +606,6 @@ export async function apply(ctx: Context) {
},
});
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'],
['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], auto: [String], search: String, limit: Int, exact: Boolean)', '[User]', async (arg, c) => {
const auto = arg.ids || arg.auto || [];
if (auto.length) {
const maybeId = auto.filter((i) => !Number.isNaN(+i));
const result = [];
if (maybeId.length) {
const udocs = await user.getList(c.args.domainId, maybeId.map((i) => +i));
for (const i in udocs) udocs[i].avatarUrl = avatar(udocs[i].avatar);
result.push(...Object.values(udocs));
}
const notFound = auto.filter((i) => !result.find((j) => j._id === +i));
if (notFound.length > 50) return result; // reject if too many
for (const i of notFound) {
// eslint-disable-next-line no-await-in-loop
const udoc = await user.getByUname(c.args.domainId, i) || await user.getByEmail(c.args.domainId, i);
if (udoc) result.push(udoc);
}
return result;
}
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.');
api.provide(UserApi);
});
}
+1 -1
View File
@@ -784,7 +784,7 @@ export async function add(
RULES[rule].check(data);
await bus.parallel('contest/before-add', data);
const docId = await document.add(domainId, content, owner, document.TYPE_CONTEST, null, null, null, {
...data, title, rule, beginAt, endAt, pids, attend: 0, rated,
assign: [], ...data, title, rule, beginAt, endAt, pids, attend: 0, rated,
});
await bus.parallel('contest/add', data, docId);
return docId;
+6 -1
View File
@@ -1,12 +1,13 @@
/* eslint-disable max-len */
/* eslint-disable no-await-in-loop */
import fs from 'fs';
import * as cordis from 'cordis';
import yaml from 'js-yaml';
import { Dictionary } from 'lodash';
import moment from 'moment-timezone';
import Schema from 'schemastery';
import { LangConfig, parseLang } from '@hydrooj/common';
import { retry } from '@hydrooj/utils';
import { findFileSync, retry } from '@hydrooj/utils';
import { Context, Service } from '../context';
import { Setting as _Setting } from '../interface';
import { Logger } from '../logger';
@@ -14,6 +15,8 @@ import * as builtin from './builtin';
type SettingDict = Dictionary<_Setting>;
const settingFile = yaml.load(fs.readFileSync(findFileSync('hydrooj/setting.yaml'), 'utf-8')) as any;
const logger = new Logger('model/setting');
const countries = moment.tz.countries();
const tzs = new Set();
@@ -344,6 +347,8 @@ SystemSetting(
Setting('setting_basic', 'pagination.solution', 20, 'number', 'pagination.solution', 'Solutions per page'),
Setting('setting_basic', 'pagination.training', 10, 'number', 'pagination.training', 'Trainings per page'),
Setting('setting_basic', 'pagination.reply', 50, 'number', 'pagination.reply', 'Replies per page'),
Setting('setting_basic', 'hydrooj.homepage', settingFile.homepage.default, 'yaml', 'hydrooj.homepage', 'Homepage config'),
Setting('setting_basic', 'hydrooj.langs', settingFile.langs.default, 'yaml', 'hydrooj.langs', 'Language config'),
Setting('setting_session', 'session.keys', [String.random(32)], 'text', 'session.keys', 'session.keys', FLAG_HIDDEN),
Setting('setting_session', 'session.domain', '', 'text', 'session.domain', 'session.domain', FLAG_HIDDEN),
Setting('setting_session', 'session.saved_expire_seconds', 3600 * 24 * 30,
-1
View File
@@ -55,4 +55,3 @@ export { db, pwsh };
// to load services into to context
export { } from './handler/contest';
export { } from './service/api';
-202
View File
@@ -1,202 +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, Service } from '../context';
import { PERM, PRIV } from '../model/builtin';
import { Handler } from './server';
const types: Record<string, Record<string, string>> = Object.create(null);
const unions: Record<string, string> = Object.create(null);
const descriptions: Record<string, Record<string, string>> = Object.create(null);
const handlers: Record<string, Record<string, any>> = {
Query: Object.create(null),
};
const root: Record<string, any> = handlers.Query;
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 = '';
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';
// FIXME: adding PERM_VIEW check will break omnibar search
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);
}
}
// TODO support dispose
class ApiService extends Service {
constructor(ctx: Context) {
super(ctx, 'api');
this.rebuild = debounce(this.rebuild.bind(this), 500, { trailing: true });
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();
}
query(query: string, args: Record<string, any>) {
return graphql({
schema,
source: query,
rootValue: root,
contextValue: this,
variableValues: args,
});
}
}
export const sideEffect = true;
export const inject = ['server', 'setting'];
export function apply(ctx: Context) {
ctx.plugin(ApiService);
ctx.Route('api', '/api', ApiHandler);
}
+4 -1
View File
@@ -4,7 +4,7 @@ import fs from 'fs-extra';
import cache from 'koa-static-cache';
import { type FindCursor, ObjectId } from 'mongodb';
import {
ConnectionHandler as ConnectionHandlerOriginal,
applyApiHandler, ConnectionHandler as ConnectionHandlerOriginal,
Handler as HandlerOriginal, HydroError, NotFoundError, UserFacingError,
WebService,
} from '@hydrooj/framework';
@@ -41,6 +41,7 @@ declare module '@hydrooj/framework' {
export * from '@hydrooj/framework/decorators';
export * from '@hydrooj/framework/validator';
export { Query, Mutation } from '@hydrooj/framework/api';
/*
* For security concern, some API requires sudo privilege to access.
@@ -106,6 +107,8 @@ export async function apply(ctx: Context) {
}
});
applyApiHandler(ctx, 'api', '/api/:op');
for (const addon of [...Object.values(global.addons)].reverse()) {
const dir = resolve(addon, 'public');
if (!fs.existsSync(dir)) continue;