mirror of
https://github.com/C4illin/ConvertX.git
synced 2026-06-28 06:55:48 +00:00
Merge branch 'main' into changes
This commit is contained in:
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
@@ -12,10 +11,12 @@ A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Checklist:**
|
||||
|
||||
- [ ] I am accessing ConvertX over HTTPS or have `HTTP_ALLOWED=true`
|
||||
|
||||
26
.github/ISSUE_TEMPLATE/converter_request.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/converter_request.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Converter request
|
||||
about: Suggest a converter for this project
|
||||
title: "[Converter Request]"
|
||||
labels: "converter request"
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**What file formats are missing?**
|
||||
|
||||
<!-- Provide an example of what you would like to convert -->
|
||||
|
||||
**What converter should be added**
|
||||
|
||||
<!-- It has to be free and preferably open source -->
|
||||
|
||||
**Are you willing to add it?**
|
||||
|
||||
<!-- Adding a converter is very easy just copy one of the existing and modify it -->
|
||||
|
||||
- [ ] Yes
|
||||
- [ ] No
|
||||
|
||||
**Additional context**
|
||||
|
||||
<!-- Add any other context or screenshots about the feature request here. -->
|
||||
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -3,8 +3,7 @@ name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[Feature Request]"
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the solution you'd like**
|
||||
|
||||
31
.github/workflows/check-lint.yml
vendored
Normal file
31
.github/workflows/check-lint.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Check Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Run linting checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Run lint
|
||||
run: bun run lint
|
||||
14
.github/workflows/docker-publish.yml
vendored
14
.github/workflows/docker-publish.yml
vendored
@@ -31,8 +31,7 @@ jobs:
|
||||
contents: write
|
||||
packages: write
|
||||
attestations: write
|
||||
checks: write
|
||||
actions: read
|
||||
id-token: write
|
||||
|
||||
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-24.04' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
|
||||
|
||||
@@ -85,7 +84,8 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
outputs: type=image,name=ghcr.io/${{ env.REPO }},push-by-digest=true,name-canonical=true,push=true,oci-mediatypes=true
|
||||
outputs: type=image,name=ghcr.io/${{ env.REPO }},push-by-digest=true,name-canonical=true,oci-mediatypes=true
|
||||
push: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
@@ -104,19 +104,21 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
name: Merge Docker manifests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
attestations: write
|
||||
contents: read
|
||||
contents: write
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
## [0.14.1](https://github.com/C4illin/ConvertX/compare/v0.14.0...v0.14.1) (2025-06-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* change to baseline build ([6ea3058](https://github.com/C4illin/ConvertX/commit/6ea3058e66262f7a14633bddcecd5573948f524a)), closes [#311](https://github.com/C4illin/ConvertX/issues/311)
|
||||
- change to baseline build ([6ea3058](https://github.com/C4illin/ConvertX/commit/6ea3058e66262f7a14633bddcecd5573948f524a)), closes [#311](https://github.com/C4illin/ConvertX/issues/311)
|
||||
|
||||
## [0.14.0](https://github.com/C4illin/ConvertX/compare/v0.13.0...v0.14.0) (2025-06-03)
|
||||
|
||||
|
||||
12
README.md
12
README.md
@@ -94,7 +94,6 @@ All are optional, JWT_SECRET is recommended to be set.
|
||||
| LANGUAGE | en | Language to format date strings in, specified as a [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag) |
|
||||
| UNAUTHENTICATED_USER_SHARING | false | Shares conversion history between all unauthenticated users |
|
||||
|
||||
|
||||
### Docker images
|
||||
|
||||
There is a `:latest` tag that is updated with every release and a `:main` tag that is updated with every push to the main branch. `:latest` is recommended for normal use.
|
||||
@@ -133,19 +132,10 @@ Tutorial in chinese: <https://xzllll.com/24092901/>
|
||||
2. `bun install`
|
||||
3. `bun run dev`
|
||||
|
||||
Pull requests are welcome! See below and open issues for the list of todos.
|
||||
Pull requests are welcome! See open issues for the list of todos. The ones tagged with "converter request" are quite easy. Help with docs and cleaning up in issues are also very welcome!
|
||||
|
||||
Use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) for commit messages.
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] Add options for converters
|
||||
- [ ] Add tests
|
||||
- [ ] Make errors logs visible from the web ui
|
||||
- [ ] Add more converters:
|
||||
- [ ] [deark](https://github.com/jsummers/deark)
|
||||
- [ ] LibreOffice
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/C4illin/ConvertX/graphs/contributors">
|
||||
|
||||
5
bun.lock
5
bun.lock
@@ -22,7 +22,6 @@
|
||||
"@types/bun": "1.2.2",
|
||||
"@types/node": "^24.0.0",
|
||||
"@typescript-eslint/parser": "^8.34.0",
|
||||
"bun-types": "1.2.17",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-plugin-better-tailwindcss": "^3.1.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
@@ -275,7 +274,7 @@
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
|
||||
"bun-types": ["bun-types@1.2.2", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-RCbMH5elr9gjgDGDhkTTugA21XtJAy/9jkKe/G3WR2q17VPGhcquf9Sir6uay9iW+7P/BV0CAHA1XlHXMAVKHg=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
@@ -655,8 +654,6 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@types/bun/bun-types": ["bun-types@1.2.2", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-RCbMH5elr9gjgDGDhkTTugA21XtJAy/9jkKe/G3WR2q17VPGhcquf9Sir6uay9iW+7P/BV0CAHA1XlHXMAVKHg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.4", "", {}, "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"entry": ["src/index.tsx"],
|
||||
"project": ["src/**/*.ts", "src/**/*.tsx", "src/main.css"],
|
||||
"project": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"tailwind": {
|
||||
"entry": ["src/main.css"]
|
||||
},
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"@types/bun": "1.2.2",
|
||||
"@types/node": "^24.0.0",
|
||||
"@typescript-eslint/parser": "^8.34.0",
|
||||
"bun-types": "1.2.17",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-plugin-better-tailwindcss": "^3.1.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"automerge": true
|
||||
}
|
||||
},
|
||||
"ignoreDeps": ["bun-types", "@types/bun"]
|
||||
}
|
||||
|
||||
@@ -66,24 +66,20 @@ export async function convert(
|
||||
options?: unknown,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
"ebook-convert",
|
||||
[filePath, targetPath],
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(`error: ${error}`);
|
||||
}
|
||||
execFile("ebook-convert", [filePath, targetPath], (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(`error: ${error}`);
|
||||
}
|
||||
|
||||
if (stdout) {
|
||||
console.log(`stdout: ${stdout}`);
|
||||
}
|
||||
if (stdout) {
|
||||
console.log(`stdout: ${stdout}`);
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.error(`stderr: ${stderr}`);
|
||||
}
|
||||
if (stderr) {
|
||||
console.error(`stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
resolve("Done");
|
||||
},
|
||||
);
|
||||
resolve("Done");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Cookie } from "elysia";
|
||||
import db from "../db/db";
|
||||
import { MAX_CONVERT_PROCESS } from "../helpers/env";
|
||||
import { normalizeFiletype, normalizeOutputFiletype } from "../helpers/normalizeFiletype";
|
||||
@@ -119,11 +120,11 @@ const properties: Record<
|
||||
};
|
||||
|
||||
function chunks<T>(arr: T[], size: number): T[][] {
|
||||
if(size <= 0){
|
||||
return [arr]
|
||||
if (size <= 0) {
|
||||
return [arr];
|
||||
}
|
||||
return Array.from({ length: Math.ceil(arr.length / size) }, (_: T, i: number) =>
|
||||
arr.slice(i * size, i * size + size)
|
||||
arr.slice(i * size, i * size + size),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,17 +134,15 @@ export async function handleConvert(
|
||||
userOutputDir: string,
|
||||
convertTo: string,
|
||||
converterName: string,
|
||||
jobId: any
|
||||
jobId: Cookie<string | undefined>,
|
||||
) {
|
||||
|
||||
const query = db.query(
|
||||
"INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?1, ?2, ?3, ?4)",
|
||||
);
|
||||
|
||||
|
||||
for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) {
|
||||
const toProcess: Promise<string>[] = [];
|
||||
for(const fileName of chunk) {
|
||||
for (const fileName of chunk) {
|
||||
const filePath = `${userUploadsDir}${fileName}`;
|
||||
const fileTypeOrig = fileName.split(".").pop() ?? "";
|
||||
const fileType = normalizeFiletype(fileTypeOrig);
|
||||
@@ -154,28 +153,23 @@ export async function handleConvert(
|
||||
);
|
||||
const targetPath = `${userOutputDir}${newFileName}`;
|
||||
toProcess.push(
|
||||
new Promise((resolve, reject) => {
|
||||
mainConverter(
|
||||
filePath,
|
||||
fileType,
|
||||
convertTo,
|
||||
targetPath,
|
||||
{},
|
||||
converterName,
|
||||
).then(r => {
|
||||
new Promise((resolve, reject) => {
|
||||
mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName)
|
||||
.then((r) => {
|
||||
if (jobId.value) {
|
||||
query.run(jobId.value, fileName, newFileName, r);
|
||||
}
|
||||
resolve(r);
|
||||
}).catch(c => reject(c));
|
||||
})
|
||||
})
|
||||
.catch((c) => reject(c));
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(toProcess);
|
||||
}
|
||||
}
|
||||
|
||||
export async function mainConverter(
|
||||
async function mainConverter(
|
||||
inputFilePath: string,
|
||||
fileTypeOriginal: string,
|
||||
convertTo: string,
|
||||
|
||||
@@ -23,7 +23,7 @@ export function convert(
|
||||
// msgconvert will output to the same directory as the input file with .eml extension
|
||||
// We need to use --outfile to specify the target path
|
||||
const args = ["--outfile", targetPath, filePath];
|
||||
|
||||
|
||||
execFile("msgconvert", args, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`msgconvert failed: ${error.message}`));
|
||||
@@ -33,13 +33,19 @@ export function convert(
|
||||
if (stderr) {
|
||||
// Log sanitized stderr to avoid exposing sensitive paths
|
||||
const sanitizedStderr = stderr.replace(/(\/[^\s]+)/g, "[REDACTED_PATH]");
|
||||
console.warn(`msgconvert stderr: ${sanitizedStderr.length > 200 ? sanitizedStderr.slice(0, 200) + '...' : sanitizedStderr}`);
|
||||
console.warn(
|
||||
`msgconvert stderr: ${sanitizedStderr.length > 200 ? sanitizedStderr.slice(0, 200) + "..." : sanitizedStderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
resolve(targetPath);
|
||||
});
|
||||
} else {
|
||||
reject(new Error(`Unsupported conversion from ${fileType} to ${convertTo}. Only MSG to EML conversion is currently supported.`));
|
||||
reject(
|
||||
new Error(
|
||||
`Unsupported conversion from ${fileType} to ${convertTo}. Only MSG to EML conversion is currently supported.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ export const WEBROOT = process.env.WEBROOT ?? "";
|
||||
|
||||
export const LANGUAGE = process.env.LANGUAGE?.toLowerCase() || "en";
|
||||
|
||||
export const MAX_CONVERT_PROCESS = process.env.MAX_CONVERT_PROCESS && Number(process.env.MAX_CONVERT_PROCESS) > 0 ? Number(process.env.MAX_CONVERT_PROCESS) : 0
|
||||
export const MAX_CONVERT_PROCESS =
|
||||
process.env.MAX_CONVERT_PROCESS && Number(process.env.MAX_CONVERT_PROCESS) > 0
|
||||
? Number(process.env.MAX_CONVERT_PROCESS)
|
||||
: 0;
|
||||
|
||||
export const UNAUTHENTICATED_USER_SHARING =
|
||||
process.env.UNAUTHENTICATED_USER_SHARING?.toLowerCase() === "true" || false;
|
||||
process.env.UNAUTHENTICATED_USER_SHARING?.toLowerCase() === "true" || false;
|
||||
|
||||
@@ -46,6 +46,11 @@ export const convert = new Elysia().use(userService).post(
|
||||
|
||||
const convertTo = normalizeFiletype(body.convert_to.split(",")[0] ?? "");
|
||||
const converterName = body.convert_to.split(",")[1];
|
||||
|
||||
if (!converterName) {
|
||||
return redirect(`${WEBROOT}/`, 302);
|
||||
}
|
||||
|
||||
const fileNames = JSON.parse(body.file_names) as string[];
|
||||
|
||||
for (let i = 0; i < fileNames.length; i++) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import path from "node:path";
|
||||
import { Elysia } from "elysia";
|
||||
import sanitize from "sanitize-filename";
|
||||
import * as tar from "tar";
|
||||
import { outputDir } from "..";
|
||||
import db from "../db/db";
|
||||
import { WEBROOT } from "../helpers/env";
|
||||
import { userService } from "./user";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
|
||||
export const download = new Elysia()
|
||||
.use(userService)
|
||||
@@ -58,8 +58,17 @@ export const download = new Elysia()
|
||||
const userId = decodeURIComponent(params.userId);
|
||||
const jobId = decodeURIComponent(params.jobId);
|
||||
const outputPath = `${outputDir}${userId}/${jobId}`;
|
||||
const outputTar = path.join(outputPath, `converted_files_${jobId}.tar`)
|
||||
const outputTar = path.join(outputPath, `converted_files_${jobId}.tar`);
|
||||
|
||||
await tar.create({file: outputTar, cwd: outputPath, filter: (path) => { return !path.match(".*\\.tar"); }}, ["."]);
|
||||
await tar.create(
|
||||
{
|
||||
file: outputTar,
|
||||
cwd: outputPath,
|
||||
filter: (path) => {
|
||||
return !path.match(".*\\.tar");
|
||||
},
|
||||
},
|
||||
["."],
|
||||
);
|
||||
return Bun.file(outputTar);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Html } from "@elysiajs/html";
|
||||
import { JWTPayloadSpec } from "@elysiajs/jwt";
|
||||
import { Elysia } from "elysia";
|
||||
import { BaseHtml } from "../components/base";
|
||||
import { Header } from "../components/header";
|
||||
@@ -6,7 +7,6 @@ import db from "../db/db";
|
||||
import { Filename, Jobs } from "../db/types";
|
||||
import { ALLOW_UNAUTHENTICATED, WEBROOT } from "../helpers/env";
|
||||
import { userService } from "./user";
|
||||
import { JWTPayloadSpec } from "@elysiajs/jwt";
|
||||
|
||||
function ResultsArticle({
|
||||
user,
|
||||
@@ -26,7 +26,7 @@ function ResultsArticle({
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-xl">Results</h1>
|
||||
<div>
|
||||
<a
|
||||
<a
|
||||
style={files.length !== job.num_files ? "pointer-events: none;" : ""}
|
||||
href={`${WEBROOT}/archive/${user.id}/${job.id}`}
|
||||
download={`converted_files_${job.id}.tar`}
|
||||
@@ -35,7 +35,7 @@ function ResultsArticle({
|
||||
type="button"
|
||||
class="float-right w-40 btn-primary"
|
||||
{...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")}
|
||||
>
|
||||
>
|
||||
{files.length === job.num_files ? "Download All" : "Converting..."}
|
||||
</button>
|
||||
</a>
|
||||
|
||||
@@ -34,7 +34,9 @@ export const root = new Elysia()
|
||||
let user: ({ id: string } & JWTPayloadSpec) | false = false;
|
||||
if (ALLOW_UNAUTHENTICATED) {
|
||||
const newUserId = String(
|
||||
UNAUTHENTICATED_USER_SHARING ? 0 : randomInt(2 ** 24, Math.min(2 ** 48 + 2 ** 24 - 1, Number.MAX_SAFE_INTEGER)),
|
||||
UNAUTHENTICATED_USER_SHARING
|
||||
? 0
|
||||
: randomInt(2 ** 24, Math.min(2 ** 48 + 2 ** 24 - 1, Number.MAX_SAFE_INTEGER)),
|
||||
);
|
||||
const accessToken = await jwt.sign({
|
||||
id: newUserId,
|
||||
|
||||
@@ -44,4 +44,4 @@
|
||||
/* lime-400 */
|
||||
--accent-400: oklch(84.1% 0.238 128.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user