fix v2 release CI failures
This commit is contained in:
1 parent
4ac9d21bc1
commit
72caa31b57
5 files changed
+278
-218
No files matched your search
@@ -12,6 +12,7 @@ COPY apps/docs ./apps/docs
|
||||
COPY packages/custom-widgets ./packages/custom-widgets
|
||||
COPY packages/definitions ./packages/definitions
|
||||
COPY packages/workshop ./packages/workshop
|
||||
COPY scripts/build-workshop-validator.mjs ./scripts/build-workshop-validator.mjs
|
||||
COPY tooling/typescript ./tooling/typescript
|
||||
RUN pnpm config set store-dir /pnpm/store && pnpm install --filter @homarr/docs... --frozen-lockfile
|
||||
RUN pnpm build:workshop-validator
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import type {
|
||||
CustomWidgetCreateInput,
|
||||
CustomWidgetSecretKind,
|
||||
CustomWidgetSource,
|
||||
HomarrCustomWidgetV2,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { hasSameSecretBinding } from "./secret-policy";
|
||||
import { serializeCustomWidgetDefinition } from "./stored-definition";
|
||||
|
||||
type CustomWidgetSecretInput = CustomWidgetCreateInput["secrets"][number];
|
||||
|
||||
export async function setCustomWidgetSecret(db: Database, definitionId: string, secret: CustomWidgetSecretInput) {
|
||||
const updatedAt = new Date();
|
||||
const secretRow = {
|
||||
definitionId,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt,
|
||||
};
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(database, schema) {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, definitionId),
|
||||
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(schema.customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
);
|
||||
await transaction.insert(schema.customWidgetSecrets).values(secretRow);
|
||||
await transaction
|
||||
.update(schema.customWidgetDefinitions)
|
||||
.set({ updatedAt })
|
||||
.where(eq(schema.customWidgetDefinitions.id, definitionId));
|
||||
});
|
||||
},
|
||||
handleSync(database) {
|
||||
database.transaction((transaction) => {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, definitionId),
|
||||
eq(customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction.insert(customWidgetSecrets).values(secretRow).run();
|
||||
transaction
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ updatedAt })
|
||||
.where(eq(customWidgetDefinitions.id, definitionId))
|
||||
.run();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function configureCustomWidgetSource(
|
||||
db: Database,
|
||||
input: {
|
||||
definitionId: string;
|
||||
sourceId: string;
|
||||
definition: HomarrCustomWidgetV2;
|
||||
previousSource: CustomWidgetSource;
|
||||
source: CustomWidgetSource;
|
||||
secrets: CustomWidgetCreateInput["secrets"];
|
||||
},
|
||||
) {
|
||||
const definitionChanges = {
|
||||
...serializeCustomWidgetDefinition(input.definition),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const bindingChanged = !hasSameSecretBinding(input.previousSource, input.source);
|
||||
const secretRows = input.secrets.map((secret) => ({
|
||||
definitionId: input.definitionId,
|
||||
sourceId: input.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(database, schema) {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.update(schema.customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
|
||||
if (bindingChanged) {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const secret of secretRows) {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
|
||||
eq(schema.customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
);
|
||||
await transaction.insert(schema.customWidgetSecrets).values(secret);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(database) {
|
||||
database.transaction((transaction) => {
|
||||
transaction
|
||||
.update(customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(customWidgetDefinitions.id, input.definitionId))
|
||||
.run();
|
||||
if (bindingChanged) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.sourceId),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
for (const secret of secretRows) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction.insert(customWidgetSecrets).values(secret).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearCustomWidgetSecret(
|
||||
db: Database,
|
||||
input: {
|
||||
definitionId: string;
|
||||
sourceId: string;
|
||||
kind: CustomWidgetSecretKind;
|
||||
},
|
||||
) {
|
||||
const updatedAt = new Date();
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(database, schema) {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
|
||||
eq(schema.customWidgetSecrets.kind, input.kind),
|
||||
),
|
||||
);
|
||||
await transaction
|
||||
.update(schema.customWidgetDefinitions)
|
||||
.set({ updatedAt })
|
||||
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
|
||||
});
|
||||
},
|
||||
handleSync(database) {
|
||||
database.transaction((transaction) => {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.sourceId),
|
||||
eq(customWidgetSecrets.kind, input.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ updatedAt })
|
||||
.where(eq(customWidgetDefinitions.id, input.definitionId))
|
||||
.run();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { and, eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import {
|
||||
customWidgetSecretInputSchema,
|
||||
customWidgetSecretsInputSchema,
|
||||
@@ -16,9 +15,10 @@ import {
|
||||
createCustomWidgetConfigurationRequest,
|
||||
getCustomWidgetConfigurationRequestForUser,
|
||||
} from "./configuration-requests";
|
||||
import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
|
||||
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
|
||||
import { clearCustomWidgetSecret, configureCustomWidgetSource, setCustomWidgetSecret } from "./secret-persistence";
|
||||
import { getPreviewSession } from "./preview-sessions";
|
||||
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
|
||||
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
|
||||
const secretRequestInputSchema = z
|
||||
.object({
|
||||
@@ -51,54 +51,7 @@ export const secretProcedures = {
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const definition = parseStoredCustomWidgetDefinition(existing);
|
||||
assertSecretSources(definition.sources, [input.secret]);
|
||||
const updatedAt = new Date();
|
||||
const secretRow = {
|
||||
definitionId: input.definitionId,
|
||||
sourceId: input.secret.sourceId,
|
||||
kind: input.secret.kind,
|
||||
encryptedValue: encryptSecret(input.secret.value),
|
||||
updatedAt,
|
||||
};
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(schema.customWidgetSecrets.sourceId, input.secret.sourceId),
|
||||
eq(schema.customWidgetSecrets.kind, input.secret.kind),
|
||||
),
|
||||
);
|
||||
await transaction.insert(schema.customWidgetSecrets).values(secretRow);
|
||||
await transaction
|
||||
.update(schema.customWidgetDefinitions)
|
||||
.set({ updatedAt })
|
||||
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.secret.sourceId),
|
||||
eq(customWidgetSecrets.kind, input.secret.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction.insert(customWidgetSecrets).values(secretRow).run();
|
||||
transaction
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ updatedAt })
|
||||
.where(eq(customWidgetDefinitions.id, input.definitionId))
|
||||
.run();
|
||||
});
|
||||
},
|
||||
});
|
||||
await setCustomWidgetSecret(ctx.db, input.definitionId, input.secret);
|
||||
return { sourceId: input.secret.sourceId, kind: input.secret.kind, isSet: true };
|
||||
}),
|
||||
|
||||
@@ -128,79 +81,13 @@ export const secretProcedures = {
|
||||
});
|
||||
assertSecretSources({ [input.sourceId]: source }, input.secrets);
|
||||
const updated = { ...definition, sources: { ...definition.sources, [input.sourceId]: source } };
|
||||
const definitionChanges = { ...serializeCustomWidgetDefinition(updated), updatedAt: new Date() };
|
||||
const bindingChanged = !hasSameSecretBinding(current, source);
|
||||
const secretRows = input.secrets.map((secret) => ({
|
||||
await configureCustomWidgetSource(ctx.db, {
|
||||
definitionId: input.definitionId,
|
||||
sourceId: input.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.update(schema.customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
|
||||
if (bindingChanged) {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const secret of secretRows) {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
|
||||
eq(schema.customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
);
|
||||
await transaction.insert(schema.customWidgetSecrets).values(secret);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction
|
||||
.update(customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(customWidgetDefinitions.id, input.definitionId))
|
||||
.run();
|
||||
if (bindingChanged) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.sourceId),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
for (const secret of secretRows) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction.insert(customWidgetSecrets).values(secret).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
definition: updated,
|
||||
previousSource: current,
|
||||
source,
|
||||
secrets: input.secrets,
|
||||
});
|
||||
return {
|
||||
definitionId: input.definitionId,
|
||||
@@ -283,45 +170,7 @@ export const secretProcedures = {
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const updatedAt = new Date();
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
|
||||
eq(schema.customWidgetSecrets.kind, input.kind),
|
||||
),
|
||||
);
|
||||
await transaction
|
||||
.update(schema.customWidgetDefinitions)
|
||||
.set({ updatedAt })
|
||||
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.sourceId),
|
||||
eq(customWidgetSecrets.kind, input.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ updatedAt })
|
||||
.where(eq(customWidgetDefinitions.id, input.definitionId))
|
||||
.run();
|
||||
});
|
||||
},
|
||||
});
|
||||
await clearCustomWidgetSecret(ctx.db, input);
|
||||
return { sourceId: input.sourceId, kind: input.kind, isSet: false };
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { createPinnedAgent } from "./network-policy";
|
||||
|
||||
const DISPATCHER_CLOSE_GRACE_MS = 1_000;
|
||||
|
||||
type CustomWidgetDispatcher = ReturnType<typeof createPinnedAgent>;
|
||||
|
||||
export async function closeDispatcher(
|
||||
dispatcher: CustomWidgetDispatcher,
|
||||
deadlineSignal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const cleanupController = new AbortController();
|
||||
const timeout = setTimeout(() => cleanupController.abort(), DISPATCHER_CLOSE_GRACE_MS);
|
||||
try {
|
||||
await abortable(dispatcher.close(), AbortSignal.any([deadlineSignal, cleanupController.signal]));
|
||||
return true;
|
||||
} catch (error) {
|
||||
detachDispatcherDestroy(dispatcher);
|
||||
if (cleanupController.signal.aborted && !deadlineSignal.aborted) return false;
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function detachDispatcherDestroy(dispatcher: CustomWidgetDispatcher): void {
|
||||
try {
|
||||
void dispatcher.destroy().catch(() => undefined);
|
||||
} catch {
|
||||
// Cleanup is best effort after its bounded grace period.
|
||||
}
|
||||
}
|
||||
|
||||
function abortable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(signal.reason ?? createAbortError());
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(signal.reason ?? createAbortError());
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
operation.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error("Operation aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
resolveSameOriginTarget,
|
||||
validateCustomWidgetUrl,
|
||||
} from "./network-policy";
|
||||
import { closeDispatcher } from "./request-dispatcher-lifecycle";
|
||||
import { parseResponseBody } from "./response";
|
||||
|
||||
export {
|
||||
@@ -30,7 +31,6 @@ export {
|
||||
|
||||
export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const DISPATCHER_CLOSE_GRACE_MS = 1_000;
|
||||
const MAX_QUERY_REDIRECTS = 3;
|
||||
export const MAX_REQUEST_DURATION_MS = 45_000;
|
||||
const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
|
||||
@@ -213,60 +213,6 @@ async function performRequestWithinDeadline(
|
||||
}
|
||||
}
|
||||
|
||||
async function closeDispatcher(
|
||||
dispatcher: ReturnType<typeof createPinnedAgent>,
|
||||
deadlineSignal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const cleanupController = new AbortController();
|
||||
const timeout = setTimeout(() => cleanupController.abort(), DISPATCHER_CLOSE_GRACE_MS);
|
||||
try {
|
||||
await abortable(dispatcher.close(), AbortSignal.any([deadlineSignal, cleanupController.signal]));
|
||||
return true;
|
||||
} catch (error) {
|
||||
detachDispatcherDestroy(dispatcher);
|
||||
if (cleanupController.signal.aborted && !deadlineSignal.aborted) return false;
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function detachDispatcherDestroy(dispatcher: ReturnType<typeof createPinnedAgent>): void {
|
||||
try {
|
||||
void dispatcher.destroy().catch(() => undefined);
|
||||
} catch {
|
||||
// Cleanup is best effort after its bounded grace period.
|
||||
}
|
||||
}
|
||||
|
||||
function abortable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(signal.reason ?? createAbortError());
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(signal.reason ?? createAbortError());
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
operation.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error("Operation aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
export function isCustomWidgetRequestTimeoutError(error: unknown, ...signals: readonly AbortSignal[]): boolean {
|
||||
if (signals.some((signal) => signal.aborted)) return true;
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
Reference in new issue
Block a user