mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
i18n Support and Task improvements (#80)
* fix: release workflow * feat: move mostly to internal tasks system * feat: migrate object clean to new task system * fix: release not getting good base version * chore: set version v0.3.0 * chore: style * feat: basic task concurrency * feat: temp pages to fill in page links * feat: inital i18n support * feat: localize store page * chore: style * fix: weblate doesn't like multifile thing * fix: update nuxt * feat: improved error logging * fix: using old task api * feat: basic translation docs * feat: add i18n eslint plugin * feat: translate store and auth pages * feat: more translation progress * feat: admin dash i18n progress * feat: enable update check by default in prod * fix: using wrong i18n keys * fix: crash in library sources page * feat: finish i18n work * fix: missing i18n translations * feat: use twemoji for emojis * feat: sanatize object ids * fix: EmojiText's alt text * fix: UserWidget not using links * feat: cache and auth for emoji api * fix: add more missing translations
This commit is contained in:
@ -1,4 +1,5 @@
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import taskHandler from "~/server/internal/tasks";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const query = getQuery(h3);
|
||||
@ -8,8 +9,7 @@ export default defineEventHandler(async (h3) => {
|
||||
statusCode: 400,
|
||||
statusMessage: "id required in fetching invitation",
|
||||
});
|
||||
|
||||
await runTask("cleanup:invitations");
|
||||
taskHandler.runTaskGroupByName("cleanup:invitations");
|
||||
|
||||
const invitation = await prisma.invitation.findUnique({ where: { id: id } });
|
||||
if (!invitation)
|
||||
|
||||
45
server/api/v1/emojis/[id]/index.get.ts
Normal file
45
server/api/v1/emojis/[id]/index.get.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import path from "path";
|
||||
import module from "module";
|
||||
import fs from "fs/promises";
|
||||
import sanitize from "sanitize-filename";
|
||||
|
||||
import aclManager from "~/server/internal/acls";
|
||||
|
||||
const twemojiJson = module.findPackageJSON(
|
||||
"@discordapp/twemoji",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const userId = await aclManager.getUserIdACL(h3, ["object:read"]);
|
||||
if (!userId)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
});
|
||||
|
||||
if (!twemojiJson)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to resolve emoji package",
|
||||
});
|
||||
|
||||
const unsafeId = getRouterParam(h3, "id");
|
||||
if (!unsafeId)
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||
|
||||
const svgPath = path.join(
|
||||
path.dirname(twemojiJson),
|
||||
"dist",
|
||||
"svg",
|
||||
sanitize(unsafeId),
|
||||
);
|
||||
|
||||
setHeader(
|
||||
h3,
|
||||
"Cache-Control",
|
||||
// 7 days
|
||||
"public, max-age=604800, s-maxage=604800",
|
||||
);
|
||||
setHeader(h3, "Content-Type", "image/svg+xml");
|
||||
return await fs.readFile(svgPath);
|
||||
});
|
||||
@ -1,12 +1,15 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import objectHandler from "~/server/internal/objects";
|
||||
import sanitize from "sanitize-filename";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const id = getRouterParam(h3, "id");
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||
const unsafeId = getRouterParam(h3, "id");
|
||||
if (!unsafeId)
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||
|
||||
const userId = await aclManager.getUserIdACL(h3, ["object:delete"]);
|
||||
|
||||
const id = sanitize(unsafeId);
|
||||
const result = await objectHandler.deleteWithPermission(id, userId);
|
||||
return { success: result };
|
||||
});
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import objectHandler from "~/server/internal/objects";
|
||||
import sanitize from "sanitize-filename";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const id = getRouterParam(h3, "id");
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||
const unsafeId = getRouterParam(h3, "id");
|
||||
if (!unsafeId)
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||
|
||||
const userId = await aclManager.getUserIdACL(h3, ["object:read"]);
|
||||
|
||||
const id = sanitize(unsafeId);
|
||||
const object = await objectHandler.fetchWithPermissions(id, userId);
|
||||
if (!object)
|
||||
throw createError({ statusCode: 404, statusMessage: "Object not found" });
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import objectHandler from "~/server/internal/objects";
|
||||
import sanitize from "sanitize-filename";
|
||||
|
||||
// this request method is purely used by the browser to check if etag values are still valid
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const id = getRouterParam(h3, "id");
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||
const unsafeId = getRouterParam(h3, "id");
|
||||
if (!unsafeId)
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||
|
||||
const userId = await aclManager.getUserIdACL(h3, ["object:read"]);
|
||||
|
||||
const id = sanitize(unsafeId);
|
||||
const object = await objectHandler.fetchWithPermissions(id, userId);
|
||||
if (!object)
|
||||
throw createError({ statusCode: 404, statusMessage: "Object not found" });
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import objectHandler from "~/server/internal/objects";
|
||||
import sanitize from "sanitize-filename";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const id = getRouterParam(h3, "id");
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||
const unsafeId = getRouterParam(h3, "id");
|
||||
if (!unsafeId)
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||
|
||||
const body = await readRawBody(h3, "binary");
|
||||
if (!body)
|
||||
@ -15,6 +17,7 @@ export default defineEventHandler(async (h3) => {
|
||||
const userId = await aclManager.getUserIdACL(h3, ["object:update"]);
|
||||
const buffer = Buffer.from(body);
|
||||
|
||||
const id = sanitize(unsafeId);
|
||||
const result = await objectHandler.writeWithPermissions(
|
||||
id,
|
||||
async () => buffer,
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
// get a specific screenshot
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import screenshotManager from "~/server/internal/screenshots";
|
||||
import sanitize from "sanitize-filename";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const userId = await aclManager.getUserIdACL(h3, ["screenshots:delete"]);
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
const screenshotId = getRouterParam(h3, "id");
|
||||
if (!screenshotId)
|
||||
const unsafeId = getRouterParam(h3, "id");
|
||||
if (!unsafeId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing screenshot ID",
|
||||
});
|
||||
|
||||
const screenshotId = sanitize(unsafeId);
|
||||
const result = await screenshotManager.get(screenshotId);
|
||||
if (!result)
|
||||
throw createError({
|
||||
|
||||
@ -1,19 +1,20 @@
|
||||
// get a specific screenshot
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import screenshotManager from "~/server/internal/screenshots";
|
||||
import sanitize from "sanitize-filename";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]);
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
const screenshotId = getRouterParam(h3, "id");
|
||||
if (!screenshotId)
|
||||
const unsafeId = getRouterParam(h3, "id");
|
||||
if (!unsafeId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing screenshot ID",
|
||||
});
|
||||
|
||||
const result = await screenshotManager.get(screenshotId);
|
||||
const result = await screenshotManager.get(sanitize(unsafeId));
|
||||
if (!result)
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
|
||||
@ -23,15 +23,19 @@ export async function readDropValidatedBody<T>(
|
||||
try {
|
||||
return validate(_body);
|
||||
} catch (e) {
|
||||
const t = await useTranslation(event);
|
||||
|
||||
if (e instanceof ArkErrors) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Invalid request body: ${e.summary}`,
|
||||
statusMessage: t("errors.invalidBody", [e.summary]),
|
||||
});
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Invalid request body: ${e}`,
|
||||
statusMessage: t("errors.invalidBody", [
|
||||
e instanceof Error ? e.message : `${e}`,
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
4
server/h3.d.ts
vendored
4
server/h3.d.ts
vendored
@ -1,5 +1 @@
|
||||
export type MinimumRequestObject = { headers: Headers };
|
||||
|
||||
export type TaskReturn<T = unknown> =
|
||||
| { success: true; data: T; error?: never }
|
||||
| { success: false; data?: never; error: { message: string } };
|
||||
|
||||
@ -5,11 +5,7 @@ class SystemConfig {
|
||||
private dropVersion;
|
||||
private gitRef;
|
||||
|
||||
private checkForUpdates =
|
||||
process.env.CHECK_FOR_UPDATES !== undefined &&
|
||||
process.env.CHECK_FOR_UPDATES.toLocaleLowerCase() === "true"
|
||||
? true
|
||||
: false;
|
||||
private checkForUpdates = getUpdateCheckConfig();
|
||||
|
||||
constructor() {
|
||||
// get drop version and git ref from nuxt config
|
||||
@ -40,3 +36,26 @@ class SystemConfig {
|
||||
}
|
||||
|
||||
export const systemConfig = new SystemConfig();
|
||||
|
||||
/**
|
||||
* Gets the configuration for checking updates based on various conditions
|
||||
* @returns true if updates should be checked, false otherwise.
|
||||
*/
|
||||
function getUpdateCheckConfig(): boolean {
|
||||
const envCheckUpdates = process.env.CHECK_FOR_UPDATES;
|
||||
|
||||
// Check environment variable
|
||||
if (envCheckUpdates !== undefined) {
|
||||
// if explicitly set to true or false, return that value
|
||||
if (envCheckUpdates.toLocaleLowerCase() === "true") {
|
||||
return true;
|
||||
} else if (envCheckUpdates.toLocaleLowerCase() === "false") {
|
||||
return false;
|
||||
}
|
||||
} else if (process.env.NODE_ENV === "production") {
|
||||
// default to true in production
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -218,6 +218,7 @@ class LibraryManager {
|
||||
|
||||
taskHandler.create({
|
||||
id: taskId,
|
||||
taskGroup: "import:game",
|
||||
name: `Importing version ${versionName} for ${game.mName}`,
|
||||
acls: ["system:import:version:read"],
|
||||
async run({ progress, log }) {
|
||||
|
||||
19
server/internal/tasks/group.ts
Normal file
19
server/internal/tasks/group.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export const taskGroups = {
|
||||
"cleanup:invitations": {
|
||||
concurrency: false,
|
||||
},
|
||||
"cleanup:objects": {
|
||||
concurrency: false,
|
||||
},
|
||||
"cleanup:sessions": {
|
||||
concurrency: false,
|
||||
},
|
||||
"check:update": {
|
||||
concurrency: false,
|
||||
},
|
||||
"import:game": {
|
||||
concurrency: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type TaskGroup = keyof typeof taskGroups;
|
||||
@ -2,34 +2,86 @@ import droplet from "@drop-oss/droplet";
|
||||
import type { MinimumRequestObject } from "~/server/h3";
|
||||
import aclManager from "../acls";
|
||||
|
||||
import cleanupInvites from "./registry/invitations";
|
||||
import cleanupSessions from "./registry/sessions";
|
||||
import checkUpdate from "./registry/update";
|
||||
import cleanupObjects from "./registry/objects";
|
||||
import { taskGroups, type TaskGroup } from "./group";
|
||||
|
||||
// a task that has been run
|
||||
type FinishedTask = {
|
||||
success: boolean;
|
||||
progress: number;
|
||||
log: string[];
|
||||
error: { title: string; description: string } | undefined;
|
||||
name: string;
|
||||
taskGroup: TaskGroup;
|
||||
acls: string[];
|
||||
|
||||
// ISO timestamp of when the task started
|
||||
startTime: string;
|
||||
// ISO timestamp of when the task ended
|
||||
endTime: string | undefined;
|
||||
};
|
||||
|
||||
// a currently running task in the pool
|
||||
type TaskPoolEntry = FinishedTask & {
|
||||
clients: Map<string, boolean>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The TaskHandler setups up two-way connections to web clients and manages the state for them
|
||||
* This allows long-running tasks (like game imports and such) to report progress, success and error states
|
||||
* easily without re-inventing the wheel every time.
|
||||
*/
|
||||
|
||||
type TaskRegistryEntry = {
|
||||
success: boolean;
|
||||
progress: number;
|
||||
log: string[];
|
||||
error: { title: string; description: string } | undefined;
|
||||
clients: Map<string, boolean>;
|
||||
name: string;
|
||||
acls: string[];
|
||||
};
|
||||
|
||||
class TaskHandler {
|
||||
// TODO: make these maps, using objects like this has performance impacts
|
||||
// https://typescript-eslint.io/rules/no-dynamic-delete/
|
||||
private taskRegistry = new Map<string, TaskRegistryEntry>();
|
||||
// registry of schedualed tasks to be created
|
||||
private scheduledTasks: Map<TaskGroup, () => Task> = new Map();
|
||||
// list of all finished tasks
|
||||
private finishedTasks: Map<string, FinishedTask> = new Map();
|
||||
|
||||
// list of all currently running tasks
|
||||
private taskPool = new Map<string, TaskPoolEntry>();
|
||||
// list of all clients currently connected to tasks
|
||||
private clientRegistry = new Map<string, PeerImpl>();
|
||||
startTasks: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
// register the cleanup invitations task
|
||||
this.saveScheduledTask(cleanupInvites);
|
||||
this.saveScheduledTask(cleanupSessions);
|
||||
this.saveScheduledTask(checkUpdate);
|
||||
this.saveScheduledTask(cleanupObjects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves scheduled task to the registry
|
||||
* @param createTask
|
||||
*/
|
||||
private saveScheduledTask(task: DropTask) {
|
||||
this.scheduledTasks.set(task.taskGroup, task.build);
|
||||
}
|
||||
|
||||
create(task: Task) {
|
||||
let updateCollectTimeout: NodeJS.Timeout | undefined;
|
||||
let updateCollectResolves: Array<(value: unknown) => void> = [];
|
||||
let logOffset: number = 0;
|
||||
|
||||
// if taskgroup disallows concurrency
|
||||
if (!taskGroups[task.taskGroup].concurrency) {
|
||||
for (const existingTask of this.taskPool.values()) {
|
||||
// if a task is already running, we don't want to start another
|
||||
if (existingTask.taskGroup === task.taskGroup) {
|
||||
// TODO: handle this more gracefully, maybe with a queue? should be configurable
|
||||
console.warn(
|
||||
`Task group ${task.taskGroup} does not allow concurrent tasks. Task ${task.id} will not be started.`,
|
||||
);
|
||||
throw new Error(
|
||||
`Task group ${task.taskGroup} does not allow concurrent tasks.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateAllClients = (reset = false) =>
|
||||
new Promise((r) => {
|
||||
if (updateCollectTimeout) {
|
||||
@ -37,7 +89,7 @@ class TaskHandler {
|
||||
return;
|
||||
}
|
||||
updateCollectTimeout = setTimeout(() => {
|
||||
const taskEntry = this.taskRegistry.get(task.id);
|
||||
const taskEntry = this.taskPool.get(task.id);
|
||||
if (!taskEntry) return;
|
||||
|
||||
const taskMessage: TaskMessage = {
|
||||
@ -67,33 +119,37 @@ class TaskHandler {
|
||||
});
|
||||
|
||||
const progress = (progress: number) => {
|
||||
const taskEntry = this.taskRegistry.get(task.id);
|
||||
const taskEntry = this.taskPool.get(task.id);
|
||||
if (!taskEntry) return;
|
||||
taskEntry.progress = progress;
|
||||
updateAllClients();
|
||||
};
|
||||
|
||||
const log = (entry: string) => {
|
||||
const taskEntry = this.taskRegistry.get(task.id);
|
||||
const taskEntry = this.taskPool.get(task.id);
|
||||
if (!taskEntry) return;
|
||||
taskEntry.log.push(entry);
|
||||
console.log(`[Task ${task.taskGroup}]: ${entry}`);
|
||||
updateAllClients();
|
||||
};
|
||||
|
||||
this.taskRegistry.set(task.id, {
|
||||
this.taskPool.set(task.id, {
|
||||
name: task.name,
|
||||
taskGroup: task.taskGroup,
|
||||
success: false,
|
||||
progress: 0,
|
||||
error: undefined,
|
||||
log: [],
|
||||
clients: new Map(),
|
||||
acls: task.acls,
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: undefined,
|
||||
});
|
||||
|
||||
updateAllClients(true);
|
||||
|
||||
droplet.callAltThreadFunc(async () => {
|
||||
const taskEntry = this.taskRegistry.get(task.id);
|
||||
const taskEntry = this.taskPool.get(task.id);
|
||||
if (!taskEntry) throw new Error("No task entry");
|
||||
|
||||
try {
|
||||
@ -106,13 +162,22 @@ class TaskHandler {
|
||||
description: (error as string).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
taskEntry.endTime = new Date().toISOString();
|
||||
await updateAllClients();
|
||||
|
||||
for (const clientId of taskEntry.clients.keys()) {
|
||||
if (!this.clientRegistry.get(clientId)) continue;
|
||||
this.disconnect(clientId, task.id);
|
||||
}
|
||||
this.taskRegistry.delete(task.id);
|
||||
|
||||
// so we can drop the clients from the task entry
|
||||
const { clients, ...copied } = taskEntry;
|
||||
this.finishedTasks.set(task.id, {
|
||||
...copied,
|
||||
});
|
||||
|
||||
this.taskPool.delete(task.id);
|
||||
});
|
||||
}
|
||||
|
||||
@ -122,7 +187,7 @@ class TaskHandler {
|
||||
peer: PeerImpl,
|
||||
request: MinimumRequestObject,
|
||||
) {
|
||||
const task = this.taskRegistry.get(taskId);
|
||||
const task = this.taskPool.get(taskId);
|
||||
if (!task) {
|
||||
peer.send(
|
||||
`error/${taskId}/Unknown task/Drop couldn't find the task you're looking for.`,
|
||||
@ -160,8 +225,8 @@ class TaskHandler {
|
||||
}
|
||||
|
||||
disconnectAll(id: string) {
|
||||
for (const taskId of this.taskRegistry.keys()) {
|
||||
this.taskRegistry.get(taskId)?.clients.delete(id);
|
||||
for (const taskId of this.taskPool.keys()) {
|
||||
this.taskPool.get(taskId)?.clients.delete(id);
|
||||
this.sendDisconnectEvent(id, taskId);
|
||||
}
|
||||
|
||||
@ -169,13 +234,13 @@ class TaskHandler {
|
||||
}
|
||||
|
||||
disconnect(id: string, taskId: string) {
|
||||
const task = this.taskRegistry.get(taskId);
|
||||
const task = this.taskPool.get(taskId);
|
||||
if (!task) return false;
|
||||
|
||||
task.clients.delete(id);
|
||||
this.sendDisconnectEvent(id, taskId);
|
||||
|
||||
const allClientIds = this.taskRegistry
|
||||
const allClientIds = this.taskPool
|
||||
.values()
|
||||
.toArray()
|
||||
.map((e) => e.clients.keys().toArray())
|
||||
@ -187,6 +252,24 @@ class TaskHandler {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
runTaskGroupByName(name: TaskGroup) {
|
||||
const task = this.scheduledTasks.get(name);
|
||||
if (!task) {
|
||||
console.warn(`No task found for group ${name}`);
|
||||
return;
|
||||
}
|
||||
this.create(task());
|
||||
}
|
||||
|
||||
/**]
|
||||
* Runs all daily tasks that are scheduled to run once a day.
|
||||
*/
|
||||
triggerDailyTasks() {
|
||||
this.runTaskGroupByName("cleanup:invitations");
|
||||
this.runTaskGroupByName("cleanup:sessions");
|
||||
this.runTaskGroupByName("check:update");
|
||||
}
|
||||
}
|
||||
|
||||
export type TaskRunContext = {
|
||||
@ -196,6 +279,7 @@ export type TaskRunContext = {
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
taskGroup: TaskGroup;
|
||||
name: string;
|
||||
run: (context: TaskRunContext) => Promise<void>;
|
||||
acls: string[];
|
||||
@ -215,5 +299,33 @@ export type PeerImpl = {
|
||||
send: (message: string) => void;
|
||||
};
|
||||
|
||||
export interface BuildTask {
|
||||
buildId: () => string;
|
||||
taskGroup: TaskGroup;
|
||||
name: string;
|
||||
run: (context: TaskRunContext) => Promise<void>;
|
||||
acls: string[];
|
||||
}
|
||||
|
||||
interface DropTask {
|
||||
taskGroup: TaskGroup;
|
||||
build: () => Task;
|
||||
}
|
||||
|
||||
export function defineDropTask(buildTask: BuildTask): DropTask {
|
||||
// TODO: only let one task with the same taskGroup run at the same time if specified
|
||||
|
||||
return {
|
||||
taskGroup: buildTask.taskGroup,
|
||||
build: () => ({
|
||||
id: buildTask.buildId(),
|
||||
taskGroup: buildTask.taskGroup,
|
||||
name: buildTask.name,
|
||||
run: buildTask.run,
|
||||
acls: buildTask.acls,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const taskHandler = new TaskHandler();
|
||||
export default taskHandler;
|
||||
|
||||
24
server/internal/tasks/registry/invitations.ts
Normal file
24
server/internal/tasks/registry/invitations.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import { defineDropTask } from "..";
|
||||
|
||||
export default defineDropTask({
|
||||
buildId: () => `cleanup:invitations:${new Date().toISOString()}`,
|
||||
name: "Cleanup Invitations",
|
||||
acls: [],
|
||||
taskGroup: "cleanup:invitations",
|
||||
async run({ log }) {
|
||||
log("Cleaning invitations");
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await prisma.invitation.deleteMany({
|
||||
where: {
|
||||
expires: {
|
||||
lt: now,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
log("Done");
|
||||
},
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import objectHandler from "~/server/internal/objects";
|
||||
import type { TaskReturn } from "../../h3";
|
||||
import { defineDropTask } from "..";
|
||||
|
||||
type FieldReferenceMap = {
|
||||
[modelName: string]: {
|
||||
@ -10,36 +10,33 @@ type FieldReferenceMap = {
|
||||
};
|
||||
};
|
||||
|
||||
export default defineTask<TaskReturn>({
|
||||
meta: {
|
||||
name: "cleanup:objects",
|
||||
},
|
||||
async run() {
|
||||
console.log("[Task cleanup:objects]: Cleaning unreferenced objects");
|
||||
export default defineDropTask({
|
||||
buildId: () => `cleanup:objects:${new Date().toISOString()}`,
|
||||
name: "Cleanup Objects",
|
||||
acls: [],
|
||||
taskGroup: "cleanup:objects",
|
||||
async run({ progress, log }) {
|
||||
log("Cleaning unreferenced objects");
|
||||
|
||||
// get all objects
|
||||
const objects = await objectHandler.listAll();
|
||||
console.log(
|
||||
`[Task cleanup:objects]: searching for ${objects.length} objects`,
|
||||
);
|
||||
log(`searching for ${objects.length} objects`);
|
||||
progress(30);
|
||||
|
||||
// find unreferenced objects
|
||||
const refMap = buildRefMap();
|
||||
console.log("[Task cleanup:objects]: Building reference map");
|
||||
console.log(
|
||||
`[Task cleanup:objects]: Found ${Object.keys(refMap).length} models with reference fields`,
|
||||
);
|
||||
console.log("[Task cleanup:objects]: Searching for unreferenced objects");
|
||||
log("Building reference map");
|
||||
log(`Found ${Object.keys(refMap).length} models with reference fields`);
|
||||
log("Searching for unreferenced objects");
|
||||
const unrefedObjects = await findUnreferencedStrings(objects, refMap);
|
||||
console.log(
|
||||
`[Task cleanup:objects]: found ${unrefedObjects.length} Unreferenced objects`,
|
||||
);
|
||||
log(`found ${unrefedObjects.length} Unreferenced objects`);
|
||||
// console.log(unrefedObjects);
|
||||
progress(60);
|
||||
|
||||
// remove objects
|
||||
const deletePromises: Promise<boolean>[] = [];
|
||||
for (const obj of unrefedObjects) {
|
||||
console.log(`[Task cleanup:objects]: Deleting object ${obj}`);
|
||||
log(`Deleting object ${obj}`);
|
||||
deletePromises.push(objectHandler.deleteAsSystem(obj));
|
||||
}
|
||||
await Promise.all(deletePromises);
|
||||
@ -47,13 +44,8 @@ export default defineTask<TaskReturn>({
|
||||
// Remove any possible leftover metadata
|
||||
objectHandler.cleanupMetadata();
|
||||
|
||||
console.log("[Task cleanup:objects]: Done");
|
||||
return {
|
||||
result: {
|
||||
success: true,
|
||||
data: unrefedObjects,
|
||||
},
|
||||
};
|
||||
log("Done");
|
||||
progress(100);
|
||||
},
|
||||
});
|
||||
|
||||
14
server/internal/tasks/registry/sessions.ts
Normal file
14
server/internal/tasks/registry/sessions.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import sessionHandler from "~/server/internal/session";
|
||||
import { defineDropTask } from "..";
|
||||
|
||||
export default defineDropTask({
|
||||
buildId: () => `cleanup:sessions:${new Date().toISOString()}`,
|
||||
name: "Cleanup Sessions",
|
||||
acls: [],
|
||||
taskGroup: "cleanup:sessions",
|
||||
async run({ log }) {
|
||||
log("Cleaning up sessions");
|
||||
await sessionHandler.cleanupSessions();
|
||||
log("Done");
|
||||
},
|
||||
});
|
||||
98
server/internal/tasks/registry/update.ts
Normal file
98
server/internal/tasks/registry/update.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { type } from "arktype";
|
||||
import * as semver from "semver";
|
||||
import { defineDropTask } from "..";
|
||||
import { systemConfig } from "../../config/sys-conf";
|
||||
import notificationSystem from "../../notifications";
|
||||
|
||||
const latestRelease = type({
|
||||
url: "string", // api url for specific release
|
||||
html_url: "string", // user facing url
|
||||
id: "number", // release id
|
||||
tag_name: "string", // tag used for release
|
||||
name: "string", // release name
|
||||
draft: "boolean",
|
||||
prerelease: "boolean",
|
||||
created_at: "string",
|
||||
published_at: "string",
|
||||
});
|
||||
|
||||
export default defineDropTask({
|
||||
buildId: () => `check:update:${new Date().toISOString()}`,
|
||||
name: "Check for Update",
|
||||
acls: [],
|
||||
taskGroup: "check:update",
|
||||
async run({ log }) {
|
||||
// TODO: maybe implement some sort of rate limit thing to prevent this from calling github api a bunch in the event of crashloop or whatever?
|
||||
// probably will require custom task scheduler for object cleanup anyway, so something to thing about
|
||||
|
||||
if (!systemConfig.shouldCheckForUpdates()) {
|
||||
log("Update check is disabled by configuration");
|
||||
return;
|
||||
}
|
||||
|
||||
log("Checking for update");
|
||||
|
||||
const currVerStr = systemConfig.getDropVersion();
|
||||
const currVer = semver.coerce(currVerStr);
|
||||
if (currVer === null) {
|
||||
const msg = "Drop provided a invalid semver tag";
|
||||
log(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/Drop-OSS/drop/releases/latest",
|
||||
);
|
||||
|
||||
// if response failed somehow
|
||||
if (!response.ok) {
|
||||
log(
|
||||
"Failed to check for update " +
|
||||
JSON.stringify({
|
||||
status: response.status,
|
||||
body: response.body,
|
||||
}),
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`Failed to check for update: ${response.status} ${response.body}`,
|
||||
);
|
||||
}
|
||||
|
||||
// parse and validate response
|
||||
const resJson = await response.json();
|
||||
const body = latestRelease(resJson);
|
||||
if (body instanceof type.errors) {
|
||||
log(body.summary);
|
||||
log("GitHub Api response" + JSON.stringify(resJson));
|
||||
throw new Error(
|
||||
`GitHub Api response did not match expected schema: ${body.summary}`,
|
||||
);
|
||||
}
|
||||
|
||||
// parse remote version
|
||||
const latestVer = semver.coerce(body.tag_name);
|
||||
if (latestVer === null) {
|
||||
const msg = "Github Api returned invalid semver tag";
|
||||
log(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// TODO: handle prerelease identifiers https://github.com/npm/node-semver#prerelease-identifiers
|
||||
// check if is newer version
|
||||
if (semver.gt(latestVer, currVer)) {
|
||||
log("Update available");
|
||||
notificationSystem.systemPush({
|
||||
nonce: `drop-update-available-${currVer}-to-${latestVer}`,
|
||||
title: `Update available to v${latestVer}`,
|
||||
description: `A new version of Drop is available v${latestVer}`,
|
||||
actions: [`View|${body.html_url}`],
|
||||
acls: ["system:notifications:read"],
|
||||
});
|
||||
} else {
|
||||
log("no update available");
|
||||
}
|
||||
|
||||
log("Done");
|
||||
},
|
||||
});
|
||||
@ -1,10 +1,4 @@
|
||||
export default defineNitroPlugin(async (_nitro) => {
|
||||
// all tasks we should run on server boot
|
||||
await Promise.all([
|
||||
runTask("cleanup:invitations"),
|
||||
runTask("cleanup:sessions"),
|
||||
// TODO: maybe implement some sort of rate limit thing to prevent this from calling github api a bunch in the event of crashloop or whatever?
|
||||
// probably will require custom task scheduler for object cleanup anyway, so something to thing about
|
||||
runTask("check:update"),
|
||||
]);
|
||||
await runTask("dailyTasks");
|
||||
});
|
||||
|
||||
@ -1,150 +0,0 @@
|
||||
import { type } from "arktype";
|
||||
import { systemConfig } from "../../internal/config/sys-conf";
|
||||
import * as semver from "semver";
|
||||
import type { TaskReturn } from "../../h3";
|
||||
import notificationSystem from "../../internal/notifications";
|
||||
|
||||
const latestRelease = type({
|
||||
url: "string", // api url for specific release
|
||||
html_url: "string", // user facing url
|
||||
id: "number", // release id
|
||||
tag_name: "string", // tag used for release
|
||||
name: "string", // release name
|
||||
draft: "boolean",
|
||||
prerelease: "boolean",
|
||||
created_at: "string",
|
||||
published_at: "string",
|
||||
});
|
||||
|
||||
export default defineTask<TaskReturn>({
|
||||
meta: {
|
||||
name: "check:update",
|
||||
},
|
||||
async run() {
|
||||
if (systemConfig.shouldCheckForUpdates()) {
|
||||
console.log("[Task check:update]: Checking for update");
|
||||
|
||||
const currVerStr = systemConfig.getDropVersion();
|
||||
const currVer = semver.coerce(currVerStr);
|
||||
if (currVer === null) {
|
||||
const msg = "Drop provided a invalid semver tag";
|
||||
console.log("[Task check:update]:", msg);
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
error: {
|
||||
message: msg,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/Drop-OSS/drop/releases/latest",
|
||||
);
|
||||
|
||||
// if response failed somehow
|
||||
if (!response.ok) {
|
||||
console.log("[Task check:update]: Failed to check for update", {
|
||||
status: response.status,
|
||||
body: response.body,
|
||||
});
|
||||
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
error: {
|
||||
message: "" + response.status,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// parse and validate response
|
||||
const resJson = await response.json();
|
||||
const body = latestRelease(resJson);
|
||||
if (body instanceof type.errors) {
|
||||
console.error(body.summary);
|
||||
console.log("GitHub Api response", resJson);
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
error: {
|
||||
message: body.summary,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// parse remote version
|
||||
const latestVer = semver.coerce(body.tag_name);
|
||||
if (latestVer === null) {
|
||||
const msg = "Github Api returned invalid semver tag";
|
||||
console.log("[Task check:update]:", msg);
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
error: {
|
||||
message: msg,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: handle prerelease identifiers https://github.com/npm/node-semver#prerelease-identifiers
|
||||
// check if is newer version
|
||||
if (semver.gt(latestVer, currVer)) {
|
||||
console.log("[Task check:update]: Update available");
|
||||
notificationSystem.systemPush({
|
||||
nonce: `drop-update-available-${currVer}-to-${latestVer}`,
|
||||
title: `Update available to v${latestVer}`,
|
||||
description: `A new version of Drop is available v${latestVer}`,
|
||||
actions: [`View|${body.html_url}`],
|
||||
acls: ["system:notifications:read"],
|
||||
});
|
||||
} else {
|
||||
console.log("[Task check:update]: no update available");
|
||||
}
|
||||
|
||||
console.log("[Task check:update]: Done");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (typeof e === "string") {
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
error: {
|
||||
message: e,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (e instanceof Error) {
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
error: {
|
||||
message: e.message,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
error: {
|
||||
message: "unknown cause, please check console",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
result: {
|
||||
success: true,
|
||||
data: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -1,23 +0,0 @@
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
export default defineTask({
|
||||
meta: {
|
||||
name: "cleanup:invitations",
|
||||
},
|
||||
async run() {
|
||||
console.log("[Task cleanup:invitations]: Cleaning invitations");
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await prisma.invitation.deleteMany({
|
||||
where: {
|
||||
expires: {
|
||||
lt: now,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[Task cleanup:invitations]: Done");
|
||||
return { result: true };
|
||||
},
|
||||
});
|
||||
@ -1,13 +0,0 @@
|
||||
import sessionHandler from "~/server/internal/session";
|
||||
|
||||
export default defineTask({
|
||||
meta: {
|
||||
name: "cleanup:sessions",
|
||||
},
|
||||
async run() {
|
||||
console.log("[Task cleanup:sessions]: Cleaning up sessions");
|
||||
await sessionHandler.cleanupSessions();
|
||||
console.log("[Task cleanup:sessions]: Done");
|
||||
return { result: true };
|
||||
},
|
||||
});
|
||||
12
server/tasks/dailyTasks.ts
Normal file
12
server/tasks/dailyTasks.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import taskHandler from "~/server/internal/tasks";
|
||||
|
||||
export default defineTask({
|
||||
meta: {
|
||||
name: "dailyTasks",
|
||||
},
|
||||
async run() {
|
||||
taskHandler.triggerDailyTasks();
|
||||
|
||||
return {};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user