chore(ci): migrate to Gitea Actions
This commit is contained in:
@@ -1,213 +0,0 @@
|
||||
# Copilot Instructions for nidus
|
||||
|
||||
A self-hosted CalDAV, CardDAV, and WebDAV server written in Go, backed by a
|
||||
filesystem store, with calendar/address book sharing grants tracked in a
|
||||
small SQLite database. HTTP Basic Auth (bcrypt) with per-user isolated
|
||||
collections. A small server-rendered web UI (templ + Tailwind + htmx) at
|
||||
`/web/` lets users log in and manage their shares.
|
||||
|
||||
## Build, test, lint
|
||||
|
||||
```bash
|
||||
make build # go build -o bin/davserver ./cmd/server
|
||||
make run # build + ./bin/davserver -config config.yaml
|
||||
make test # go test ./... -v -race
|
||||
make lint # golangci-lint run ./...
|
||||
make tidy # go mod tidy
|
||||
go test ./internal/store/ -run TestStoreRoundTrip -v # single test
|
||||
make templ-generate # regenerate *_templ.go after editing internal/web/templates/*.templ
|
||||
make web-css # templ-generate + rebuild web/static/app.css (needs `make web-deps` once, Node.js/npm)
|
||||
make web-ts # compile web/ts/*.ts to web/static/*.js
|
||||
make web-assets # web-css + web-ts (everything under web/static/)
|
||||
```
|
||||
|
||||
Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
|
||||
`internal/db/shares_test.go`, `internal/caldav/backend_test.go`,
|
||||
`internal/carddav/backend_test.go`, `internal/web/server_test.go`,
|
||||
`tools/nidusctl/main_test.go`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `cmd/server/main.go` — entrypoint. Loads config, builds the `slog.Logger`,
|
||||
constructs the `store.Store` and `db.DB`, lists all users/calendars/
|
||||
address-books from the DB (`dbase.ListUsers`/`ListCalendars`/
|
||||
`ListAddressBooks`) to pre-create their collections on disk, warns if
|
||||
zero users exist (`nidusctl user create ...`), wires up `auth.Middleware`,
|
||||
and builds the `http.ServeMux` (`buildMux`). Routes: `/cal/`, `/card/`,
|
||||
`/files/`, `/.well-known/{caldav,carddav}`, `/healthz` (unauthenticated),
|
||||
and `/` (welcome page on GET/HEAD only; any other method — e.g. a WebDAV
|
||||
client pointed at the wrong URL — gets `405` instead of a misleading
|
||||
`200`).
|
||||
- `internal/config` — YAML config loading (`config.Load`), defaults
|
||||
(`applyDefaults`), and validation (`validate`). Holds only server/auth/
|
||||
storage/logging/TLS settings — **no user, calendar, or address-book
|
||||
data**; all of that lives in `internal/db` now (see below).
|
||||
- `internal/auth` — HTTP Basic Auth middleware (`auth.Middleware.Wrap`).
|
||||
Takes a `*db.DB` and validates credentials via `dbase.GetUser` +
|
||||
`dbase.VerifyPassword` (bcrypt), then stores a
|
||||
`*Principal{Username, DisplayName, Email}` in the request context.
|
||||
Downstream code retrieves it with `auth.FromContext(ctx)` — every backend
|
||||
method needs this and returns `webdav.NewHTTPError(http.StatusUnauthorized, ...)`
|
||||
if it's nil. `auth.NewContext(ctx, p)` is the test-only inverse, used to
|
||||
build authenticated contexts without a real Basic Auth handshake.
|
||||
- `internal/store` — the single source of truth for all persisted data.
|
||||
A thin filesystem KV abstraction: `<data_dir>/<user>/<collection>/<objectID>`.
|
||||
All collection/object names pass through `sanitize()` (via
|
||||
`filepath.Base` + strip `..`) to prevent path traversal — preserve this
|
||||
when adding new store methods. Writes use temp-file + rename for atomicity
|
||||
(`PutObject`). Locking is sharded per-user (`lockFor(user)`, a
|
||||
`map[string]*sync.RWMutex` guarded by its own mutex) rather than one
|
||||
global lock, so different users' requests don't serialize against each
|
||||
other.
|
||||
- `internal/caldav` and `internal/carddav` — implement the
|
||||
`caldav.Backend`/`carddav.Backend` interfaces from `github.com/emersion/go-webdav`
|
||||
on top of `store.Store`. Calendars are stored as collections prefixed
|
||||
`cal-<name>` and address books as `card-<name>` (see `ListCalendars`,
|
||||
`parseCalPath`). **The URL scheme has no username segment**, but DOES
|
||||
have a fixed literal `home` segment: `/cal/` (principal), `/cal/home/`
|
||||
(calendar-home-set), `/cal/home/<calname>/` (calendar),
|
||||
`/cal/home/<calname>/<objid>` (object) — and equivalently
|
||||
`/card/`, `/card/home/`, `/card/home/<bookname>/`,
|
||||
`/card/home/<bookname>/<objid>` for carddav. These are identical for
|
||||
every user; the acting user always comes from `auth.FromContext(ctx)`,
|
||||
never from the path. **The `home` segment is load-bearing, not
|
||||
cosmetic**: go-webdav's `caldav`/`carddav` server (in the
|
||||
`github.com/emersion/go-webdav` dependency, not our code) classifies
|
||||
each request purely by counting URL path segments relative to the
|
||||
handler's `Prefix` (which we leave `""`) — 1 segment = user principal, 2
|
||||
= home-set, 3 = calendar/address book, 4 = object. If the segment counts
|
||||
don't line up (e.g. removing `home` would make `/cal/` and
|
||||
`/cal/<calname>/` collapse to 1 and 2 segments, misclassifying the
|
||||
calendar collection itself as the home-set), PROPFIND requests silently
|
||||
return an empty `<multistatus>` (200/207, zero `<response>` elements) —
|
||||
no error, just nothing found, which breaks client auto-discovery (e.g.
|
||||
DAVx5 reporting "no resources found"). Keep this in mind when touching
|
||||
`parseCalPath`/`parseObjPath`/`parseBookPath`,
|
||||
`calHomePath`/`cardHomePath`, or `CurrentUserPrincipal` — always
|
||||
preserve the exact segment depth at each level. Query methods
|
||||
(`QueryCalendarObjects`) currently list all objects and filter in-memory
|
||||
via `caldav.Filter` — fine for small collections, not optimized for
|
||||
scale.
|
||||
**Sharing**: both backends take an `*db.DB` (required — used for both
|
||||
the base `ListCalendars`/`ListAddressBooks`/`Create*`/`Delete*`
|
||||
operations and sharing). A calendar/address book shared with a user is exposed under
|
||||
the synthetic local name `<owner>~<name>` (see `sharedNameSep`,
|
||||
`sharedCalendarName`/`sharedBookName`) in that user's own home-set —
|
||||
`resolveCalendar`/`resolveBook` split the local name back into
|
||||
owner+real name and check the grant's permission (`db.PermRead`/
|
||||
`db.PermWrite`) via `dbase.CalendarShareFor`/`AddressBookShareFor`
|
||||
before allowing reads (any share) or writes (write share only). The
|
||||
shared data is never copied — it's read/written directly under the
|
||||
owner's own `store.Store` namespace, just addressed via the synthetic
|
||||
name from the grantee's requests.
|
||||
- `internal/db` — a small `database/sql` wrapper around
|
||||
`modernc.org/sqlite` (pure Go, no CGO) at `<data_dir>/nidus.db`. Only
|
||||
one open connection is used (`SetMaxOpenConns(1)`) since SQLite allows a
|
||||
single writer; this is intentionally simple and not meant to scale to
|
||||
heavy concurrent write load. Schema lives in `migrate()`; there's no
|
||||
migration framework, just idempotent `CREATE TABLE IF NOT EXISTS`.
|
||||
Foreign keys are enabled per-connection (`?_pragma=foreign_keys(1)` in
|
||||
the DSN). **This is now the single source of truth for users,
|
||||
calendars, and address books** (`internal/db/users.go`):
|
||||
`CreateUser`/`SetPassword`/`DeleteUser`/`GetUser`/`VerifyPassword`/
|
||||
`ListUsers`, and `CreateCalendar`/`DeleteCalendar`/`ListCalendars`,
|
||||
`CreateAddressBook`/`DeleteAddressBook`/`ListAddressBooks`. `users` is
|
||||
the parent table; `calendars`/`addressbooks` cascade-delete via FK on
|
||||
`DeleteUser`; `calendar_shares`/`addressbook_shares`/`web_sessions`
|
||||
reference usernames as plain strings (no FK) so `DeleteUser` explicitly
|
||||
cleans those up in a transaction. `modernc.org/sqlite` has no typed
|
||||
unique-constraint error, so `isUniqueConstraintErr()` string-matches the
|
||||
driver's error message.
|
||||
- `internal/webdav` — plain-file WebDAV via `golang.org/x/net/webdav`,
|
||||
mounted at the single fixed URL `/files/` for all users (no username in
|
||||
the path either). `NewHandler` caches one `*xwebdav.Handler` per
|
||||
authenticated username (keyed off `auth.FromContext`), each rooted at
|
||||
`<data_dir>/files/<username>/` on disk with its own persistent
|
||||
`LockSystem` — the handler (and its lock table) must be created once and
|
||||
reused, not per-request, or LOCK/UNLOCK state resets on every call.
|
||||
- `tools/hashpwd` — standalone CLI (`go run ./tools/hashpwd <password>`) to
|
||||
generate bcrypt hashes for ad-hoc testing (no longer needed for normal
|
||||
user setup — see `nidusctl user create` below).
|
||||
- `tools/nidusctl` — standalone admin CLI (`go run ./tools/nidusctl
|
||||
-config config.yaml <user|calendar|addressbook> ...`) — the only way to
|
||||
create/delete users, calendars, and address books, plus manage sharing
|
||||
grants (`calendar|addressbook share|unshare|shares`). It's a thin
|
||||
argv-parsing wrapper around `db.DB`'s methods (`tools/nidusctl/main.go`
|
||||
routes subcommands, `tools/nidusctl/users.go` implements `user
|
||||
create/delete/list/passwd` with interactive masked password prompting
|
||||
via `golang.org/x/term`, falling back to a plain stdin read when not a
|
||||
TTY) — no server interaction, no daemon, no RPC; it just opens the same
|
||||
SQLite file the running server uses. Changes take effect immediately
|
||||
without restarting the server since nothing is cached.
|
||||
- `internal/web` — the web UI, mounted at `/web/` in `cmd/server/main.go`
|
||||
(`mux.Handle("/web/", http.StripPrefix("/web", web.NewServer(cfg, st,
|
||||
dbase, logger).Handler(webstatic.FS())))`, so `Server.Handler`'s own
|
||||
routes are all unprefixed — `/login`, `/`, `/shares/...` — and only the
|
||||
outer mux adds the `/web` prefix), entirely separate from `internal/auth`'s
|
||||
Basic Auth: logins go through
|
||||
`/web/login` (username/password checked against the DB the same way
|
||||
Basic Auth does, via `dbase.VerifyPassword`) and issue an opaque random session token
|
||||
stored in the `web_sessions` SQLite table (`db.CreateSession`/
|
||||
`SessionUser`/`DeleteSession`, see `internal/db/sessions.go`), set as an
|
||||
`HttpOnly` cookie (`sessionCookieName` in `internal/web/session.go`).
|
||||
`requireLogin` is the auth-guard middleware for authenticated routes,
|
||||
storing the username in the request context (`userFromContext`).
|
||||
`internal/web/dashboard.go` renders the logged-in user's own
|
||||
calendars/address books plus who they're shared with
|
||||
(`SharesOfCalendar`/`SharesOfAddressBook`) and what's shared with them
|
||||
(`CalendarsSharedWith`/`AddressBooksSharedWith`); `resourceCards(username)`
|
||||
is the shared helper (also used by `internal/web/resources.go`, see
|
||||
below) that builds the list of `templates.ResourceCard`s from the DB.
|
||||
`internal/web/resources.go` handles POST (create) and DELETE (delete) at
|
||||
`/web/resources/{calendar,addressbook}`, validating names against
|
||||
`resourceNameRe` (`^[a-zA-Z0-9_-]{1,64}$`) and re-rendering the whole
|
||||
`#resources` list (`templates.ResourceList`) since the *set* of cards
|
||||
changes (unlike a share update, which only touches one card).
|
||||
`internal/web/shares.go`
|
||||
handles POST (create/update share) and DELETE (revoke) at
|
||||
`/web/shares/{calendar,addressbook}`, re-rendering just the affected
|
||||
resource card for htmx's `hx-swap="outerHTML"`; it always checks
|
||||
`ownsResource` first so a user can only share resources actually
|
||||
configured for their own account (never someone else's, even via a
|
||||
forged form post). **htmx v2 quirk**: `hx-delete` requests send
|
||||
`hx-vals`/form params as URL **query string** parameters, not a request
|
||||
body (unlike POST/PUT/PATCH) — `handleShare` special-cases
|
||||
`r.Method == http.MethodDelete` to read from `r.URL.Query()` instead of
|
||||
calling `r.ParseForm()`. Templates live in `internal/web/templates/*.templ`
|
||||
(compiled to `*_templ.go` via `templ generate`/`make templ-generate` —
|
||||
regenerate after editing any `.templ` file, the generated files are
|
||||
committed). Styling is Tailwind v4, scanned directly over the generated
|
||||
`_templ.go` files (`web/input.css`'s `@source` directives) and compiled
|
||||
to `web/static/app.css` via `make web-css` (needs Node/npm — see
|
||||
`web/package.json`); htmx itself is vendored as a static file
|
||||
(`web/static/htmx.min.js`, not npm-installed) to avoid a CDN dependency.
|
||||
Client-side-only logic (currently just the login page's password-visibility
|
||||
toggle) is written in TypeScript under `web/ts/*.ts`, compiled to plain
|
||||
JS via `tsc` (`web/tsconfig.json`, `make web-ts`) into `web/static/*.js`
|
||||
as ES modules (`<script type="module">`). All static assets (CSS, JS,
|
||||
vendored htmx) are embedded into the Go binary at build time via
|
||||
`web/staticassets.go` (`//go:embed static`), so the compiled server has
|
||||
no runtime dependency on Node.js or the `web/` directory being present —
|
||||
Node/npm are only needed when actually changing templates/styles/TS
|
||||
(`make web-assets` rebuilds everything under `web/static/`).
|
||||
|
||||
## Conventions
|
||||
|
||||
- **No username in any DAV URL** (`/cal/`, `/card/`, `/files/` are the same
|
||||
for every account) — the acting user is always resolved from the Basic
|
||||
Auth identity (`auth.FromContext`), never parsed out of the request path.
|
||||
Don't reintroduce a `<user>` path segment when adding routes/paths.
|
||||
- Path parsing in the CalDAV/CardDAV backends assumes fixed URL segment
|
||||
positions (e.g. `cal/home/<calname>/<objid>`) split on `/` — see
|
||||
`parseCalPath`/`parseObjPath`/`parseBookPath`. The `home` segment must
|
||||
stay exactly one fixed literal segment (see note above on go-webdav's
|
||||
segment-count-based resource classification) — don't remove it or add/
|
||||
remove segments elsewhere without re-checking all four resource-type
|
||||
depths still line up. New path-based operations should follow the same
|
||||
segment-index approach for consistency.
|
||||
- Store errors are sentinel values (`store.ErrNotFound`, `store.ErrConflict`)
|
||||
checked with `errors.Is`/direct comparison; backends translate them into
|
||||
`webdav.NewHTTPError` with the appropriate HTTP status.
|
||||
- Logging uses `log/slog` structured fields (e.g. `logger.Warn("...", "user", u, "error", err)`), passed down explicitly to every constructor (`NewBackend`, `NewHandler`, `NewMiddleware`) rather than a global logger.
|
||||
- Config module path is `github.com/yourusername/caldav-server` (go.mod name
|
||||
predates the `nidus` repo rename) — import paths still use this, not `nidus`.
|
||||
@@ -113,15 +113,15 @@ docker compose exec nidus nidusctl addressbook create alice contacts
|
||||
This project uses year.month.hotfix versioning (e.g. `2026.8.0`, `2026.8.1`)
|
||||
rather than semantic versioning. Pushing a version tag (e.g. `2026.8.0`) or
|
||||
publishing a release triggers
|
||||
[`.github/workflows/docker-release.yml`](.github/workflows/docker-release.yml),
|
||||
[`.gitea/workflows/docker-release.yml`](.gitea/workflows/docker-release.yml),
|
||||
which builds and publishes a multi-arch (`linux/amd64` + `linux/arm64`)
|
||||
image to `git.arnef.de/arnef/nidus`, tagged with the version, `<year>.<month>`,
|
||||
image to `git.arnef.de/arnef/nidus`, tagged with the version, `<major>.<minor>`,
|
||||
`latest`, and the short commit SHA. It authenticates via the
|
||||
`REGISTRY_USERNAME`/`REGISTRY_PASSWORD` repository secrets.
|
||||
|
||||
A minimal standalone `docker-compose.yml` that pulls this image instead of
|
||||
building from a checkout — just fetch `config.example.yaml`, copy it to
|
||||
`config.yaml`, and adjust it to your needs:
|
||||
building from a checkout — configuration is done entirely via environment
|
||||
variables (no `config.yaml` to copy over):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -182,24 +182,25 @@ grantee's own home-set alongside their own calendars — no separate account
|
||||
or extra client configuration needed.
|
||||
|
||||
Sharing grants are stored in a small SQLite database at
|
||||
`<data_dir>/nidus.db` (not in `config.yaml`) and can be managed either via
|
||||
`<data_dir>/nidus.db` (not in any config file) and can be managed either via
|
||||
the `nidusctl` CLI or the web UI's dashboard (see below):
|
||||
|
||||
```bash
|
||||
# Give bob write access to alice's "work" calendar
|
||||
go run ./tools/nidusctl -config config.yaml calendar share alice work bob write
|
||||
go run ./tools/nidusctl calendar share alice work bob write
|
||||
|
||||
# List everyone alice's "work" calendar is shared with
|
||||
go run ./tools/nidusctl -config config.yaml calendar shares alice work
|
||||
go run ./tools/nidusctl calendar shares alice work
|
||||
|
||||
# Revoke access
|
||||
go run ./tools/nidusctl -config config.yaml calendar unshare alice work bob
|
||||
go run ./tools/nidusctl calendar unshare alice work bob
|
||||
|
||||
# Address books work the same way, using "addressbook" instead of "calendar"
|
||||
go run ./tools/nidusctl -config config.yaml addressbook share alice contacts bob read
|
||||
go run ./tools/nidusctl addressbook share alice contacts bob read
|
||||
```
|
||||
|
||||
Or via `make`: `make nidusctl ARGS="calendar share alice work bob write"`.
|
||||
(Both the server and `nidusctl` resolve the data directory from the
|
||||
`NIDUS_DATA_DIR` environment variable.)
|
||||
|
||||
A calendar that `alice` shares with `bob` appears in bob's calendar
|
||||
home-set as `/cal/home/alice~work/` (i.e. `<owner>~<calendar name>`) — the
|
||||
@@ -225,18 +226,18 @@ mobile browsers:
|
||||
place via [htmx](https://htmx.org/) without a full page reload.
|
||||
- **Files** (`/web/files/`) — a browser for the same storage the WebDAV
|
||||
endpoint (`/files/`) serves: navigate folders, create new folders,
|
||||
upload files/folders (including via drag & drop), download, and delete
|
||||
files or folders. Files open **inline** in the browser when the type
|
||||
supports it (video, audio, images, PDF, …) instead of always forcing a
|
||||
download; a separate "Download" action is always available to force a
|
||||
save-as.
|
||||
upload files/folders (including via drag & drop), download, sort by name or
|
||||
date, and delete files or folders. Files open **inline** in the browser when
|
||||
the type supports it (video, audio, images, PDF, …) instead of always
|
||||
forcing a download; a separate "Download" action is always available to
|
||||
force a save-as.
|
||||
- **Contacts** (`/web/contacts/`) — browse address books, create/edit/
|
||||
delete contacts (name, organization, birthday, phone numbers, emails,
|
||||
addresses, photo), and import/export vCards (`.vcf`).
|
||||
delete contacts (first/last name, organization, birthday, phone numbers,
|
||||
emails, address, photo), and import/export vCards (`.vcf`).
|
||||
- **Calendar** (`/web/calendar`) — month and week views across all your
|
||||
own and shared calendars (including ICS/webcal subscriptions and the
|
||||
Birthdays calendar), create/edit/delete events, per-calendar colors,
|
||||
and import/export `.ics` files.
|
||||
Birthdays calendar), with a detail view for each event; create/edit/delete
|
||||
events, per-calendar colors, and import/export `.ics` files.
|
||||
- **Account** (`/web/account`) — update your display name/email and
|
||||
change your password.
|
||||
- **Logout** (`/web/logout`).
|
||||
@@ -287,7 +288,8 @@ Traefik) to terminate TLS and forward requests to the server.
|
||||
## Managing users
|
||||
|
||||
All user/calendar/address-book management is done with `nidusctl` or the
|
||||
web UI (`/web/`). Nothing is stored in `config.yaml` anymore.
|
||||
web UI (`/web/`). User and resource data lives in `nidus.db`, not in a
|
||||
config file.
|
||||
|
||||
```bash
|
||||
# Users
|
||||
@@ -314,15 +316,10 @@ nidusctl addressbook unshare <owner> <book> <user>
|
||||
nidusctl addressbook shares <owner> <book>
|
||||
```
|
||||
|
||||
Password are prompted for interactively (masked, double-entry) when
|
||||
Passwords are prompted for interactively (masked, double-entry) when
|
||||
`--password` is omitted. The web UI (`/web/`) also lets a logged-in user
|
||||
create/delete their own calendars, address books, and ICS/webcal subscriptions
|
||||
from the dashboard.
|
||||
|
||||
> **Upgrading from an older version?** The `users:` section in
|
||||
> `config.yaml` is no longer read. Recreate your users with
|
||||
> `nidusctl user create` (and their calendars/address books) — there is no
|
||||
> automatic migration from the old config format.
|
||||
create/delete their own calendars, address books, and ICS/webcal
|
||||
subscriptions from the dashboard.
|
||||
|
||||
---
|
||||
|
||||
@@ -333,19 +330,22 @@ nidus/
|
||||
├── cmd/server/ # main entrypoint
|
||||
├── internal/
|
||||
│ ├── auth/ # HTTP Basic Auth middleware (DAV endpoints)
|
||||
│ ├── caldav/ # CalDAV backend
|
||||
│ ├── birthdays/ # compute a virtual Birthday calendar from contacts
|
||||
│ ├── caldav/ # CalDAV backend (incl. ICS subscriptions & Birthdays)
|
||||
│ ├── carddav/ # CardDAV backend
|
||||
│ ├── config/ # YAML config loader
|
||||
│ ├── config/ # configuration loader (environment variables)
|
||||
│ ├── db/ # SQLite store (users, calendars, shares, sessions)
|
||||
│ ├── icalfix/ # iCalendar (RFC 5545) parsing/fixing helpers
|
||||
│ ├── icssub/ # remote ICS/webcal subscription fetcher
|
||||
│ ├── store/ # filesystem storage layer
|
||||
│ ├── web/ # web UI (templ, dashboard, share mgmt, sessions)
|
||||
│ │ └── templates/ # templ templates (+ generated *_templ.go)
|
||||
│ └── webdav/ # WebDAV file handler
|
||||
├── internal/web/ # web UI (templ, dashboard, share mgmt, sessions)
|
||||
│ └── templates/ # templ templates (+ generated *_templ.go)
|
||||
├── cmd/nidusctl/ # admin CLI (users, calendars, address books, sharing)
|
||||
├── tools/nidusctl/ # admin CLI (users, calendars, address books, sharing)
|
||||
├── tools/migrate/ # data directory migration tool
|
||||
├── tools/hashpwd/ # standalone bcrypt password generator
|
||||
├── web/ # front-end assets: Tailwind input/config, static/
|
||||
│ └── static/ # compiled app.css + htmx.min.js (embedded into the binary)
|
||||
├── tools/migrate/ # data directory migration tool
|
||||
├── config.example.yaml # sample configuration (copy to config.yaml)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yaml
|
||||
└── Makefile
|
||||
@@ -370,4 +370,3 @@ the help of AI coding assistants (e.g. GitHub Copilot). Changes are
|
||||
reviewed and tested where practical, but not every part of the codebase
|
||||
has been fully reviewed yet — use accordingly, especially before relying
|
||||
on this in security-sensitive environments.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user