| name | hydro-plugin-services |
| description | Hydro plugin service APIs including SettingService (plugin configuration), StorageService/StorageModel (file storage), TaskModel (background job queue), ScheduleModel (delayed task execution), and OAuth provider registration for third-party login. |
Hydro Plugin Development: Settings, Storage & OAuth Services
This skill covers service-level APIs for Hydro plugins: declaring plugin settings, file storage operations, and registering OAuth login providers.
1. Plugin Settings — SettingService
Plugins can declare configuration settings that appear in Hydro's admin panels. Settings are declared via schema functions exposed through ctx.setting.
Setting scopes
| Method | Location | Who sees it |
|---|
ctx.setting.PreferenceSetting(schema) | User → Preferences | Per-user settings |
ctx.setting.AccountSetting(schema) | User → Account | Account-level settings |
ctx.setting.DomainSetting(schema) | Domain → Settings | Per-domain settings |
ctx.setting.DomainUserSetting(schema) | Domain → User Settings | Per-domain-user settings |
ctx.setting.SystemSetting(schema) | System → Settings | System-wide (superadmin only) |
All methods accept a Schema object from Schemastery and auto-register cleanup on plugin unload.
Declaring settings in a Service
import { Context, Service, Schema } from 'hydrooj';
export default class MyPluginService extends Service {
static Config = Schema.object({
apiKey: Schema.string().description('API Key').required(),
maxRetries: Schema.number().default(3).description('Max retry count'),
endpoint: Schema.string().description('API Endpoint'),
enabled: Schema.boolean().default(true),
});
constructor(ctx: Context, config: ReturnType<typeof MyPluginService.Config>) {
super(ctx, 'myPlugin');
}
}
The static Config schema is automatically loaded by the Hydro Loader. Configuration values are read from the system config YAML stored in MongoDB, validated against the schema, and passed to the constructor.
Important caveat: this constructor injection assumes the service class is the plugin entry. If the module also exports a named apply, then apply becomes the entry point. In that mixed style, export Config at the module level, accept config in apply, and forward it with ctx.plugin(MyPluginService, config) if you still want the service constructor to receive the resolved config.
Reading system settings
const value = ctx.setting.get('server.name');
const port = ctx.setting.get('server.port');
Setting types (SettingType)
type SettingType =
| 'text'
| 'password'
| 'textarea'
| 'markdown'
| 'number'
| 'float'
| 'boolean'
| 'json'
| 'yaml'
| [string, string][]
| Record<string, string>;
Setting flags
import { FLAG_HIDDEN, FLAG_DISABLED, FLAG_SECRET, FLAG_PRO, FLAG_PUBLIC, FLAG_PRIVATE } from 'hydrooj';
FLAG_HIDDEN = 1
FLAG_DISABLED = 2
FLAG_SECRET = 4
FLAG_PRO = 8
FLAG_PUBLIC = 16
FLAG_PRIVATE = 32
Real example: GitHub OAuth plugin settings
export default class LoginWithGithubService extends Service {
static inject = ['oauth'];
static Config = Schema.object({
id: Schema.string().description('GitHub OAuth AppID').required(),
secret: Schema.string().description('GitHub OAuth Secret').role('secret').required(),
endpoint: Schema.string().description('GitHub Endpoint'),
canRegister: Schema.boolean().default(true),
});
constructor(ctx: Context, config: ReturnType<typeof LoginWithGithubService.Config>) {
super(ctx, 'oauth.github');
}
}
2. File Storage — StorageModel
StorageModel provides a unified file storage API over either S3-compatible storage (RemoteStorageService) or local filesystem (LocalStorageService). Plugins should always use StorageModel rather than accessing the storage backend directly.
Import
import { StorageModel } from 'hydrooj';
Core methods
const storageId = StorageModel.generateId('.pdf');
await StorageModel.put(
path: string,
file: string | Buffer | Readable,
owner?: number,
);
const stream: Readable = await StorageModel.get(
path: string,
savePath?: string,
);
await StorageModel.del(
path: string[],
operator?: number,
);
const exists: boolean = await StorageModel.exists(path: string);
const meta = await StorageModel.getMeta(path: string);
const files = await StorageModel.list(
target: string,
recursive?: boolean,
);
await StorageModel.rename(
path: string,
newPath: string,
operator?: number,
);
await StorageModel.move(src: string, dst: string): Promise<boolean>;
await StorageModel.copy(src: string, dst: string): Promise<string>;
Signed download links
const url = await StorageModel.signDownloadLink(
target: string,
filename?: string,
noExpire?: boolean,
useAlternativeEndpointFor?: 'user' | 'judge',
);
Real example: problem testdata upload
const fileId = StorageModel.generateId('.zip');
await StorageModel.put(`problem/${domainId}/${docId}/testdata/${fileId}`, buffer);
const link = await StorageModel.signDownloadLink(
`problem/${domainId}/${docId}/testdata/${fileId}`,
'testdata.zip',
);
Real example: contest file attachment
const path = `contest/${domainId}/${tid}/files/${filename}`;
await StorageModel.put(path, file, this.user._id);
const files = await StorageModel.list(`contest/${domainId}/${tid}/files`);
3. StorageService (Low-Level Backend)
The StorageService is the underlying storage backend. There are two implementations:
RemoteStorageService — S3-compatible object storage (production)
LocalStorageService — Local filesystem (development / simple deployments)
Plugins typically use StorageModel (higher-level) instead of accessing StorageService directly.
Available when needed
ctx.storage.client
await ctx.storage.put(target: string, file: string | Buffer | Readable, meta?: Record<string, string>);
const { url, fields } = await ctx.storage.signUpload(target: string, size: number);
4. OAuth Provider Registration
Plugins can register third-party OAuth login providers (e.g., GitHub, Google, custom providers). This makes the provider appear on Hydro's login page.
OAuthProvider interface
interface OAuthProvider {
text: string;
name: string;
icon?: string;
hidden?: boolean;
canRegister?: boolean;
lockUsername?: boolean;
get: (this: Handler) => Promise<void>;
callback: (this: Handler, args: Record<string, any>) => Promise<OAuthUserResponse>;
}
interface OAuthUserResponse {
_id: string;
email: string;
bio?: string;
uname?: string[];
avatar?: string;
viewLang?: string;
set?: Record<string, any>;
setInDomain?: Record<string, any>;
}
Registration pattern
export default class MyOAuthService extends Service {
static inject = ['oauth'];
static Config = Schema.object({
clientId: Schema.string().required(),
clientSecret: Schema.string().role('secret').required(),
canRegister: Schema.boolean().default(true),
});
constructor(ctx: Context, config: ReturnType<typeof MyOAuthService.Config>) {
super(ctx, 'oauth.myprovider');
ctx.oauth.provide('myprovider', {
text: 'Login with MyProvider',
name: 'MyProvider',
icon: '<svg>...</svg>',
canRegister: config.canRegister,
get: async function get(this: Handler) {
const [state] = await TokenModel.add(TokenModel.TYPE_OAUTH, 600, {
redirect: this.request.referer,
});
this.response.redirect =
`https://myprovider.com/oauth/authorize?client_id=${config.clientId}&state=${state}&scope=email`;
},
callback: async function callback({ state, code }) {
const s = await TokenModel.get(state, TokenModel.TYPE_OAUTH);
if (!s) throw new ValidationError('token');
const tokenRes = await superagent.post('https://myprovider.com/oauth/token')
.send({
client_id: config.clientId,
client_secret: config.clientSecret,
code,
})
.set('accept', 'application/json');
const accessToken = tokenRes.body.access_token;
const userRes = await superagent.get('https://api.myprovider.com/user')
.set('Authorization', `Bearer ${accessToken}`);
await TokenModel.del(s._id, TokenModel.TYPE_OAUTH);
return {
_id: userRes.body.id.toString(),
email: userRes.body.email,
uname: [userRes.body.name, userRes.body.username].filter(Boolean),
avatar: `myprovider:${userRes.body.username}`,
bio: userRes.body.bio,
};
},
});
}
}
OAuth helper methods (from OauthModel)
const uid = await ctx.oauth.get(platform: string, id: string): Promise<number | null>;
await ctx.oauth.set(platform: string, id: string, uid: number): Promise<number>;
await ctx.oauth.unbind(platform: string, uid: number): Promise<DeleteResult>;
const accounts = await ctx.oauth.list(uid: number): Promise<OauthMap[]>;
Key security considerations
- Always validate the
state parameter using TokenModel.get() / TokenModel.del() to prevent CSRF
- Delete the state token after successful callback to prevent replay attacks
- Require verified email — throw
ForbiddenError if no verified email is available
- Use
role('secret') for client_secret in Config schema to prevent leaking in API responses
lockUsername: true prevents users from changing their username after OAuth registration (useful for institutional SSO)
5. Background Tasks — TaskModel
TaskModel provides a persistent background job queue backed by MongoDB. Tasks survive process restarts and can be consumed by worker processes at a controlled concurrency.
Import
import { TaskModel } from 'hydrooj';
Task document shape
interface Task {
_id: ObjectId;
type: string;
subType?: string;
priority: number;
[key: string]: any;
}
Core methods
const taskId = await TaskModel.add({
type: 'my_plugin/send_email',
subType: 'verification',
priority: 10,
to: 'user@example.com',
subject: 'Welcome',
});
const taskIds = await TaskModel.addMany([
{ type: 'my_plugin/process', file: 'a.txt', priority: 0 },
{ type: 'my_plugin/process', file: 'b.txt', priority: 0 },
]);
await TaskModel.get(taskId);
await TaskModel.count({ type: 'my_plugin/send_email' });
await TaskModel.del(taskId);
await TaskModel.deleteMany({ type: 'my_plugin/send_email' });
await TaskModel.getFirst({ type: 'my_plugin/send_email' });
Consuming tasks (worker pattern)
TaskModel.consume() creates a long-running consumer that picks up tasks from the queue:
export async function apply(ctx: Context) {
const consumer = TaskModel.consume(
{ type: 'my_plugin/send_email' },
async (task) => {
await sendEmail(task.to, task.subject);
},
true,
5,
);
ctx.effect(() => () => consumer.destroy());
}
Consumer API
class Consumer {
consuming: boolean;
processing: Set<Task>;
async consume(): Promise<void>;
async destroy(): Promise<void>;
setConcurrency(n: number): void;
setQuery(query: any): void;
}
Real example: VJudge submission queue
await TaskModel.add({
type: 'vjudge',
subType: provider.type,
priority: 1,
rid,
domainId,
pid: provider.pid,
});
const consumer = TaskModel.consume(
{ type: 'vjudge', subType: type },
async (t) => {
const result = await provider.judge(t);
await record.updateResult(t.domainId, t.rid, result);
},
false,
3,
);
6. Scheduled Tasks — ScheduleModel
ScheduleModel stores tasks that should execute after a specific time. The Hydro core scheduler checks for due tasks and executes them.
Import
import { ScheduleModel } from 'hydrooj';
Schedule document shape
interface Schedule {
_id: ObjectId;
type: string;
subType?: string;
executeAfter: Date;
[key: string]: any;
}
Core methods
const scheduleId = await ScheduleModel.add({
type: 'my_plugin/notify',
subType: 'deadline',
executeAfter: new Date(Date.now() + 3600_000),
uid: 12345,
message: 'Your homework is due',
});
await ScheduleModel.get(scheduleId);
await ScheduleModel.count({ type: 'my_plugin/notify' });
await ScheduleModel.del(scheduleId);
await ScheduleModel.deleteMany({ type: 'my_plugin/notify' });
await ScheduleModel.getFirst({ type: 'my_plugin/notify' });
How scheduling works
- Plugin calls
ScheduleModel.add() with executeAfter set to the desired time
- Hydro's core scheduler periodically scans for schedules where
executeAfter <= now
- When a schedule is due, the system fires a
task/daily or custom event based on the type
- Your plugin listens for the event and processes the schedule:
await ScheduleModel.add({
type: 'my_plugin/reminder',
executeAfter: new Date(Date.now() + 3600_000),
uid: userId,
content: 'Don\'t forget!',
});
ctx.on('task/daily', async () => {
const now = new Date();
const due = await ScheduleModel.getFirst({
type: 'my_plugin/reminder',
executeAfter: { $lte: now },
});
if (due) {
await sendNotification(due.uid, due.content);
await ScheduleModel.del(due._id);
}
});
Real example: contest start notification
ctx.on('contest/add', async (payload, tid) => {
await ScheduleModel.add({
type: 'contest/start_notify',
executeAfter: payload.beginAt,
tid,
domainId: payload.domainId,
});
});
7. Quick Reference
| Service | Access | Purpose |
|---|
| Settings | ctx.setting.{scope}Setting(schema) | Declare config UI |
| Settings | ctx.setting.get(key) | Read config value |
| Storage | StorageModel.put/get/del/list/signDownloadLink | File operations |
| Storage | StorageModel.generateId(ext) | Unique file IDs |
| OAuth | ctx.oauth.provide(name, provider) | Register login provider |
| OAuth | ctx.oauth.get/set/unbind/list | Manage account links |
| Token | TokenModel.add(type, expireSec, data) | Create temp tokens |
| Token | TokenModel.get(id, type) / TokenModel.del(id, type) | Validate/clean tokens |
| Task | TaskModel.add/addMany | Enqueue background jobs |
| Task | TaskModel.consume(query, handler, destroyOnError, concurrency) | Worker process pattern |
| Task | TaskModel.get/count/del/getFirst | Query/manage tasks |
| Schedule | ScheduleModel.add({ type, executeAfter, ... }) | Schedule delayed tasks |
| Schedule | ScheduleModel.get/count/del/getFirst | Query/manage schedules |