mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-27 17:00:54 +01:00
feat(widget): add restriction callback to restrict visibility and modification of widget kinds (#2658)
* feat(widget): add restriction callback to restrict visibility and modification of widget kinds * fix: typecheck issue * chore: address pull request feedback
This commit is contained in:
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod";
|
||||
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { constructBoardPermissions, isWidgetRestricted } from "@homarr/auth/shared";
|
||||
import type { DeviceType } from "@homarr/common/server";
|
||||
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
|
||||
import { and, asc, createId, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
|
||||
@@ -40,6 +40,7 @@ import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||
import { sectionSchema, sharedItemSchema, validation, zodUnionFromArray } from "@homarr/validation";
|
||||
|
||||
import { widgetImports } from "../../../widgets/src";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { generateResponsiveGridFor } from "./board/grid-algorithm";
|
||||
@@ -323,6 +324,13 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
|
||||
const allowedBoardItems = boardItems.filter((item) => {
|
||||
return !isWidgetRestricted({
|
||||
definition: widgetImports[item.kind].definition,
|
||||
user: ctx.session.user,
|
||||
check: (level) => level !== "none",
|
||||
});
|
||||
});
|
||||
|
||||
const newBoardId = createId();
|
||||
|
||||
@@ -370,8 +378,8 @@ export const boardRouter = createTRPCRouter({
|
||||
),
|
||||
);
|
||||
|
||||
const itemMap = new Map<string, string>(boardItems.map((item) => [item.id, createId()]));
|
||||
const itemsToInsert: InferInsertModel<typeof items>[] = boardItems.map(
|
||||
const itemMap = new Map<string, string>(allowedBoardItems.map((item) => [item.id, createId()]));
|
||||
const itemsToInsert: InferInsertModel<typeof items>[] = allowedBoardItems.map(
|
||||
({ integrations: _, layouts: _layouts, ...item }) => ({
|
||||
...item,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
@@ -380,7 +388,7 @@ export const boardRouter = createTRPCRouter({
|
||||
}),
|
||||
);
|
||||
|
||||
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems.flatMap((item) =>
|
||||
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = allowedBoardItems.flatMap((item) =>
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
...layoutSection,
|
||||
@@ -413,7 +421,7 @@ export const boardRouter = createTRPCRouter({
|
||||
)
|
||||
.then((result) => result.map((row) => row.id));
|
||||
|
||||
const itemIntegrationsToInsert = boardItems.flatMap((item) =>
|
||||
const itemIntegrationsToInsert = allowedBoardItems.flatMap((item) =>
|
||||
item.integrations
|
||||
// Restrict integrations to only those the user has access to
|
||||
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
|
||||
@@ -743,105 +751,140 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
|
||||
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
|
||||
const sectionsToInsert = addedSections.map(
|
||||
(section): InferInsertModel<typeof sections> => ({
|
||||
id: section.id,
|
||||
kind: section.kind,
|
||||
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
|
||||
xOffset: section.kind === "dynamic" ? null : 0,
|
||||
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
|
||||
name: "name" in section ? section.name : null,
|
||||
boardId: dbBoard.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionLayoutsToInsert = addedSections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.flatMap((section) =>
|
||||
section.layouts.map(
|
||||
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const addedItems = filterAddedItems(input.items, dbBoard.items).filter((item) => {
|
||||
return !isWidgetRestricted({
|
||||
definition: widgetImports[item.kind].definition,
|
||||
user: ctx.session.user,
|
||||
check: (level) => level !== "none",
|
||||
});
|
||||
});
|
||||
const itemsToInsert = addedItems.map(
|
||||
(item): InferInsertModel<typeof items> => ({
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
options: superjson.stringify(item.options),
|
||||
advancedOptions: superjson.stringify(item.advancedOptions),
|
||||
boardId: dbBoard.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const itemLayoutsToInsert = addedItems.flatMap((item) =>
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
|
||||
integrationIds.map((integrationId) => ({
|
||||
integrationId,
|
||||
itemId,
|
||||
})),
|
||||
);
|
||||
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
|
||||
integrationIds.map((integrationId) => ({
|
||||
integrationId,
|
||||
itemId,
|
||||
})),
|
||||
);
|
||||
const addedIntegrationRelations = inputIntegrationRelations.filter(
|
||||
(inputRelation) =>
|
||||
!dbIntegrationRelations.some(
|
||||
(dbRelation) =>
|
||||
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
|
||||
),
|
||||
);
|
||||
const integrationItemsToInsert = addedIntegrationRelations.map((relation) => ({
|
||||
itemId: relation.itemId,
|
||||
integrationId: relation.integrationId,
|
||||
}));
|
||||
|
||||
const updatedItems = filterUpdatedItems(input.items, dbBoard.items).filter((item) => {
|
||||
return !isWidgetRestricted({
|
||||
definition: widgetImports[item.kind].definition,
|
||||
user: ctx.session.user,
|
||||
check: (level) => level !== "none",
|
||||
});
|
||||
});
|
||||
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
|
||||
|
||||
const removedIntegrationRelations = dbIntegrationRelations.filter(
|
||||
(dbRelation) =>
|
||||
!inputIntegrationRelations.some(
|
||||
(inputRelation) =>
|
||||
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
|
||||
),
|
||||
);
|
||||
|
||||
const removedItems = filterRemovedItems(input.items, dbBoard.items).filter((item) => {
|
||||
return !isWidgetRestricted({
|
||||
definition: widgetImports[item.kind].definition,
|
||||
user: ctx.session.user,
|
||||
check: (level) => level !== "none",
|
||||
});
|
||||
});
|
||||
const itemIdsToRemove = removedItems.map((item) => item.id);
|
||||
|
||||
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
|
||||
const sectionIdsToRemove = removedSections.map((section) => section.id);
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
|
||||
|
||||
if (addedSections.length > 0) {
|
||||
await transaction.insert(schema.sections).values(
|
||||
addedSections.map((section) => ({
|
||||
id: section.id,
|
||||
kind: section.kind,
|
||||
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
|
||||
xOffset: section.kind === "dynamic" ? null : 0,
|
||||
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
|
||||
name: "name" in section ? section.name : null,
|
||||
boardId: dbBoard.id,
|
||||
})),
|
||||
);
|
||||
|
||||
if (addedSections.some((section) => section.kind === "dynamic")) {
|
||||
await transaction.insert(schema.sectionLayouts).values(
|
||||
addedSections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.flatMap((section) =>
|
||||
section.layouts.map(
|
||||
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (sectionsToInsert.length > 0) {
|
||||
await transaction.insert(schema.sections).values(sectionsToInsert);
|
||||
}
|
||||
|
||||
const addedItems = filterAddedItems(input.items, dbBoard.items);
|
||||
|
||||
if (addedItems.length > 0) {
|
||||
await transaction.insert(schema.items).values(
|
||||
addedItems.map((item) => ({
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
options: superjson.stringify(item.options),
|
||||
advancedOptions: superjson.stringify(item.advancedOptions),
|
||||
boardId: dbBoard.id,
|
||||
})),
|
||||
);
|
||||
await transaction.insert(schema.itemLayouts).values(
|
||||
addedItems.flatMap((item) =>
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (sectionLayoutsToInsert.length > 0) {
|
||||
await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
|
||||
}
|
||||
|
||||
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
|
||||
integrationIds.map((integrationId) => ({
|
||||
integrationId,
|
||||
itemId,
|
||||
})),
|
||||
);
|
||||
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
|
||||
integrationIds.map((integrationId) => ({
|
||||
integrationId,
|
||||
itemId,
|
||||
})),
|
||||
);
|
||||
const addedIntegrationRelations = inputIntegrationRelations.filter(
|
||||
(inputRelation) =>
|
||||
!dbIntegrationRelations.some(
|
||||
(dbRelation) =>
|
||||
dbRelation.itemId === inputRelation.itemId &&
|
||||
dbRelation.integrationId === inputRelation.integrationId,
|
||||
),
|
||||
);
|
||||
|
||||
if (addedIntegrationRelations.length > 0) {
|
||||
await transaction.insert(schema.integrationItems).values(
|
||||
addedIntegrationRelations.map((relation) => ({
|
||||
itemId: relation.itemId,
|
||||
integrationId: relation.integrationId,
|
||||
})),
|
||||
);
|
||||
if (itemsToInsert.length > 0) {
|
||||
await transaction.insert(schema.items).values(itemsToInsert);
|
||||
}
|
||||
if (itemLayoutsToInsert.length > 0) {
|
||||
await transaction.insert(schema.itemLayouts).values(itemLayoutsToInsert);
|
||||
}
|
||||
|
||||
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
|
||||
if (integrationItemsToInsert.length > 0) {
|
||||
await transaction.insert(schema.integrationItems).values(integrationItemsToInsert);
|
||||
}
|
||||
|
||||
for (const item of updatedItems) {
|
||||
await transaction
|
||||
@@ -872,8 +915,6 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
|
||||
|
||||
for (const section of updatedSections) {
|
||||
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
|
||||
await transaction
|
||||
@@ -907,15 +948,6 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const removedIntegrationRelations = dbIntegrationRelations.filter(
|
||||
(dbRelation) =>
|
||||
!inputIntegrationRelations.some(
|
||||
(inputRelation) =>
|
||||
dbRelation.itemId === inputRelation.itemId &&
|
||||
dbRelation.integrationId === inputRelation.integrationId,
|
||||
),
|
||||
);
|
||||
|
||||
for (const relation of removedIntegrationRelations) {
|
||||
await transaction
|
||||
.delete(schema.integrationItems)
|
||||
@@ -927,134 +959,36 @@ export const boardRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
|
||||
const removedItems = filterRemovedItems(input.items, dbBoard.items);
|
||||
|
||||
const itemIds = removedItems.map((item) => item.id);
|
||||
if (itemIds.length > 0) {
|
||||
await transaction.delete(schema.items).where(inArray(schema.items.id, itemIds));
|
||||
if (itemIdsToRemove.length > 0) {
|
||||
await transaction.delete(schema.items).where(inArray(schema.items.id, itemIdsToRemove));
|
||||
}
|
||||
|
||||
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
|
||||
const sectionIds = removedSections.map((section) => section.id);
|
||||
|
||||
if (sectionIds.length > 0) {
|
||||
await transaction.delete(schema.sections).where(inArray(schema.sections.id, sectionIds));
|
||||
if (sectionIdsToRemove.length > 0) {
|
||||
await transaction.delete(schema.sections).where(inArray(schema.sections.id, sectionIdsToRemove));
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
|
||||
|
||||
if (addedSections.length > 0) {
|
||||
transaction
|
||||
.insert(sections)
|
||||
.values(
|
||||
addedSections.map((section) => ({
|
||||
id: section.id,
|
||||
kind: section.kind,
|
||||
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
|
||||
xOffset: section.kind === "dynamic" ? null : 0,
|
||||
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
|
||||
name: "name" in section ? section.name : null,
|
||||
boardId: dbBoard.id,
|
||||
})),
|
||||
)
|
||||
.run();
|
||||
|
||||
if (addedSections.some((section) => section.kind === "dynamic")) {
|
||||
transaction
|
||||
.insert(sectionLayouts)
|
||||
.values(
|
||||
addedSections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.flatMap((section) =>
|
||||
section.layouts.map(
|
||||
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
if (sectionsToInsert.length > 0) {
|
||||
transaction.insert(sections).values(sectionsToInsert).run();
|
||||
}
|
||||
|
||||
const addedItems = filterAddedItems(input.items, dbBoard.items);
|
||||
|
||||
if (addedItems.length > 0) {
|
||||
transaction
|
||||
.insert(items)
|
||||
.values(
|
||||
addedItems.map((item) => ({
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
options: superjson.stringify(item.options),
|
||||
advancedOptions: superjson.stringify(item.advancedOptions),
|
||||
boardId: dbBoard.id,
|
||||
})),
|
||||
)
|
||||
.run();
|
||||
transaction
|
||||
.insert(itemLayouts)
|
||||
.values(
|
||||
addedItems.flatMap((item) =>
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
if (sectionLayoutsToInsert.length > 0) {
|
||||
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
|
||||
}
|
||||
|
||||
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
|
||||
integrationIds.map((integrationId) => ({
|
||||
integrationId,
|
||||
itemId,
|
||||
})),
|
||||
);
|
||||
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
|
||||
integrationIds.map((integrationId) => ({
|
||||
integrationId,
|
||||
itemId,
|
||||
})),
|
||||
);
|
||||
const addedIntegrationRelations = inputIntegrationRelations.filter(
|
||||
(inputRelation) =>
|
||||
!dbIntegrationRelations.some(
|
||||
(dbRelation) =>
|
||||
dbRelation.itemId === inputRelation.itemId &&
|
||||
dbRelation.integrationId === inputRelation.integrationId,
|
||||
),
|
||||
);
|
||||
|
||||
if (addedIntegrationRelations.length > 0) {
|
||||
transaction
|
||||
.insert(integrationItems)
|
||||
.values(
|
||||
addedIntegrationRelations.map((relation) => ({
|
||||
itemId: relation.itemId,
|
||||
integrationId: relation.integrationId,
|
||||
})),
|
||||
)
|
||||
.run();
|
||||
if (itemsToInsert.length > 0) {
|
||||
transaction.insert(items).values(itemsToInsert).run();
|
||||
}
|
||||
|
||||
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
|
||||
if (itemLayoutsToInsert.length > 0) {
|
||||
transaction.insert(itemLayouts).values(itemLayoutsToInsert).run();
|
||||
}
|
||||
|
||||
if (integrationItemsToInsert.length > 0) {
|
||||
transaction.insert(integrationItems).values(integrationItemsToInsert).run();
|
||||
}
|
||||
|
||||
for (const item of updatedItems) {
|
||||
transaction
|
||||
@@ -1082,8 +1016,6 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
|
||||
|
||||
for (const section of updatedSections) {
|
||||
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
|
||||
transaction
|
||||
@@ -1116,15 +1048,6 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const removedIntegrationRelations = dbIntegrationRelations.filter(
|
||||
(dbRelation) =>
|
||||
!inputIntegrationRelations.some(
|
||||
(inputRelation) =>
|
||||
dbRelation.itemId === inputRelation.itemId &&
|
||||
dbRelation.integrationId === inputRelation.integrationId,
|
||||
),
|
||||
);
|
||||
|
||||
for (const relation of removedIntegrationRelations) {
|
||||
transaction
|
||||
.delete(integrationItems)
|
||||
@@ -1137,18 +1060,12 @@ export const boardRouter = createTRPCRouter({
|
||||
.run();
|
||||
}
|
||||
|
||||
const removedItems = filterRemovedItems(input.items, dbBoard.items);
|
||||
|
||||
const itemIds = removedItems.map((item) => item.id);
|
||||
if (itemIds.length > 0) {
|
||||
transaction.delete(items).where(inArray(items.id, itemIds)).run();
|
||||
if (itemIdsToRemove.length > 0) {
|
||||
transaction.delete(items).where(inArray(items.id, itemIdsToRemove)).run();
|
||||
}
|
||||
|
||||
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
|
||||
const sectionIds = removedSections.map((section) => section.id);
|
||||
|
||||
if (sectionIds.length > 0) {
|
||||
transaction.delete(sections).where(inArray(sections.id, sectionIds)).run();
|
||||
if (sectionIdsToRemove.length > 0) {
|
||||
transaction.delete(sections).where(inArray(sections.id, sectionIdsToRemove)).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -1318,7 +1235,7 @@ export const boardRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const content = await input.file.text();
|
||||
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
|
||||
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
|
||||
await importOldmarrAsync(ctx.db, oldmarr, input.configuration, ctx.session);
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const importRouter = createTRPCRouter({
|
||||
.requiresStep("import")
|
||||
.input(importInitialOldmarrInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await importInitialOldmarrAsync(ctx.db, input);
|
||||
await importInitialOldmarrAsync(ctx.db, input, ctx.session);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user