From 6e16d03f67ee075ddcc1d7aa277912b88cbd5513 Mon Sep 17 00:00:00 2001 From: undefined Date: Tue, 29 Apr 2025 22:02:20 +0800 Subject: [PATCH] core&ui: drop graphql apis --- eslint.config.mjs | 3 + framework/eslint-config/package.json | 8 +- framework/framework/api.ts | 172 +++++++++++++++ framework/framework/index.ts | 1 + framework/framework/interface.ts | 8 +- framework/framework/server.ts | 16 +- framework/framework/tests/projection.spec.ts | 46 ++++ package.json | 8 +- .../frontend/autocomplete/autocomplete.scss | 1 - packages/components/package.json | 6 +- packages/elastic/package.json | 2 +- packages/hydrooj/package.json | 5 +- packages/hydrooj/src/entry/worker.ts | 1 - packages/hydrooj/src/handler/contest.ts | 27 --- packages/hydrooj/src/handler/domain.ts | 69 +++--- packages/hydrooj/src/handler/manage.ts | 7 +- packages/hydrooj/src/handler/problem.ts | 95 +++----- packages/hydrooj/src/handler/user.ts | 120 ++++++----- packages/hydrooj/src/model/contest.ts | 2 +- packages/hydrooj/src/model/setting.ts | 7 +- packages/hydrooj/src/plugin-api.ts | 1 - packages/hydrooj/src/service/api.ts | 202 ------------------ packages/hydrooj/src/service/server.ts | 5 +- packages/migrate/package.json | 4 +- packages/migrate/scripts/syzoj.ts | 12 +- packages/ui-default/build/config/webpack.ts | 4 +- .../components/ProblemSelectAutoComplete.tsx | 10 +- .../components/UserSelectAutoComplete.tsx | 19 +- .../MessagePadDialogueContentContainer.jsx | 1 + .../MessagePadDialogueListContainer.jsx | 1 + .../messagepad/MessagePadInputContainer.jsx | 1 + .../components/messagepad/index.jsx | 1 + .../components/monaco/languages/markdown.ts | 20 +- .../components/omnisearch/index.page.tsx | 11 +- .../problemconfig/tree/AddTestcase.tsx | 4 +- .../scratchpad/ScratchpadEditorContainer.tsx | 1 + .../scratchpad/ScratchpadPretestContainer.jsx | 1 + .../scratchpad/ScratchpadRecordsContainer.jsx | 1 + .../ScratchpadRecordsRowContainer.jsx | 1 + .../scratchpad/ScratchpadToolbarContainer.jsx | 1 + .../components/zipDownloader/index.ts | 34 ++- packages/ui-default/package.json | 9 +- packages/ui-default/pages/api.page.styl | 10 - packages/ui-default/pages/api.page.tsx | 29 --- .../ui-default/pages/domain_group.page.ts | 21 +- .../ui-default/pages/home_messages.page.tsx | 14 +- packages/ui-default/templates/api.html | 10 - .../ui-default/templates/partials/footer.html | 1 - packages/ui-default/templates/ranking.html | 4 +- packages/ui-default/utils/index.ts | 27 +-- packages/ui-next/package.json | 2 +- packages/utils/package.json | 2 +- 52 files changed, 485 insertions(+), 583 deletions(-) create mode 100644 framework/framework/api.ts create mode 100644 framework/framework/tests/projection.spec.ts delete mode 100644 packages/hydrooj/src/service/api.ts delete mode 100644 packages/ui-default/pages/api.page.tsx delete mode 100644 packages/ui-default/templates/api.html diff --git a/eslint.config.mjs b/eslint.config.mjs index 3df38c2da..e4e7e6bcc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -86,6 +86,9 @@ export default defineConfig([globalIgnores([ }, settings: { + 'react-x': { + version: '18.3.1', + }, 'import/resolver': { webpack: { config: { diff --git a/framework/eslint-config/package.json b/framework/eslint-config/package.json index 7dddc1cf3..ed5c54add 100644 --- a/framework/eslint-config/package.json +++ b/framework/eslint-config/package.json @@ -4,19 +4,19 @@ "license": "MIT", "main": "base.mjs", "dependencies": { - "@eslint-react/eslint-plugin": "^1.48.4", + "@eslint-react/eslint-plugin": "^1.48.5", "@eslint/compat": "^1.2.8", "@eslint/eslintrc": "^3.3.1", "@stylistic/eslint-plugin": "^4.2.0", - "@typescript-eslint/eslint-plugin": "^8.30.1", - "@typescript-eslint/parser": "^8.30.1", + "@typescript-eslint/eslint-plugin": "^8.31.0", + "@typescript-eslint/parser": "^8.31.0", "eslint-config-airbnb": "^19.0.4", "eslint-plugin-de-morgan": "^1.2.1", "eslint-plugin-github": "^6.0.0", "eslint-plugin-import": "2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", + "eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-simple-import-sort": "^12.1.1", "globals": "^16.0.0" }, diff --git a/framework/framework/api.ts b/framework/framework/api.ts new file mode 100644 index 000000000..20cb73ede --- /dev/null +++ b/framework/framework/api.ts @@ -0,0 +1,172 @@ +import { Context, Service } from 'cordis'; +import Schema from 'schemastery'; +import { param } from './decorators'; +import { BadRequestError, MethodNotAllowedError, NotFoundError } from './error'; +import { } from './interface'; +import { Handler } from './server'; +import { Types } from './validator'; + +const BINARY = Symbol.for('hydro.api.response.binary'); +const REDIRECT = Symbol.for('hydro.api.response.redirect'); + +export type ApiCall = { + readonly type: 'Query' | 'Mutation'; + readonly input: Schema; + readonly func: (context: any, args: Arg) => Promise | Res; + readonly hooks: ApiCall[]; +}; + +export const _get = (type: 'Query' | 'Mutation') => ( + schema: Schema, + func: (context: Handler, args: Arg) => Promise | Res, + hooks: ApiCall[] = [], +): ApiCall => ({ input: schema, func, hooks, type } as const); // eslint-disable-line + +export const Query = _get('Query'); +export const Mutation = _get('Mutation'); + +export class BinaryResponse { + [BINARY]: true; + constructor(public readonly data: Buffer, public filename: string) { } + static check(value: any): value is BinaryResponse { + return value && typeof value === 'object' && BINARY in value && value[BINARY] === true; + } +} + +export class RedirectResponse { + [REDIRECT]: true; + constructor(public readonly url: string) { } + static check(value: any): value is RedirectResponse { + return value && typeof value === 'object' && REDIRECT in value && value[REDIRECT] === true; + } +} + +/** @deprecated TODO */ +export const NOP = Query(Schema.any(), () => { }); + +export const APIS = { + 'query.batch': Query(Schema.array(Schema.object({ op: Schema.string(), args: Schema.any() })), () => ({})), + 'mutation.batch': Mutation(Schema.array(Schema.object({ op: Schema.string(), args: Schema.any() })), () => ({})), +} as const; +export interface Apis { + builtin: { + 'query.batch': ApiCall<{ op: string, args: any }[], { [key: string]: any }>; + 'mutation.batch': ApiCall<{ op: string, args: any }[], { [key: string]: any }>; + } +} +export type FlattenedApis = Apis[keyof Apis]; + +// Thanks to @ForkKILLET for the projection function +type ProjectionSchemaId = 1; +type MKeyOf = T extends any ? keyof T : never; +type MId = { [K in MKeyOf]: T[K] } & {}; +type ProjectionSchema = T extends Array + ? ProjectionSchema + : | { [K in keyof T]?: ProjectionSchemaId | ProjectionSchema } + | Record; +type AsKeys = T extends Array ? Record : T; +type Projection = S extends ProjectionSchemaId + ? T : T extends Array ? Array>> : { + [K in keyof T & keyof S]: K extends keyof AsKeys ? Projection[K]> : never + }; + +export const projection = >(input: T, schema: S): Projection => { + if (typeof input !== 'object' || input === null) throw new Error('Input must be an object.'); + type R = Projection; + if (Array.isArray(schema)) schema = Object.fromEntries(schema.map((s) => [s, 1])) as S; + if (Array.isArray(input)) { + return input.map((item) => projection(item, schema)) as R; + } + const result = {} as R; + for (const key of Reflect.ownKeys(input)) { + const schemaIt = schema[key]; + if (!schemaIt) continue; + if (schemaIt === 1) result[key] = input[key]; + else result[key] = projection(input[key], schemaIt); + } + return result; +}; + +export interface ApiExecutionContext { +} + +export class ApiService extends Service { + constructor(ctx: Context) { + super(ctx, 'api'); + } + + provide(calls: Partial) { + this.ctx.effect(() => { + for (const key in calls) { + APIS[key] = calls[key]; + } + return () => { + for (const key in calls) { + delete APIS[key]; + } + }; + }); + } + + async execute(context: ApiExecutionContext, callOrName: ApiCall | string, rawArgs: any, emitHook?: any, project?: any) { + const call = typeof callOrName === 'string' ? APIS[callOrName] : callOrName; + if (!call) throw new NotFoundError(callOrName); + const { input, func, hooks } = call; + // eslint-disable-next-line no-await-in-loop + for (const hook of hooks) await this.execute(context, hook, rawArgs); + + let args: any; + try { + args = input ? input(rawArgs as any) : rawArgs; + } catch (e) { + throw new BadRequestError(e.message); + } + if (typeof callOrName === 'string') { + await emitHook?.('api/before', args); + await emitHook?.(`api/before/${callOrName}`, args); + } + const result = await func(context, args as any); + return project ? projection(result, project) : result; + } +} + +declare module 'cordis' { + interface Context { + api: ApiService; + } +} + +export class ApiHandler extends Handler { + @param('op', Types.String) + async all({ }, op: string) { + if (!['get', 'post'].includes(this.request.method.toLowerCase())) { + throw new MethodNotAllowedError(this.request.method); + } + if (!APIS[op]) throw new BadRequestError('Invalid operation'); + if (APIS[op].type === 'Mutation' && this.request.method.toLowerCase() === 'get') { + throw new BadRequestError('Mutation operation cannot be called with GET method'); + } + // @ts-ignore + await this.ctx.parallel('handler/api/before', this); + // @ts-ignore + await this.ctx.parallel(`handler/api/before/${op}`, this); + const result = await this.ctx.api.execute( + this, op, { domainId: this.args.domainId, ...(this.args.args || {}) }, + (m, args) => (this.ctx.parallel as any)(m, args), this.args.projection, + ); + if (BinaryResponse.check(result)) { + this.binary(result.data, result.filename); + } else if (RedirectResponse.check(result)) { + this.response.redirect = result.url; + } else { + this.response.body = result; + } + } +} + +export function applyApiHandler(ctx: Context, name: string, path: string) { + ctx.plugin(ApiService); + ctx.inject(['server', 'api'], ({ Route }) => { + Route(name, path, ApiHandler); + }); +} diff --git a/framework/framework/index.ts b/framework/framework/index.ts index 51c500287..154afd26d 100644 --- a/framework/framework/index.ts +++ b/framework/framework/index.ts @@ -5,3 +5,4 @@ export * from './validator'; export * from './error'; export * from './router'; export * from './interface'; +export * from './api'; diff --git a/framework/framework/interface.ts b/framework/framework/interface.ts index 05a84c71c..1085416f9 100644 --- a/framework/framework/interface.ts +++ b/framework/framework/interface.ts @@ -1,3 +1,5 @@ +import type { Context } from 'cordis'; +import type { ApiHandler } from './api'; import type { ConnectionHandler, Handler, NotFoundHandler } from './server'; export interface KnownHandlers { @@ -21,16 +23,20 @@ export type HookType = 'before-prepare' | 'before' | 'before-operation' | 'after export type Methods = 'get' | 'post' | 'put' | 'delete' | 'patch'; export type HookWithMethod = Exclude; -export interface ServerEvents extends KnownHandlerEvents, HandlerEvents { +export interface ServerEvents extends KnownHandlerEvents, HandlerEvents { 'handler/create': (thisArg: Handler | ConnectionHandler, type: 'ws' | 'http') => VoidReturn 'handler/create/http': (thisArg: Handler) => VoidReturn 'handler/create/ws': (thisArg: ConnectionHandler) => VoidReturn 'handler/init': (thisArg: Handler) => VoidReturn 'handler/error': (thisArg: Handler | ConnectionHandler, e: Error) => VoidReturn + 'handler/api/before': (thisArg: ApiHandler) => VoidReturn + 'api/before': (args: any) => VoidReturn [k: `handler/${HookType}/${string}`]: (thisArg: any) => VoidReturn [k: `handler/${HookWithMethod}/${string}/${Methods}`]: (thisArg: any) => VoidReturn [k: `handler/register/${string}`]: (HandlerClass: new (...args: any[]) => any) => VoidReturn [k: `handler/error/${string}`]: (thisArg: Handler, e: Error) => VoidReturn + [k: `handler/api/before/${string}`]: (thisArg: ApiHandler) => VoidReturn + [k: `api/before/${string}`]: (args: any) => VoidReturn } declare module 'cordis' { diff --git a/framework/framework/server.ts b/framework/framework/server.ts index f3d065259..e5388407a 100644 --- a/framework/framework/server.ts +++ b/framework/framework/server.ts @@ -502,16 +502,18 @@ ${c.response.status} ${endTime - startTime}ms ${c.response.length}`); await (this.ctx.parallel as any)('handler/create/http', h); if (checker) checker.call(h); - if (method === 'post') { - if (operation) { - if (typeof h[`post${operation}`] !== 'function') { - throw new InvalidOperationError(operation); + if (typeof h.all !== 'function') { + if (method === 'post') { + if (operation) { + if (typeof h[`post${operation}`] !== 'function') { + throw new InvalidOperationError(operation); + } + } else if (typeof h.post !== 'function') { + throw new MethodNotAllowedError(method); } - } else if (typeof h.post !== 'function') { + } else if (typeof h[method] !== 'function') { throw new MethodNotAllowedError(method); } - } else if (typeof h[method] !== 'function' && typeof h.all !== 'function') { - throw new MethodNotAllowedError(method); } const steps = [ diff --git a/framework/framework/tests/projection.spec.ts b/framework/framework/tests/projection.spec.ts new file mode 100644 index 000000000..24e94deb2 --- /dev/null +++ b/framework/framework/tests/projection.spec.ts @@ -0,0 +1,46 @@ +import { expect } from 'chai'; +import { describe, it } from 'node:test'; +import { projection } from '../api'; + +const input = { + foo: 1, + bar: 'string', + arr: [ + { sub: 1, another: 'key' }, + { sub: 2 }, + ], + obj: { + sub: 3, + }, +}; + +describe('projection', () => { + it('pick', () => { + expect(projection(input, { foo: 1, bar: 1 })).to.deep.equal({ foo: 1, bar: 'string' }); + }); + it('array', () => { + expect(projection(input, { arr: { sub: 1 } })).to.deep.equal({ arr: [{ sub: 1 }, { sub: 2 }] }); + }); + it('non-exist', () => { + expect(projection(input, { 'non-exist': 1 })).to.deep.equal({}); + }); + it('partial', () => { + expect(projection(input, { arr: { sub: 1, another: 1 } })).to.deep.equal({ arr: [{ sub: 1, another: 'key' }, { sub: 2 }] }); + }); + it('object', () => { + expect(projection(input, { obj: { sub: 1 } })).to.deep.equal({ obj: { sub: 3 } }); + }); +}); +describe('safety', () => { + it('prototype', () => { + // eslint-disable-next-line + expect(projection({}, { __proto__: 1 })).to.deep.equal({}); + expect(projection({}, { prototype: 1 })).to.deep.equal({}); + }); + it('circular', () => { + const circular = {}; + // @ts-ignore + circular.circular = circular; + expect(projection(circular, { circular: 1 })).to.deep.equal({ circular }); + }); +}); diff --git a/package.json b/package.json index 8fa3b320a..d329c906f 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,8 @@ "cross-env": "^7.0.3", "cross-spawn": "^7.0.6", "esbuild": "0.25.2", - "eslint": "^9.25.0", - "eslint-import-resolver-typescript": "^4.3.3", + "eslint": "^9.25.1", + "eslint-import-resolver-typescript": "^4.3.4", "eslint-import-resolver-webpack": "^0.13.10", "fs-extra": "^11.3.0", "globals": "^16.0.0", @@ -53,7 +53,7 @@ "mongodb-memory-server": "10.1.4", "nyc": "^17.1.0", "ora": "^8.2.0", - "oxlint": "^0.16.6", + "oxlint": "^0.16.8", "package-json": "^10.0.1", "semver": "^7.7.1", "simple-git": "^3.27.0", @@ -62,5 +62,5 @@ "typescript": "5.8.3", "wtfnode": "^0.10.0" }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + "packageManager": "yarn@4.9.1" } diff --git a/packages/components/frontend/autocomplete/autocomplete.scss b/packages/components/frontend/autocomplete/autocomplete.scss index db8e6ba90..c05c94436 100644 --- a/packages/components/frontend/autocomplete/autocomplete.scss +++ b/packages/components/frontend/autocomplete/autocomplete.scss @@ -43,7 +43,6 @@ height: 30px; box-sizing: border-box; padding: 4px 6px; - width: 100%; min-width: 30px; flex-grow: 1; border: 0; diff --git a/packages/components/package.json b/packages/components/package.json index d0f935d07..ebced9015 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -3,6 +3,7 @@ "version": "1.0.0-beta.1", "main": "./frontend/index.ts", "dependencies": { + "allotment": "^1.20.3", "diff": "^7.0.0", "js-yaml": "^4.1.0", "lodash": "^4.17.21", @@ -15,6 +16,9 @@ "@types/diff": "^7.0.2", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.16", - "sass": "^1.86.3" + "sass": "^1.87.0" + }, + "peerDependencies": { + "schemastery": "*" } } diff --git a/packages/elastic/package.json b/packages/elastic/package.json index c9f73497f..fc2746da8 100644 --- a/packages/elastic/package.json +++ b/packages/elastic/package.json @@ -6,7 +6,7 @@ "author": "undefined ", "license": "AGPL-3.0-or-later", "dependencies": { - "@elastic/elasticsearch": "^8.18.0" + "@elastic/elasticsearch": "^8.18.2" }, "engines": { "hydrooj": ">=5.0.0-alpha.1" diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 5006b51dc..230b10f37 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -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", diff --git a/packages/hydrooj/src/entry/worker.ts b/packages/hydrooj/src/entry/worker.ts index 64c3f396c..20bc8f856 100644 --- a/packages/hydrooj/src/entry/worker.ts +++ b/packages/hydrooj/src/entry/worker.ts @@ -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')); diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 59b1c9970..a6edb2199 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -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); diff --git a/packages/hydrooj/src/handler/domain.ts b/packages/hydrooj/src/handler/domain.ts index e2b721592..85013ca08 100644 --- a/packages/hydrooj/src/handler/domain.ts +++ b/packages/hydrooj/src/handler/domain.ts @@ -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); }); } diff --git a/packages/hydrooj/src/handler/manage.ts b/packages/hydrooj/src/handler/manage.ts index f87b70bb2..8d6a71d20 100644 --- a/packages/hydrooj/src/handler/manage.ts +++ b/packages/hydrooj/src/handler/manage.ts @@ -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], diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 263acc987..b58c798c2 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -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); }); } diff --git a/packages/hydrooj/src/handler/user.ts b/packages/hydrooj/src/handler/user.ts index 4d22b8bf7..3a732b9fe 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 { 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); }); } diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index 4f2017d73..30c12de4e 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -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; diff --git a/packages/hydrooj/src/model/setting.ts b/packages/hydrooj/src/model/setting.ts index 6898cd6b5..a66c8d227 100644 --- a/packages/hydrooj/src/model/setting.ts +++ b/packages/hydrooj/src/model/setting.ts @@ -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, diff --git a/packages/hydrooj/src/plugin-api.ts b/packages/hydrooj/src/plugin-api.ts index 6c738d7d3..04fb0e604 100644 --- a/packages/hydrooj/src/plugin-api.ts +++ b/packages/hydrooj/src/plugin-api.ts @@ -55,4 +55,3 @@ export { db, pwsh }; // to load services into to context export { } from './handler/contest'; -export { } from './service/api'; diff --git a/packages/hydrooj/src/service/api.ts b/packages/hydrooj/src/service/api.ts deleted file mode 100644 index 33674d690..000000000 --- a/packages/hydrooj/src/service/api.ts +++ /dev/null @@ -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> = Object.create(null); -const unions: Record = Object.create(null); -const descriptions: Record> = Object.create(null); -const handlers: Record> = { - Query: Object.create(null), -}; -const root: Record = 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) { - 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); -} diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index 88e29e765..61f7da875 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -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; diff --git a/packages/migrate/package.json b/packages/migrate/package.json index 41d2bd6d0..f5c3c3d32 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -6,8 +6,8 @@ "author": "undefined ", "license": "AGPL-3.0-or-later", "dependencies": { - "mariadb": "^3.4.1", - "mongodb": "^6.15.0", + "mariadb": "^3.4.2", + "mongodb": "^6.16.0", "turndown": "^7.2.0", "xml2js": "^0.6.2" }, diff --git a/packages/migrate/scripts/syzoj.ts b/packages/migrate/scripts/syzoj.ts index 64cde8d9b..5f0966fa2 100644 --- a/packages/migrate/scripts/syzoj.ts +++ b/packages/migrate/scripts/syzoj.ts @@ -276,10 +276,16 @@ export async function run({ const pdocs = tdoc.problems.split('|').map((i) => i.trim()); const pids = pdocs.map((i) => pidMap[i]).filter((i) => i); const admin = uidMap[tdoc.holder_id] || uidMap[tdoc.admins.split('|')[0]]; + const startAt = new Date(tdoc.start_time * 1000); + let endAt = new Date(tdoc.end_time * 1000); + if (startAt >= endAt) { + report({ message: `Invalid contest time: ${tdoc.title} ${tdoc.start_time} ${tdoc.end_time}` }); + endAt = new Date(startAt.getTime() + 5000); + } const tid = await ContestModel.add( domainId, tdoc.title, `${tdoc.subtitle ? `#### ${tdoc.subtitle}\n` : ''}${tdoc.information || 'No Description'}`, - admin, contentTypeMap[tdoc.type], new Date(tdoc.start_time * 1000), new Date(tdoc.end_time * 1000), - pids, ratedTids.includes(tdoc.id), { maintainer: tdoc.admins.split('|').map((i) => uidMap[i]) }, + admin, contentTypeMap[tdoc.type], startAt, endAt, + pids, ratedTids.includes(tdoc.id), { maintainer: tdoc.admins.split('|').map((i) => uidMap[i]), assign: [] }, ); tidMap[tdoc.id] = tid.toHexString(); } @@ -461,7 +467,7 @@ export async function run({ `spj.${langMap[syzojConfig.specialJudge.language]}`, `${dataDir}/testdata/${file.name}/${syzojConfig.specialJudge.fileName}`); config.checker = `spj.${langMap[syzojConfig.specialJudge.language]}`; } - if (syzojConfig.subtasks) { + if (syzojConfig.subtasks && syzojConfig.inputFile && syzojConfig.outputFile) { config.subtasks = syzojConfig.subtasks.map((subtask, index) => ({ score: subtask.score, id: index + 1, diff --git a/packages/ui-default/build/config/webpack.ts b/packages/ui-default/build/config/webpack.ts index 8291899fe..720f8854a 100644 --- a/packages/ui-default/build/config/webpack.ts +++ b/packages/ui-default/build/config/webpack.ts @@ -134,7 +134,8 @@ export default async function (env: { watch?: boolean, production?: boolean, mea extensions: ['.js', '.jsx', '.ts', '.tsx', '.cjs'], alias: { vj: root(), - 'graphql-ws': root('../../modules/nop.ts'), + 'react/jsx-runtime': require.resolve('react/jsx-runtime'), + react: require.resolve('react'), }, }, module: { @@ -291,7 +292,6 @@ export default async function (env: { watch?: boolean, production?: boolean, mea { from: root('components/navigation/nav-logo-small_dark.png'), to: 'components/navigation/nav-logo-small_dark.png' }, { from: root(`${dirname(require.resolve('streamsaver/package.json'))}/mitm.html`), to: 'streamsaver/mitm.html' }, { from: root(`${dirname(require.resolve('streamsaver/package.json'))}/sw.js`), to: 'streamsaver/sw.js' }, - { from: root(`${dirname(require.resolve('graphiql/package.json'))}/graphiql.min.css`), to: 'graphiql.min.css' }, { from: `${dirname(require.resolve('monaco-themes/package.json'))}/themes`, to: 'monaco/themes/' }, ], }), diff --git a/packages/ui-default/components/autocomplete/components/ProblemSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/ProblemSelectAutoComplete.tsx index 7afd21e3a..c735f5f5e 100644 --- a/packages/ui-default/components/autocomplete/components/ProblemSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/ProblemSelectAutoComplete.tsx @@ -2,7 +2,7 @@ import { AutoComplete, AutoCompleteHandle, AutoCompleteProps } from '@hydrooj/co import type { ProblemDoc } from 'hydrooj/src/interface'; import PropTypes from 'prop-types'; import React, { forwardRef } from 'react'; -import { api, gql, request } from 'vj/utils'; +import { api, request } from 'vj/utils'; const ProblemSelectAutoComplete = forwardRef, AutoCompleteProps>((props, ref) => ( @@ -12,13 +12,7 @@ const ProblemSelectAutoComplete = forwardRef, Aut const { pdocs } = await request.get(`/d/${UiContext.domainId}/p`, { q: query, quick: true }); return pdocs; }} - fetchItems={(ids) => api(gql` - problems(ids: ${ids.map((i) => +i)}) { - docId - pid - title - } - `, ['data', 'problems'])} + fetchItems={(ids) => api('problems', { ids: ids.map((i) => +i) }, ['docId', 'pid', 'title'])} itemText={(pdoc) => `${`${pdoc.docId} ${pdoc.title}`}`} itemKey={(pdoc) => `${pdoc.docId || pdoc}`} renderItem={(pdoc) => ( diff --git a/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.tsx index 63ebb1134..04d50f0d8 100644 --- a/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.tsx @@ -2,27 +2,14 @@ import { AutoComplete, AutoCompleteHandle, AutoCompleteProps } from '@hydrooj/co import type { Udoc } from 'hydrooj/src/interface'; import PropTypes from 'prop-types'; import { forwardRef } from 'react'; -import { api, gql } from 'vj/utils'; +import { api } from 'vj/utils'; const UserSelectAutoComplete = forwardRef, AutoCompleteProps>((props, ref) => ( ref={ref as any} cacheKey="user" - queryItems={(query) => api(gql` - users(search: ${query}) { - _id - uname - displayName - avatarUrl - } - `, ['data', 'users'])} - fetchItems={(ids) => api(gql` - users(auto: ${ids}) { - _id - uname - displayName - } - `, ['data', 'users'])} + queryItems={(query) => api('users', { search: query }, ['_id', 'uname', 'displayName', 'avatarUrl'])} + fetchItems={(ids) => api('users', { auto: ids }, ['_id', 'uname', 'displayName'])} itemText={(user) => user.uname + (user.displayName ? ` (${user.displayName})` : '')} itemKey={(user) => ((props.multi || /^[+-]?\d+$/.test(user.uname.trim())) ? user._id.toString() : user.uname)} renderItem={(user) => ( diff --git a/packages/ui-default/components/messagepad/MessagePadDialogueContentContainer.jsx b/packages/ui-default/components/messagepad/MessagePadDialogueContentContainer.jsx index 9dad8fb3b..c7da4613e 100644 --- a/packages/ui-default/components/messagepad/MessagePadDialogueContentContainer.jsx +++ b/packages/ui-default/components/messagepad/MessagePadDialogueContentContainer.jsx @@ -16,6 +16,7 @@ const mapStateToProps = (state) => ({ : null, }); +// eslint-disable-next-line react-refresh/only-export-components export default connect(mapStateToProps)(class MessagePadDialogueContentContainer extends React.PureComponent { componentDidMount() { $(this.refs.list).scrollLock({ strict: true }); diff --git a/packages/ui-default/components/messagepad/MessagePadDialogueListContainer.jsx b/packages/ui-default/components/messagepad/MessagePadDialogueListContainer.jsx index e85732e40..6354efd96 100644 --- a/packages/ui-default/components/messagepad/MessagePadDialogueListContainer.jsx +++ b/packages/ui-default/components/messagepad/MessagePadDialogueListContainer.jsx @@ -21,6 +21,7 @@ const mapDispatchToProps = (dispatch) => ({ }, }); +// eslint-disable-next-line react-refresh/only-export-components export default connect(mapStateToProps, mapDispatchToProps)(class MessagePadDialogueListContainer extends React.PureComponent { componentDidMount() { $(this.refs.list).scrollLock({ strict: true }); diff --git a/packages/ui-default/components/messagepad/MessagePadInputContainer.jsx b/packages/ui-default/components/messagepad/MessagePadInputContainer.jsx index eda747021..369003a98 100644 --- a/packages/ui-default/components/messagepad/MessagePadInputContainer.jsx +++ b/packages/ui-default/components/messagepad/MessagePadInputContainer.jsx @@ -42,6 +42,7 @@ const mapDispatchToProps = (dispatch) => ({ }, }); +// eslint-disable-next-line react-refresh/only-export-components export default connect(mapStateToProps, mapDispatchToProps)(class MessagePadInputContainer extends React.PureComponent { static contextTypes = { store: PropTypes.object, diff --git a/packages/ui-default/components/messagepad/index.jsx b/packages/ui-default/components/messagepad/index.jsx index d3b0b176f..e41badeb2 100644 --- a/packages/ui-default/components/messagepad/index.jsx +++ b/packages/ui-default/components/messagepad/index.jsx @@ -16,6 +16,7 @@ const mapDispatchToProps = (dispatch) => ({ }, }); +// eslint-disable-next-line react-refresh/only-export-components export default connect(null, mapDispatchToProps)(class MessagePadContainer extends React.PureComponent { static propTypes = { onAdd: PropTypes.func.isRequired, diff --git a/packages/ui-default/components/monaco/languages/markdown.ts b/packages/ui-default/components/monaco/languages/markdown.ts index f3a57f61a..fdba4f840 100644 --- a/packages/ui-default/components/monaco/languages/markdown.ts +++ b/packages/ui-default/components/monaco/languages/markdown.ts @@ -2,7 +2,7 @@ import keyword from 'emojis-keywords'; import list from 'emojis-list'; import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import qface from 'qface'; -import { api, gql } from 'vj/utils'; +import { api } from 'vj/utils'; function emoji(range) { return keyword.map((i, index) => ({ @@ -40,12 +40,7 @@ monaco.languages.registerCodeLensProvider('markdown', { dispose: () => { }, }; } - const { data } = await api(gql` - users(ids: ${users.map((i) => +i.matches[1])}) { - _id - uname - } - `); + const data = await api('users', { ids: users.map((i) => +i.matches[1]) }, ['_id', 'uname']); return { lenses: users.map((i, index) => ({ range: i.range, @@ -53,7 +48,7 @@ monaco.languages.registerCodeLensProvider('markdown', { command: { id: 'hydro.openUserPage', arguments: [i.matches[1]], - title: `@${data.users.find((doc) => doc._id.toString() === i.matches[1])?.uname || i.matches[1]}`, + title: `@${data.find((doc) => doc._id.toString() === i.matches[1])?.uname || i.matches[1]}`, }, })), dispose: () => { }, @@ -82,14 +77,7 @@ monaco.languages.registerCompletionItemProvider('markdown', { endColumn: word.endColumn, }; if (prefix === '@') { - const users = await api(gql` - users(search: ${word.word}) { - _id - uname - avatarUrl - priv - } - `, ['data', 'users']); + const users = await api('users', { search: word.word }, ['_id', 'uname']); return { suggestions: users.map((i) => ({ label: { label: `@${i.uname}`, description: `UID=${i._id}` }, diff --git a/packages/ui-default/components/omnisearch/index.page.tsx b/packages/ui-default/components/omnisearch/index.page.tsx index 44276df93..2a9c3db09 100644 --- a/packages/ui-default/components/omnisearch/index.page.tsx +++ b/packages/ui-default/components/omnisearch/index.page.tsx @@ -4,7 +4,7 @@ import ReactDOM from 'react-dom/client'; import { STATUS_CODES } from 'vj/constant/record'; import { AutoloadPage } from 'vj/misc/Page'; import { - api, gql, i18n, request, + api, i18n, request, } from 'vj/utils'; export default new AutoloadPage('omnibar', () => { @@ -92,14 +92,7 @@ export default new AutoloadPage('omnibar', () => { setSearching?.(true); [{ pdocs, psdict }, udocs] = await Promise.all([ request.get(`/d/${UiContext.domainId}/p`, { q: query, limit: 10 }), - api(gql` - users(search: ${query}) { - _id - uname - displayName - avatarUrl - } - `, ['data', 'users']), + api('users', { search: query }, ['_id', 'uname', 'displayName', 'avatarUrl']), ]); setSearching?.(false); } diff --git a/packages/ui-default/components/problemconfig/tree/AddTestcase.tsx b/packages/ui-default/components/problemconfig/tree/AddTestcase.tsx index 351a7a063..f594ba1d5 100644 --- a/packages/ui-default/components/problemconfig/tree/AddTestcase.tsx +++ b/packages/ui-default/components/problemconfig/tree/AddTestcase.tsx @@ -17,8 +17,8 @@ export function AddTestcase() { const [autoOutput, setAutoOutput] = React.useState(true); const testdata = useSelector((state: RootState) => state.testdata); const store = useStore(); - const refInput = useRef(); - const refOutput = useRef(); + const refInput = useRef(null); + const refOutput = useRef(null); useEffect(() => { const inputValid = testdata.find((i) => i.name === input); diff --git a/packages/ui-default/components/scratchpad/ScratchpadEditorContainer.tsx b/packages/ui-default/components/scratchpad/ScratchpadEditorContainer.tsx index 6e2256092..e6e6e8563 100644 --- a/packages/ui-default/components/scratchpad/ScratchpadEditorContainer.tsx +++ b/packages/ui-default/components/scratchpad/ScratchpadEditorContainer.tsx @@ -13,6 +13,7 @@ interface ScratchpadOptions { commandDone?: () => void; } +// eslint-disable-next-line react-refresh/only-export-components export default connect((state: any) => ({ value: state.editor.code, language: window.LANGS[state.editor.lang]?.monaco, diff --git a/packages/ui-default/components/scratchpad/ScratchpadPretestContainer.jsx b/packages/ui-default/components/scratchpad/ScratchpadPretestContainer.jsx index 34ff8aa5b..04bd22857 100644 --- a/packages/ui-default/components/scratchpad/ScratchpadPretestContainer.jsx +++ b/packages/ui-default/components/scratchpad/ScratchpadPretestContainer.jsx @@ -27,6 +27,7 @@ const mapDispatchToProps = (dispatch) => ({ }, }); +// eslint-disable-next-line react-refresh/only-export-components export default connect(mapStateToProps, mapDispatchToProps)(class ScratchpadPretestContainer extends React.PureComponent { render() { return ( diff --git a/packages/ui-default/components/scratchpad/ScratchpadRecordsContainer.jsx b/packages/ui-default/components/scratchpad/ScratchpadRecordsContainer.jsx index 1f21cbfbe..aed7d9f86 100644 --- a/packages/ui-default/components/scratchpad/ScratchpadRecordsContainer.jsx +++ b/packages/ui-default/components/scratchpad/ScratchpadRecordsContainer.jsx @@ -11,6 +11,7 @@ const mapStateToProps = (state) => ({ isLoading: state.ui.records.isLoading, }); +// eslint-disable-next-line react-refresh/only-export-components export default connect(mapStateToProps)(class ScratchpadRecordsContainer extends React.PureComponent { render() { const cn = classNames('data-table is--full-row scratchpad__records__table', { diff --git a/packages/ui-default/components/scratchpad/ScratchpadRecordsRowContainer.jsx b/packages/ui-default/components/scratchpad/ScratchpadRecordsRowContainer.jsx index f44aab1c7..456917340 100644 --- a/packages/ui-default/components/scratchpad/ScratchpadRecordsRowContainer.jsx +++ b/packages/ui-default/components/scratchpad/ScratchpadRecordsRowContainer.jsx @@ -46,6 +46,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => ({ data: stateProps.items[ownProps.id], }); +// eslint-disable-next-line react-refresh/only-export-components export default connect(mapStateToProps, null, mergeProps)(class ScratchpadRecordsRowContainer extends React.PureComponent { handleRowClick(ev, id) { const url = substitute( diff --git a/packages/ui-default/components/scratchpad/ScratchpadToolbarContainer.jsx b/packages/ui-default/components/scratchpad/ScratchpadToolbarContainer.jsx index d659f9809..e722a2bb9 100644 --- a/packages/ui-default/components/scratchpad/ScratchpadToolbarContainer.jsx +++ b/packages/ui-default/components/scratchpad/ScratchpadToolbarContainer.jsx @@ -73,6 +73,7 @@ const mapDispatchToProps = (dispatch) => ({ const availableLangs = getAvailableLangs(UiContext.pdoc.config.langs); const keys = Object.keys(availableLangs); +// eslint-disable-next-line react-refresh/only-export-components export default connect(mapStateToProps, mapDispatchToProps)(class ScratchpadToolbarContainer extends React.PureComponent { static contextTypes = { store: PropTypes.object, diff --git a/packages/ui-default/components/zipDownloader/index.ts b/packages/ui-default/components/zipDownloader/index.ts index ca040fa01..a143ad89b 100644 --- a/packages/ui-default/components/zipDownloader/index.ts +++ b/packages/ui-default/components/zipDownloader/index.ts @@ -4,7 +4,7 @@ import PQueue from 'p-queue'; import streamsaver from 'streamsaver'; import Notification from 'vj/components/notification'; import { - api, createZipStream, gql, i18n, pipeStream, request, + api, createZipStream, i18n, pipeStream, request, } from 'vj/utils'; import { ctx } from '../../context'; @@ -94,23 +94,21 @@ export async function downloadProblemSet(pids, name = 'Export') { try { await ctx.serial('problemset/download', pids, name, targets); for (const pid of pids) { - const pdoc = await api(gql` - problem(id: ${+pid}) { - pid - owner - title - content - tag - nSubmit - nAccept - data { - name - } - additional_file { - name - } - } - `, ['data', 'problem']); + const pdoc = await api('problem', { id: +pid }, { + pid: 1, + owner: 1, + title: 1, + content: 1, + tag: 1, + nSubmit: 1, + nAccept: 1, + data: { + name: 1, + }, + additional_file: { + name: 1, + }, + }); targets.push({ filename: `${pid}/problem.yaml`, content: dump({ diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 1c38d7cff..718e3efe3 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -11,7 +11,7 @@ "cli": false }, "devDependencies": { - "@blueprintjs/core": "^5.18.0", + "@blueprintjs/core": "^5.19.0", "@fontsource/dm-mono": "^5.2.5", "@fontsource/fira-code": "^5.2.5", "@fontsource/inconsolata": "^5.2.5", @@ -21,7 +21,7 @@ "@fontsource/source-code-pro": "^5.2.5", "@fontsource/ubuntu-mono": "^5.2.5", "@hydrooj/utils": "workspace:^", - "@sentry/browser": "^9.13.0", + "@sentry/browser": "^9.14.0", "@sentry/webpack-plugin": "^3.3.1", "@simplewebauthn/browser": "13.1.0", "@svgr/webpack": "^8.1.0", @@ -60,7 +60,6 @@ "emojis-list": "2.1.0", "esbuild-loader": "^4.3.0", "flatpickr": "^4.6.13", - "graphiql": "^3.8.3", "idb": "^8.0.2", "jquery": "^3.7.1", "jquery-scroll-lock": "^3.1.3", @@ -99,7 +98,7 @@ "redux-promise-middleware": "^6.2.0", "redux-thunk": "^3.1.0", "rupture": "^0.7.1", - "sass": "^1.86.3", + "sass": "^1.87.0", "sass-loader": "^16.0.5", "schemastery": "^3.16.1", "schemastery-react": "^0.1.2", @@ -116,7 +115,7 @@ "timeago.js": "^4.0.2", "ts-loader": "^9.5.2", "web-streams-polyfill": "^4.1.0", - "webpack": "^5.99.6", + "webpack": "^5.99.7", "webpack-bundle-analyzer": "^4.10.2", "webpack-dev-server": "^5.2.1", "webpack-manifest-plugin": "^5.0.1", diff --git a/packages/ui-default/pages/api.page.styl b/packages/ui-default/pages/api.page.styl index 1fbc32fa2..e69de29bb 100644 --- a/packages/ui-default/pages/api.page.styl +++ b/packages/ui-default/pages/api.page.styl @@ -1,10 +0,0 @@ -.graphiql-container .doc-explorer-title, .graphiql-container .history-title - overflow: hidden; - padding: 5px 40px 0px 0px !important - -.graphiql-container .docExplorerWrap - font-family: var(--code-font-family) !important - -.graphiql-container .doc-explorer-back - overflow: hidden; - padding: 8px 20px 0px 20px !important diff --git a/packages/ui-default/pages/api.page.tsx b/packages/ui-default/pages/api.page.tsx deleted file mode 100644 index 77008e2e4..000000000 --- a/packages/ui-default/pages/api.page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { pick } from 'lodash'; -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import { NamedPage } from 'vj/misc/Page'; -import { request } from 'vj/utils'; - -const defaultQuery = `\ -query Example( - $name: String! -) { - user(uname: $name) { - _id - uname - } -}`; - -export default new NamedPage('api', async () => { - const { GraphiQL } = await import('graphiql'); - GraphiQL.Logo = function () { - return

; - } as any; - createRoot(document.getElementById('main')!).render( - request.post(`/d/${UiContext.domain._id}/api`, body, pick(opts, 'headers'))} - defaultQuery={defaultQuery} - variables='{"name": "Hydro"}' - />, - ); -}); diff --git a/packages/ui-default/pages/domain_group.page.ts b/packages/ui-default/pages/domain_group.page.ts index 0269d00a5..a411f2326 100644 --- a/packages/ui-default/pages/domain_group.page.ts +++ b/packages/ui-default/pages/domain_group.page.ts @@ -5,22 +5,19 @@ import { ActionDialog, ConfirmDialog } from 'vj/components/dialog'; import Notification from 'vj/components/notification'; import { NamedPage } from 'vj/misc/Page'; import { - api, delay, gql, i18n, tpl, + api, delay, i18n, tpl, } from 'vj/utils'; -function update(name: string, uids: number[]) { - return api(gql` - domain { manage { group { - update(name: ${name}, uids: ${uids}) - } } } - `); +function update(name: string, uids: number[] = []) { + return api('domain.group', { + name, + uids, + }, []); } function del(name: string) { - return api(gql` - domain { manage { group { - del(name: ${name}) - } } } - `); + return api('domain.group', { + name, + }, []); } const page = new NamedPage('domain_group', () => { diff --git a/packages/ui-default/pages/home_messages.page.tsx b/packages/ui-default/pages/home_messages.page.tsx index 60cc584cb..670158031 100644 --- a/packages/ui-default/pages/home_messages.page.tsx +++ b/packages/ui-default/pages/home_messages.page.tsx @@ -5,7 +5,7 @@ import VjNotification from 'vj/components/notification'; import selectUser from 'vj/components/selectUser'; import { ctx, Service } from 'vj/context'; import { NamedPage } from 'vj/misc/Page'; -import { api, gql, loadReactRedux } from 'vj/utils'; +import { api, loadReactRedux } from 'vj/utils'; class MessagePadService extends Service { WebSocket: typeof import('../components/socket').default; @@ -91,14 +91,10 @@ const page = new NamedPage('home_messages', () => { async function loadSendTarget() { const target = new URL(window.location.href).searchParams.get('target'); if (!target) return; - const user = await api(gql` - users(search: ${target}, exact: true) { - _id - uname - avatarUrl - mail - } - `, ['data', 'users']); + const user = await api( + 'users', { search: target, exact: true }, + ['_id', 'uname', 'avatarUrl', 'mail'], + ); if (!user?.length) return; createDialog(user[0]); } diff --git a/packages/ui-default/templates/api.html b/packages/ui-default/templates/api.html deleted file mode 100644 index 82129faeb..000000000 --- a/packages/ui-default/templates/api.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "layout/basic.html" %} -{% block content %} -
- - -{% endblock %} diff --git a/packages/ui-default/templates/partials/footer.html b/packages/ui-default/templates/partials/footer.html index e82ef717a..bd878d56e 100644 --- a/packages/ui-default/templates/partials/footer.html +++ b/packages/ui-default/templates/partials/footer.html @@ -23,7 +23,6 @@