22 Commits
Author SHA1 Message Date
arnef f34041d889 feat(web): show ICS subscription events in detail view 2026-09-02 20:02:48 +02:00
arnef e18c83b618 feat(web): add calendar event detail view 2026-09-01 19:10:17 +02:00
arnef 1f383878ab refactor(web): simplify address to single free-form value
Docker Image bauen und veröffentlichen / docker (push) Canceled after 26s
2026-09-01 07:02:40 +02:00
arnef c945e785c8 feat(web): split contact name into first and last name 2026-08-31 22:08:53 +02:00
arnef ea917b4148 feat(web): add file sorting to files browser 2026-08-31 05:57:35 +02:00
arnef 995441e917 docs: update versioning scheme in README 2026-08-30 20:55:21 +02:00
arnef 36e3fecc11 feat: allow dots in resource/folder names 2026-08-30 20:16:05 +02:00
arnef 6a95e27e31 chore: remove example config file 2026-08-30 19:59:04 +02:00
arnef e8c4dadfea chore: fmt code 2026-08-30 19:58:06 +02:00
arnef 2e43e32a9f refactor: clean up nidusctl CLI 2026-08-30 19:54:34 +02:00
arnef 9b9cbe2f6f refactor: update migration logic 2026-08-30 19:54:34 +02:00
arnef 0ee0874458 refactor: streamline session management 2026-08-30 19:54:34 +02:00
arnef b364bee265 refactor: simplify config validation 2026-08-30 19:54:34 +02:00
arnef dd77a61b18 refactor: unify config and data structures 2026-08-30 19:54:34 +02:00
arnef d3780e9e32 refactor: align import paths with module name 2026-08-30 18:51:45 +02:00
arnef e96a0b8c92 Merge pull request 'feat: implement unified directory structure with automatic migration for CalDAV and CardDAV' (#4) from bug/3 into main
Reviewed-on: #4

closes #3
2026-08-30 12:04:12 +00:00
arnef 0567fea729 docs: remove CHANGES_SUMMARY.md (superseded by AGENTS.md) 2026-08-30 13:57:16 +02:00
arnef 5af3a8508b docs: update AGENTS.md with current commands and features
- Add web-deps, nidusctl, and migrate commands to build section
- Document ICS/webcal subscriptions and Birthdays calendar
- Note Migrate() is idempotent
- Add additional database tables
- Include Docker/CI and database migration details
2026-08-30 13:54:55 +02:00
arnef 8d2d8d3972 fix: update web files root to use unified path structure 2026-08-30 13:04:40 +02:00
arnef 2fd39c8180 refactor: unify data directory structure
Unify user data directory from fragmented layout to consistent nested format:

- Move WebDAV from: data/files/<username> → data/<username>/files/
- Move CalDAV from: data/<username>/cal-<name> → data/<username>/calendars/<name>
- Move CardDAV from: data/<username>/card-<name> → data/<username>/addressbooks/<name>

Changes:
- internal/store/store.go: Update collectionPath() to map collection names
- internal/store/migrate.go: Add idempotent Migrate() method
- internal/store/migrate_test.go: Comprehensive migration tests
- internal/webdav/handler.go: Use new unified path structure
- cmd/server/main.go: Auto-run migration on startup
- tools/nidusctl/main.go: Add migrate subcommand
- Update tests to verify new structure

URL endpoints unchanged - only on-disk structure modified. All tests pass.
2026-08-30 12:15:31 +02:00
arnefandCopilot f463c01f0f Add a standalone docker-compose.yml example using the pre-built image
Complements the existing "build: ." example (for a full checkout) with
a minimal compose file that just pulls git.arnef.de/arnef/nidus:latest,
for users who only want to run the container without cloning the repo.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-21 22:00:20 +02:00
arnefandCopilot cd0761c9d1 Remove TLS/Reverse proxy and Dependencies sections from README
Both were low-value docs (TLS config is already in the Configuration
reference table; Go/JS dependencies are visible in go.mod and
web/package.json) that just added noise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-21 21:59:05 +02:00
55 changed files with 3696 additions and 1319 deletions
+239
View File
@@ -0,0 +1,239 @@
# 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 + ./bin/nidusctl
make run # build + ./bin/davserver -config config.yaml
make test # go test ./... -v -race
make lint # golangci-lint run ./... (no config file; uses defaults)
make tidy # go mod tidy
make web-deps # install Tailwind CLI + TypeScript (needed once, or after web/package.json changes)
make templ-generate # regenerate *_templ.go after editing internal/web/templates/*.templ
make web-css # templ-generate + rebuild web/static/app.css
make web-ts # compile web/ts/*.ts to web/static/*.js
make web-assets # web-css + web-ts (everything under web/static/)
make nidusctl # build + run nidusctl with ARGS (e.g. `make nidusctl ARGS="user list"`)
go run ./tools/migrate -config config.yaml # restructure data directory
```
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>/[calendars|addressbooks|files]/<name>`.
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. `Migrate()` restructures data from the old format (`cal-<name>`,
`card-<name>`) to the new unified layout and is idempotent.
- `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.
**ICSSubscriptions**: read-only calendars backed by remote ICS/webcal
URLs (see `db.CreateICSSubscription`, `internal/db/ics.go`) are exposed
alongside real calendars under `/cal/home/<name>/` (shared namespace;
see `internal/caldav/ics.go`). `Birthdays` is a computed calendar
synthesized from contacts' BDAY fields (see `internal/caldav/birthdays.go`,
`internal/birthdays/birthdays.go`).
- `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.
**Additional tables**: `birthday_calendars` (per-user display color for
the computed Birthdays calendar), `ics_subscriptions` (remote ICS/webcal
calendar subscriptions), `web_sessions` (server-side session tokens).
- `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/migrate` — restructures data directory from old format (`cal-<name>`,
`card-<name>`) to unified layout (`calendars/`, `addressbooks/`,
`files/`). Run automatically on server startup; invoke manually with
`go run ./tools/migrate -config config.yaml`.
- `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/`).
**Web UI features**: dashboard (calendars/address books/ICS subscriptions
management), login (cookie-based sessions), share management (create/update/
revoke share grants via htmx), files browser (inline preview, upload, download),
contacts manager (vCard import/export, edit fields), calendar view (month/week,
per-calendar colors), account settings (name/email/password). The
synthetic `Birthdays` calendar (computed from contacts' BDAY fields) and
ICS/webcal subscriptions are managed via the web UI just like real
calendars.
## 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`.
- **Docker/CI**: Docker image pushed to `git.arnef.de/arnef/nidus` on tags (`v*`) or releases (`.github/workflows/docker-release.yml`), multi-arch (`linux/amd64`, `linux/arm64`). Use `docker-compose` for local dev with healthcheck on `/healthz`.
- **Database schema** lives in `internal/db/db.go` `migrate()` — all tables are created with `CREATE TABLE IF NOT EXISTS` and schema changes are applied via `ALTER TABLE` in `migrateAddColumns()`. No external migration framework.
+9 -6
View File
@@ -18,13 +18,16 @@ WORKDIR /app
COPY --from=builder /bin/davserver /usr/local/bin/davserver COPY --from=builder /bin/davserver /usr/local/bin/davserver
COPY --from=builder /bin/nidusctl /usr/local/bin/nidusctl COPY --from=builder /bin/nidusctl /usr/local/bin/nidusctl
# Default data and config locations # Default data location
VOLUME ["/app/data"] VOLUME ["/app/data"]
# config.yaml itself is git-ignored (it holds real secrets), so the image
# ships the example config as a working default; mount your own # environment variables (override as needed)
# config.yaml over /app/config.yaml (see docker-compose.yaml) to override it. ENV NIDUS_DATA_DIR=/app/data
COPY config.example.yaml /app/config.yaml ENV NIDUS_HOST=0.0.0.0
ENV NIDUS_PORT=8080
ENV NIDUS_LOG_LEVEL=warn
ENV NIDUS_LOG_FORMAT=text
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["davserver", "-config", "/app/config.yaml"] ENTRYPOINT ["davserver"]
+2 -2
View File
@@ -7,7 +7,7 @@ build:
## run: run the server locally ## run: run the server locally
run: build run: build
./bin/davserver -config config.yaml ./bin/davserver
## test: run all tests ## test: run all tests
test: test:
@@ -37,7 +37,7 @@ hash-password:
## nidusctl: build and run the sharing-grant admin CLI ## nidusctl: build and run the sharing-grant admin CLI
## Usage: make nidusctl ARGS="calendar share alice work bob write" ## Usage: make nidusctl ARGS="calendar share alice work bob write"
nidusctl: build nidusctl: build
./bin/nidusctl -config config.yaml $(ARGS) ./bin/nidusctl $(ARGS)
## templ-generate: regenerate *_templ.go files from internal/web/templates/*.templ ## templ-generate: regenerate *_templ.go files from internal/web/templates/*.templ
templ-generate: templ-generate:
+109 -129
View File
@@ -1,4 +1,4 @@
# DAV Server # nidus
A self-hosted **CalDAV**, **CardDAV**, and **WebDAV** server written in Go. A self-hosted **CalDAV**, **CardDAV**, and **WebDAV** server written in Go.
@@ -23,56 +23,61 @@ and files, all kept under your own roof instead of a third-party cloud.
- **Web UI** — a mobile-friendly app at `/web/` for managing calendars, - **Web UI** — a mobile-friendly app at `/web/` for managing calendars,
contacts, files, and account settings (see [Web UI](#web-ui) below), contacts, files, and account settings (see [Web UI](#web-ui) below),
built with templ + Tailwind + htmx built with templ + Tailwind + htmx
- **ICSSubscriptions** — add remote ICS/webcal calendars
- **Birthdays calendar** — auto-computed from contacts' BDAY fields
- Auto-discovery via `/.well-known/caldav` and `/.well-known/carddav` - Auto-discovery via `/.well-known/caldav` and `/.well-known/carddav`
- Optional **TLS** (or use a reverse proxy)
- Structured logging (text or JSON) - Structured logging (text or JSON)
- Graceful shutdown - Graceful shutdown
- Docker & Docker Compose support - Docker & Docker Compose support
## Quick start ## Quick start
### 1. Install dependencies ### 1. Run the server
```bash
go mod tidy
```
### 2. Create your `config.yaml`
Copy the example config and edit it — `config.yaml` is git-ignored so your
real settings never get committed:
```bash
cp config.example.yaml config.yaml
```
Users, calendars, and address books are **no longer configured in
`config.yaml`** — they live in the SQLite database and are managed with
`nidusctl` (see below).
### 3. Run the server
```bash ```bash
make run make run
# or # or
go run ./cmd/server -config config.yaml go run ./cmd/server
``` ```
The server starts at **http://localhost:8080**. The server starts at **http://localhost:8080**.
### 4. Create a user and their resources ### 2. Configure the server via environment variables
The server is configured via environment variables:
```bash ```bash
go run ./tools/nidusctl -config config.yaml user create alice \ # Required: Set the data directory
export NIDUS_DATA_DIR="./data"
# Optional: Set port, host, and base URL
export NIDUS_PORT="8080"
export NIDUS_HOST="0.0.0.0"
export NIDUS_BASE_URL="https://dav.example.com"
# Optional: Set auth realm
export NIDUS_AUTH_REALM="My DAV Server"
# Optional: Set logging
export NIDUS_LOG_LEVEL="info"
export NIDUS_LOG_FORMAT="text"
```
This server does **not** handle TLS — use a reverse proxy (e.g. Nginx, Caddy,
Traefik) to terminate TLS and forward requests to the server.
### 3. Create a user and their resources
```bash
go run ./tools/nidusctl user create alice \
--display-name "Alice Smith" --email alice@example.com --display-name "Alice Smith" --email alice@example.com
# (prompts for a password; use --password to skip the prompt, e.g. in scripts) # (prompts for a password; use --password to skip the prompt, e.g. in scripts)
go run ./tools/nidusctl -config config.yaml calendar create alice personal go run ./tools/nidusctl calendar create alice personal
go run ./tools/nidusctl -config config.yaml addressbook create alice contacts go run ./tools/nidusctl addressbook create alice contacts
``` ```
Users can also be created/removed via the web UI (`/web/`) once logged in Or use the web UI (`/web/`) once logged in — see **Web UI** below.
as an existing user — see **Web UI** below.
--- ---
@@ -83,44 +88,66 @@ as an existing user — see **Web UI** below.
docker compose up --build docker compose up --build
# Or build manually # Or build manually
docker build -t davserver . docker build -t nidus .
docker run -p 8080:8080 \ docker run -p 8080:8080 \
-v ./config.yaml:/app/config.yaml:ro \ -v nidus-data:/app/data \
-v dav-data:/app/data \ -e NIDUS_DATA_DIR=/app/data \
davserver nidus
``` ```
The image also ships `nidusctl`, so once the container is running you can The image also ships `nidusctl`, so once the container is running you can
create your first user (and their calendars/address books) with create your first user (and their calendars/address books) with
`docker compose exec` — no need to install Go locally: `docker compose exec`:
```bash ```bash
docker compose exec davserver nidusctl -config /app/config.yaml user create alice \ docker compose exec nidus nidusctl user create alice \
--display-name "Alice Smith" --email alice@example.com --display-name "Alice Smith" --email alice@example.com
# (prompts for a password; use --password to skip the prompt, e.g. in scripts) # (prompts for a password; use --password to skip the prompt)
docker compose exec davserver nidusctl -config /app/config.yaml calendar create alice personal docker compose exec nidus nidusctl calendar create alice personal
docker compose exec davserver nidusctl -config /app/config.yaml addressbook create alice contacts docker compose exec nidus nidusctl addressbook create alice contacts
``` ```
### Pre-built images ### Using pre-built images
Pushing a version tag (e.g. `v1.2.3`) or publishing a release triggers 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), [`.github/workflows/docker-release.yml`](.github/workflows/docker-release.yml),
which builds and publishes a multi-arch (`linux/amd64` + `linux/arm64`) which builds and publishes a multi-arch (`linux/amd64` + `linux/arm64`)
image to `git.arnef.de/arnef/nidus`, tagged with the version, `<major>.<minor>`, image to `git.arnef.de/arnef/nidus`, tagged with the version, `<year>.<month>`,
`latest`, and the short commit SHA. It authenticates via the `latest`, and the short commit SHA. It authenticates via the
`REGISTRY_USERNAME`/`REGISTRY_PASSWORD` repository secrets. Point `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` repository secrets.
`docker-compose.yaml`'s `image:` at it instead of `build: .` to use it
directly, e.g.: 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:
```yaml ```yaml
services: services:
davserver: nidus:
image: git.arnef.de/arnef/nidus:latest image: git.arnef.de/arnef/nidus:latest
ports:
- "8080:8080"
volumes:
- nidus-data:/app/data
environment:
- NIDUS_DATA_DIR=/app/data
# Optional: other environment variables
# - NIDUS_PORT=8080
# - NIDUS_HOST=0.0.0.0
# - NIDUS_BASE_URL=https://dav.example.com
# - NIDUS_AUTH_REALM="My DAV Server"
# - NIDUS_LOG_LEVEL=info
# - NIDUS_LOG_FORMAT=text
restart: unless-stopped
volumes:
nidus-data:
``` ```
--- Note: This server does **not** handle TLS. Use a reverse proxy (e.g. Nginx,
Caddy, Traefik) to terminate TLS and forward requests to the server.
## API endpoints ## API endpoints
@@ -192,10 +219,10 @@ mobile browsers:
`nidus.db` (`web_sessions` table), independent of DAV Basic Auth. Includes `nidus.db` (`web_sessions` table), independent of DAV Basic Auth. Includes
a "Show/Hide" password toggle to rule out typos before submitting. a "Show/Hide" password toggle to rule out typos before submitting.
- **Dashboard** (`/web/`) — create/delete your own calendars, address - **Dashboard** (`/web/`) — create/delete your own calendars, address
books, and ICS/webcal subscriptions; see who your resources are shared books, ICS/webcal subscriptions, and the Birthdays calendar; see who your
with and what others have shared with you; manage sharing grants resources are shared with and what others have shared with you; manage
directly (same effect as `nidusctl`) — updates happen in place via sharing grants directly (same effect as `nidusctl`) — updates happen in
[htmx](https://htmx.org/) without a full page reload. place via [htmx](https://htmx.org/) without a full page reload.
- **Files** (`/web/files/`) — a browser for the same storage the WebDAV - **Files** (`/web/files/`) — a browser for the same storage the WebDAV
endpoint (`/files/`) serves: navigate folders, create new folders, endpoint (`/files/`) serves: navigate folders, create new folders,
upload files/folders (including via drag & drop), download, and delete upload files/folders (including via drag & drop), download, and delete
@@ -207,8 +234,9 @@ mobile browsers:
delete contacts (name, organization, birthday, phone numbers, emails, delete contacts (name, organization, birthday, phone numbers, emails,
addresses, photo), and import/export vCards (`.vcf`). addresses, photo), and import/export vCards (`.vcf`).
- **Calendar** (`/web/calendar`) — month and week views across all your - **Calendar** (`/web/calendar`) — month and week views across all your
own and shared calendars, create/edit/delete events, per-calendar own and shared calendars (including ICS/webcal subscriptions and the
colors, and import/export `.ics` files. Birthdays calendar), create/edit/delete events, per-calendar colors,
and import/export `.ics` files.
- **Account** (`/web/account`) — update your display name/email and - **Account** (`/web/account`) — update your display name/email and
change your password. change your password.
- **Logout** (`/web/logout`). - **Logout** (`/web/logout`).
@@ -230,64 +258,36 @@ make web-assets # regenerate templ code + rebuild web/static/app.css and web/st
--- ---
## TLS / Reverse proxy
### Self-signed certificate (development)
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
```
Update `config.yaml`:
```yaml
tls:
enabled: true
cert_file: cert.pem
key_file: key.pem
```
### Caddy reverse proxy (recommended for production)
```
dav.example.com {
reverse_proxy localhost:8080
}
```
---
## Configuration reference ## Configuration reference
```yaml Configuration is done via environment variables:
server:
host: "0.0.0.0"
port: 8080
base_url: "https://dav.example.com" # used in DAV responses
auth: | Variable | Default | Description |
realm: "My DAV Server" |----------|---------|-------------|
| `NIDUS_HOST` | `0.0.0.0` | Server listen host |
| `NIDUS_PORT` | `8080` | Server listen port |
| `NIDUS_BASE_URL` | (auto) | Public URL for DAV responses (e.g. https://dav.example.com) |
| `NIDUS_AUTH_REALM` | `DAV Server` | HTTP Basic Auth realm |
| `NIDUS_DATA_DIR` | `./data` | Data directory for all user data |
| `NIDUS_LOG_LEVEL` | `info` | Log level: debug, info, warn, error |
| `NIDUS_LOG_FORMAT` | `text` | Log format: text, json |
storage: Example:
data_dir: "./data" # all user data lives here
logging: ```bash
level: "info" # debug | info | warn | error export NIDUS_DATA_DIR="./data"
format: "text" # text | json export NIDUS_PORT="8080"
export NIDUS_BASE_URL="https://dav.example.com"
tls: export NIDUS_LOG_LEVEL="info"
enabled: false
cert_file: ""
key_file: ""
``` ```
Users, calendars, and address books are managed via `nidusctl`, not This server does **not** handle TLS — use a reverse proxy (e.g. Nginx, Caddy,
`config.yaml` — see **Managing users** below. Traefik) to terminate TLS and forward requests to the server.
## Managing users ## Managing users
All user/calendar/address-book management is done with `nidusctl` (or the All user/calendar/address-book management is done with `nidusctl` or the
web UI). Nothing is stored in `config.yaml` anymore. web UI (`/web/`). Nothing is stored in `config.yaml` anymore.
```bash ```bash
# Users # Users
@@ -314,9 +314,10 @@ nidusctl addressbook unshare <owner> <book> <user>
nidusctl addressbook shares <owner> <book> nidusctl addressbook shares <owner> <book>
``` ```
Passwords are prompted for interactively (masked, double-entry) when Password are prompted for interactively (masked, double-entry) when
`--password` is omitted. The web UI (`/web/`) also lets a logged-in user `--password` is omitted. The web UI (`/web/`) also lets a logged-in user
create/delete their own calendars and address books from the dashboard. create/delete their own calendars, address books, and ICS/webcal subscriptions
from the dashboard.
> **Upgrading from an older version?** The `users:` section in > **Upgrading from an older version?** The `users:` section in
> `config.yaml` is no longer read. Recreate your users with > `config.yaml` is no longer read. Recreate your users with
@@ -328,22 +329,22 @@ create/delete their own calendars and address books from the dashboard.
## Project layout ## Project layout
``` ```
caldav-server/ nidus/
├── cmd/server/ # main entrypoint ├── cmd/server/ # main entrypoint
├── internal/ ├── internal/
│ ├── auth/ # HTTP Basic Auth middleware (DAV endpoints) │ ├── auth/ # HTTP Basic Auth middleware (DAV endpoints)
│ ├── caldav/ # CalDAV backend │ ├── caldav/ # CalDAV backend
│ ├── carddav/ # CardDAV backend │ ├── carddav/ # CardDAV backend
│ ├── config/ # YAML config loader │ ├── config/ # YAML config loader
│ ├── db/ # SQLite store (shares, web UI sessions) │ ├── db/ # SQLite store (users, calendars, shares, sessions)
│ ├── store/ # filesystem storage layer │ ├── store/ # filesystem storage layer
│ ├── web/ # web UI (cookie sessions, dashboard, share mgmt)
│ │ └── templates/ # templ templates (+ generated *_templ.go)
│ └── webdav/ # WebDAV file handler │ └── webdav/ # WebDAV file handler
├── tools/hashpwd/ # bcrypt password hasher CLI ├── internal/web/ # web UI (templ, dashboard, share mgmt, sessions)
├── tools/nidusctl/ # sharing-grant admin CLI │ └── templates/ # templ templates (+ generated *_templ.go)
├── cmd/nidusctl/ # admin CLI (users, calendars, address books, sharing)
├── web/ # front-end assets: Tailwind input/config, static/ ├── web/ # front-end assets: Tailwind input/config, static/
│ └── static/ # compiled app.css + htmx.min.js (embedded into the binary) │ └── 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) ├── config.example.yaml # sample configuration (copy to config.yaml)
├── Dockerfile ├── Dockerfile
├── docker-compose.yaml ├── docker-compose.yaml
@@ -370,24 +371,3 @@ reviewed and tested where practical, but not every part of the codebase
has been fully reviewed yet — use accordingly, especially before relying has been fully reviewed yet — use accordingly, especially before relying
on this in security-sensitive environments. on this in security-sensitive environments.
---
## Dependencies
| Package | Purpose |
|---------|---------|
| `github.com/emersion/go-webdav` | WebDAV/CalDAV/CardDAV protocol layer |
| `github.com/emersion/go-ical` | iCalendar parsing/serialisation |
| `github.com/emersion/go-vcard` | vCard parsing/serialisation |
| `golang.org/x/crypto` | bcrypt |
| `golang.org/x/net` | `golang.org/x/net/webdav` |
| `gopkg.in/yaml.v3` | YAML config parsing |
| `modernc.org/sqlite` | Pure-Go SQLite driver (shares, web UI sessions) |
| `github.com/a-h/templ` | Type-safe Go HTML templates (web UI) |
Front-end (dev-only, not required at runtime — see [Web UI](#web-ui)):
| Tool | Purpose |
|------|---------|
| Tailwind CSS v4 (`web/package.json`) | Utility-first CSS, compiled to `web/static/app.css` |
| [htmx](https://htmx.org/) (`web/static/htmx.min.js`, vendored) | Partial page updates without a JS framework |
+27 -23
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
@@ -13,24 +12,21 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/yourusername/caldav-server/internal/auth" "git.arnef.de/arnef/nidus/internal/auth"
"github.com/yourusername/caldav-server/internal/caldav" "git.arnef.de/arnef/nidus/internal/caldav"
"github.com/yourusername/caldav-server/internal/carddav" "git.arnef.de/arnef/nidus/internal/carddav"
"github.com/yourusername/caldav-server/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/store" "git.arnef.de/arnef/nidus/internal/icssub"
"github.com/yourusername/caldav-server/internal/web" "git.arnef.de/arnef/nidus/internal/store"
filewebdav "github.com/yourusername/caldav-server/internal/webdav" "git.arnef.de/arnef/nidus/internal/web"
webstatic "github.com/yourusername/caldav-server/web" filewebdav "git.arnef.de/arnef/nidus/internal/webdav"
webstatic "git.arnef.de/arnef/nidus/web"
) )
func main() { func main() {
var cfgPath string
flag.StringVar(&cfgPath, "config", "config.yaml", "path to configuration file")
flag.Parse()
// ---- Configuration ---- // ---- Configuration ----
cfg, err := config.Load(cfgPath) cfg, err := config.Load()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -41,8 +37,7 @@ func main() {
logger.Info("starting DAV server", logger.Info("starting DAV server",
"host", cfg.Server.Host, "host", cfg.Server.Host,
"port", cfg.Server.Port, "port", cfg.Server.Port,
"base_url", cfg.Server.BaseURL, "base_url", cfg.Server.BaseURL)
"tls", cfg.TLS.Enabled)
// ---- Storage ---- // ---- Storage ----
st, err := store.NewStore(cfg.Storage.DataDir) st, err := store.NewStore(cfg.Storage.DataDir)
@@ -51,6 +46,13 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// ---- Run migration if data directory needs restructuring ----
if err := st.Migrate(); err != nil {
logger.Error("migration failed", "error", err)
os.Exit(1)
}
logger.Info("data directory migration completed")
// ---- Database (users, calendars, address books, sharing) ---- // ---- Database (users, calendars, address books, sharing) ----
dbPath := filepath.Join(cfg.Storage.DataDir, "nidus.db") dbPath := filepath.Join(cfg.Storage.DataDir, "nidus.db")
dbase, err := db.Open(dbPath) dbase, err := db.Open(dbPath)
@@ -102,10 +104,16 @@ func main() {
authMw := auth.NewMiddleware(cfg, dbase, logger) authMw := auth.NewMiddleware(cfg, dbase, logger)
// ---- Handlers ---- // ---- Handlers ----
calHandler := caldav.NewHandler(cfg, st, dbase, logger) // A single ICS-subscription cache is shared by both the CalDAV backend
// (for DAV clients) and the web UI (for the browser calendar page), so
// the same subscription is fetched and served identically regardless
// of which surface a client hits, and one background refresh refreshes
// both at once.
icsCache := icssub.NewCache(icssub.DefaultTTL)
calHandler := caldav.NewHandler(cfg, st, dbase, logger, icsCache)
cardHandler := carddav.NewHandler(cfg, st, dbase, logger) cardHandler := carddav.NewHandler(cfg, st, dbase, logger)
fileHandler := filewebdav.NewHandler(cfg, cfg.Storage.DataDir, logger) fileHandler := filewebdav.NewHandler(cfg, cfg.Storage.DataDir, logger)
webUI := web.NewServer(cfg, st, dbase, logger) webUI := web.NewServer(cfg, st, dbase, logger, icsCache)
mux := buildMux(cfg, authMw, calHandler, cardHandler, fileHandler, webUI, logger) mux := buildMux(cfg, authMw, calHandler, cardHandler, fileHandler, webUI, logger)
@@ -136,11 +144,7 @@ func main() {
}() }()
logger.Info("server ready", "addr", addr) logger.Info("server ready", "addr", addr)
if cfg.TLS.Enabled {
err = srv.ListenAndServeTLS(cfg.TLS.CertFile, cfg.TLS.KeyFile)
} else {
err = srv.ListenAndServe() err = srv.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed { if err != nil && err != http.ErrServerClosed {
logger.Error("server error", "error", err) logger.Error("server error", "error", err)
os.Exit(1) os.Exit(1)
-31
View File
@@ -1,31 +0,0 @@
server:
host: "0.0.0.0"
port: 8080
# Set this to your public-facing URL so discovery responses are correct.
# base_url: "https://dav.example.com"
auth:
realm: "My DAV Server"
storage:
data_dir: "./data"
logging:
level: "debug" # debug | info | warn | error
format: "text" # text | json
tls:
enabled: false
# cert_file: "/etc/ssl/certs/dav.crt"
# key_file: "/etc/ssl/private/dav.key"
# Users, calendars, and address books are no longer configured here — they
# are stored in the database (<data_dir>/nidus.db) and managed with
# nidusctl or the web UI (/web/):
#
# nidusctl user create alice --password mysecretpassword --display-name "Alice Smith" --email alice@example.com
# nidusctl calendar create alice personal
# nidusctl calendar create alice work
# nidusctl addressbook create alice contacts
#
# Run `nidusctl help` for the full command list.
+12 -4
View File
@@ -1,11 +1,19 @@
services: services:
davserver: nidus:
build: . build: .
ports: ports:
- "8080:8080" - "8080:8080"
environment:
# Optional: other environment variables
# - NIDUS_DATA_DIR=/app/data
# - NIDUS_HOST=0.0.0.0
# - NIDUS_PORT=8080
# - NIDUS_BASE_URL=https://dav.example.com
# - NIDUS_AUTH_REALM="My DAV Server"
# - NIDUS_LOG_LEVEL=info
# - NIDUS_LOG_FORMAT=text
volumes: volumes:
- ./config.yaml:/app/config.yaml:ro - nidus-data:/app/data
- dav-data:/app/data
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
@@ -14,4 +22,4 @@ services:
retries: 3 retries: 3
volumes: volumes:
dav-data: nidus-data:
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/yourusername/caldav-server module git.arnef.de/arnef/nidus
go 1.25.0 go 1.25.0
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/yourusername/caldav-server/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
) )
type contextKey string type contextKey string
+2 -2
View File
@@ -14,8 +14,8 @@ import (
"github.com/emersion/go-vcard" "github.com/emersion/go-vcard"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/store" "git.arnef.de/arnef/nidus/internal/store"
) )
// Contact holds one contact's parsed birthday. // Contact holds one contact's parsed birthday.
+18 -12
View File
@@ -14,15 +14,15 @@ import (
"strings" "strings"
"time" "time"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icalfix"
"git.arnef.de/arnef/nidus/internal/icssub"
"git.arnef.de/arnef/nidus/internal/store"
ical "github.com/emersion/go-ical" ical "github.com/emersion/go-ical"
"github.com/emersion/go-webdav" "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/icalfix"
"github.com/yourusername/caldav-server/internal/icssub"
"github.com/yourusername/caldav-server/internal/store"
) )
// sharedNameSep separates the owner from the calendar name in the // sharedNameSep separates the owner from the calendar name in the
@@ -41,10 +41,16 @@ type Backend struct {
icsCache *icssub.Cache icsCache *icssub.Cache
} }
// NewBackend creates a CalDAV backend. dbase may be nil, in which case // NewBackend creates a CalDAV backend over an existing ICS cache.
// calendar sharing is disabled (only a user's own calendars are visible). // dbase may be nil, in which case calendar sharing is disabled (only a
func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Backend { // user's own calendars are visible). The icsCache is shared with any
return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icssub.NewCache(icssub.DefaultTTL)} // other consumers (notably the web UI) so CalDAV and the web calendar
// page see identical, cache-consistent events for ICS subscriptions.
func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger, icsCache *icssub.Cache) *Backend {
if icsCache == nil {
icsCache = icssub.NewCache(icssub.DefaultTTL)
}
return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icsCache}
} }
// NewHandler returns an http.Handler for the /cal/ prefix. // NewHandler returns an http.Handler for the /cal/ prefix.
@@ -54,8 +60,8 @@ func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.
// property into PROPFIND responses for calendar collections, since // property into PROPFIND responses for calendar collections, since
// go-webdav's caldav.Backend interface has no extension point for // go-webdav's caldav.Backend interface has no extension point for
// vendor-specific WebDAV properties. // vendor-specific WebDAV properties.
func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) http.Handler { func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger, icsCache *icssub.Cache) http.Handler {
b := NewBackend(cfg, st, dbase, logger) b := NewBackend(cfg, st, dbase, logger, icsCache)
return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}} return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}}
} }
+7 -7
View File
@@ -11,11 +11,11 @@ import (
"strings" "strings"
"testing" "testing"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store"
ical "github.com/emersion/go-ical" ical "github.com/emersion/go-ical"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
) )
func newTestBackend(t *testing.T) (*Backend, *db.DB) { func newTestBackend(t *testing.T) (*Backend, *db.DB) {
@@ -46,7 +46,7 @@ func newTestBackend(t *testing.T) (*Backend, *db.DB) {
t.Fatalf("CreateCalendar bob/personal: %v", err) t.Fatalf("CreateCalendar bob/personal: %v", err)
} }
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewBackend(cfg, st, dbase, logger), dbase return NewBackend(cfg, st, dbase, logger, nil), dbase
} }
func ctxFor(username string) context.Context { func ctxFor(username string) context.Context {
@@ -179,7 +179,7 @@ func TestPropFindEmitsCalendarColor(t *testing.T) {
} }
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
handler := NewHandler(&config.Config{}, st, dbase, logger) handler := NewHandler(&config.Config{}, st, dbase, logger, nil)
req := httptest.NewRequest("PROPFIND", "/cal/home/", strings.NewReader( req := httptest.NewRequest("PROPFIND", "/cal/home/", strings.NewReader(
`<?xml version="1.0"?><a:propfind xmlns:a="DAV:"><a:allprop/></a:propfind>`)) `<?xml version="1.0"?><a:propfind xmlns:a="DAV:"><a:allprop/></a:propfind>`))
@@ -250,7 +250,7 @@ func TestPropFindExplicitCalendarColorRequest(t *testing.T) {
} }
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
handler := NewHandler(&config.Config{}, st, dbase, logger) handler := NewHandler(&config.Config{}, st, dbase, logger, nil)
req := httptest.NewRequest("PROPFIND", "/cal/home/work/", strings.NewReader( req := httptest.NewRequest("PROPFIND", "/cal/home/work/", strings.NewReader(
`<?xml version="1.0"?><D:propfind xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">`+ `<?xml version="1.0"?><D:propfind xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">`+
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"github.com/emersion/go-webdav" "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
"github.com/yourusername/caldav-server/internal/birthdays" "git.arnef.de/arnef/nidus/internal/birthdays"
) )
// birthdaysCalendarName is the fixed, reserved local calendar name the // birthdaysCalendarName is the fixed, reserved local calendar name the
+23 -27
View File
@@ -1,8 +1,6 @@
package caldav package caldav
import ( import (
"crypto/sha1"
"encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -12,7 +10,8 @@ import (
"github.com/emersion/go-webdav" "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icssub"
) )
// defaultICSColor is the display color for an ICS/webcal subscription // defaultICSColor is the display color for an ICS/webcal subscription
@@ -32,8 +31,9 @@ func (b *Backend) icsSubscriptionCalendarMeta(owner string, sub db.ICSSubscripti
} }
// icsObjectUID returns the UID a fetched VEVENT should be addressed by: // icsObjectUID returns the UID a fetched VEVENT should be addressed by:
// its own UID property if it has one, otherwise a stable hash of its // its own UID property if it has one, otherwise a placeholder derived from
// position so it still round-trips consistently between requests. // the object ID (so the event still has a *unique* UID in the returned
// VCALENDAR).
func icsObjectUID(ev ical.Event, fallback string) string { func icsObjectUID(ev ical.Event, fallback string) string {
if p := ev.Props.Get(ical.PropUID); p != nil && p.Value != "" { if p := ev.Props.Get(ical.PropUID); p != nil && p.Value != "" {
return p.Value return p.Value
@@ -41,16 +41,11 @@ func icsObjectUID(ev ical.Event, fallback string) string {
return fallback return fallback
} }
// icsObjID builds the object ID (file-name-like, ".ics" suffixed) used to // icsSubscriptionCalendarObjects fetches sub's remote calendar (via
// address a fetched VEVENT within its subscription calendar, derived from // b.icsCache) and returns one caldav.CalendarObject per VEVENT. The
// its UID so it stays stable across fetches of the same feed. // object path is derived from icssub.EventID(ev) so the same event keeps
func icsObjID(uid string) string { // the same path across fetches, even if its position in the document
sum := sha1.Sum([]byte(uid)) // changes.
return hex.EncodeToString(sum[:]) + ".ics"
}
// listICSSubscriptionCalendarObjects fetches sub's remote calendar (via
// b.icsCache) and returns one caldav.CalendarObject per VEVENT.
func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.ICSSubscription) ([]caldav.CalendarObject, error) { func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.ICSSubscription) ([]caldav.CalendarObject, error) {
cal, err := b.icsCache.Get(sub.URL) cal, err := b.icsCache.Get(sub.URL)
if err != nil { if err != nil {
@@ -58,8 +53,8 @@ func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.IC
} }
var objs []caldav.CalendarObject var objs []caldav.CalendarObject
for i, ev := range cal.Events() { for _, ev := range cal.Events() {
obj, err := b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i)) obj, err := b.encodeICSObject(localName, ev)
if err != nil { if err != nil {
continue continue
} }
@@ -75,24 +70,25 @@ func (b *Backend) icsSubscriptionCalendarObject(localName, objID string, sub db.
if err != nil { if err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("fetching ics subscription: %w", err)) return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("fetching ics subscription: %w", err))
} }
for i, ev := range cal.Events() { for _, ev := range cal.Events() {
uid := icsObjectUID(ev, fmt.Sprintf("event-%d", i)) if icssub.EventID(ev) != objID {
if icsObjID(uid) != objID {
continue continue
} }
return b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i)) return b.encodeICSObject(localName, ev)
} }
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("ics subscription event not found")) return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("ics subscription event not found"))
} }
// encodeICSObject wraps a single fetched VEVENT ev into its own // encodeICSObject wraps a single fetched VEVENT ev into its own
// caldav.CalendarObject, encoding it as a standalone one-event calendar // caldav.CalendarObject, encoding it as a standalone one-event calendar
// the same way every other calendar object in this backend is // the same way every other calendar object in this backend is represented.
// represented. fallbackUID is used to derive the object ID/UID if ev has func (b *Backend) encodeICSObject(localName string, ev ical.Event) (*caldav.CalendarObject, error) {
// no UID property of its own. objID := icssub.EventID(ev)
func (b *Backend) encodeICSObject(localName string, ev ical.Event, fallbackUID string) (*caldav.CalendarObject, error) { if objID == "" {
uid := icsObjectUID(ev, fallbackUID) // Not addressable (no DTSTART) — skip.
objID := icsObjID(uid) return nil, fmt.Errorf("event has no DTSTART; not addressable")
}
uid := icsObjectUID(ev, objID)
event := ical.NewEvent() event := ical.NewEvent()
event.Props = ev.Props event.Props = ev.Props
+268
View File
@@ -0,0 +1,268 @@
package caldav
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icssub"
"git.arnef.de/arnef/nidus/internal/store"
ical "github.com/emersion/go-ical"
)
// icsSample is a minimal valid ICS feed with one VEVENT.
const icsSample = "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"PRODID:-//nidus//test//EN\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:shared1@nidus.test\r\n" +
"DTSTAMP:20260101T000000Z\r\n" +
"DTSTART:20260805T090000Z\r\n" +
"DTEND:20260805T100000Z\r\n" +
"SUMMARY:Shared event\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
// mustCalEvent parses raw and returns its first VEVENT (panic on error;
// safe in tests).
func mustCalEvent(t *testing.T, raw string) ical.Event {
t.Helper()
_ = t
cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
if err != nil {
panic("mustCalEvent: " + err.Error())
}
evs := cal.Events()
if len(evs) == 0 {
panic("mustCalEvent: no events")
}
return evs[0]
}
// TestICSSubscriptionListAndGetObject verifies the full CalDAV read path
// for an ICS subscription: ListCalendarObjects returns one synthetic
// stand-alone object whose Path is derived from icssub.EventID (not from
// the event's position), and the same object is returned via
// GetCalendarObject by the same Path.
func TestICSSubscriptionListAndGetObject(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(icsSample))
}))
defer srv.Close()
dir := t.TempDir()
st, err := store.NewStore(filepath.Join(dir, "data"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
dbase, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("db.Open: %v", err)
}
defer dbase.Close()
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil {
t.Fatalf("CreateICSSubscription: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
b := NewBackend(&config.Config{}, st, dbase, logger, icssub.NewCache(time.Hour))
ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"})
objs, err := b.ListCalendarObjects(ctx, "/cal/home/holidays/", nil)
if err != nil {
t.Fatalf("ListCalendarObjects: %v", err)
}
if len(objs) != 1 {
t.Fatalf("expected 1 object, got %d", len(objs))
}
// The Path must be derived from the event's DTSTART/DTEND/SUMMARY —
// i.e. icssub.EventID(ev), not from the event's position in the feed.
ev := mustCalEvent(t, icsSample)
wantID := icssub.EventID(ev)
if wantID == "" {
t.Fatal("EventID must be non-empty for a valid event")
}
wantPath := calObjectPath("holidays", wantID)
if objs[0].Path != wantPath {
t.Fatalf("Path = %q, want %q (icssub.EventID-based, not position-based)", objs[0].Path, wantPath)
}
// GetCalendarObject by the same Path should return an object that has
// a valid VEVENT whose UID matches our source ICS (round-trip
// correctness).
found, err := b.GetCalendarObject(ctx, objs[0].Path, nil)
if err != nil {
t.Fatalf("GetCalendarObject: %v", err)
}
if found.Path != objs[0].Path {
t.Fatalf("round-trip path mismatch: %q vs %q", found.Path, objs[0].Path)
}
if found.Data == nil || len(found.Data.Events()) == 0 {
t.Fatalf("expected found.Data to have >=1 event, got %+v", found.Data)
}
uid := found.Data.Events()[0].Props.Get(ical.PropUID)
if uid == nil || uid.Value != "shared1@nidus.test" {
t.Fatalf("expected UID shared1@nidus.test in round-tripped event, got %+v", uid)
}
}
// TestIcssubCacheSharedBetweenConsumersInCalDav verifies that one
// *icssub.Cache shared by two different callers of the same backend does
// not re-fetch the upstream twice (the second caller sees the cached
// copy via the singleflight guard), proving the "return cached value and
// update in the background" design is reachable from the CalDAV API.
func TestIcssubCacheSharedBetweenConsumersInCalDav(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
// Sleep long enough that *if* the second Get blocked, this
// test's wall clock would obviously exceed the bound below.
time.Sleep(30 * time.Millisecond)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(icsSample))
}))
defer srv.Close()
dir := t.TempDir()
st, err := store.NewStore(filepath.Join(dir, "data"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
dbase, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("db.Open: %v", err)
}
defer dbase.Close()
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil {
t.Fatalf("CreateICSSubscription: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
// One cache, shared by two backends on the same DB — mirrors the
// production wiring (cmd/server/main.go) where the CalDAV and web UI
// share one instance.
shared := icssub.NewCache(time.Hour)
b1 := NewBackend(&config.Config{}, st, dbase, logger, shared)
b2 := NewBackend(&config.Config{}, st, dbase, logger, shared)
ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"})
if _, err := b1.ListCalendarObjects(ctx, "/cal/home/holidays/", nil); err != nil {
t.Fatalf("backend 1 list: %v", err)
}
after1 := hits.Load()
if _, err := b2.ListCalendarObjects(ctx, "/cal/home/holidays/", nil); err != nil {
t.Fatalf("backend 2 list: %v", err)
}
after2 := hits.Load()
if after2 > after1+1 {
t.Fatalf("expected shared cache to coalesce (hits %d → %d)", after1, after2)
}
}
// TestIcssubCacheStaleReturnsWithBackgroundRefresh verifies the
// stale-while-revalidate path through the public Cache API: a cached
// entry past its TTL is still returned immediately (never blocking the
// caller for the slow 30 ms fetch), while a background refresh updates
// the entry for the next Get.
func TestIcssubCacheStaleReturnsWithBackgroundRefresh(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
time.Sleep(50 * time.Millisecond)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(icsSample))
}))
defer srv.Close()
c := icssub.NewCache(2 * time.Millisecond) // very short TTL
if _, err := c.Get(srv.URL); err != nil {
t.Fatalf("first Get: %v", err)
}
firstHits := hits.Load()
time.Sleep(5 * time.Millisecond) // force staleness
st := time.Now()
if _, err := c.Get(srv.URL); err != nil {
t.Fatalf("stale Get: %v", err)
}
if time.Since(st) > 40*time.Millisecond {
t.Fatalf("stale Get blocked on the network for %v — should have returned the cached copy", time.Since(st))
}
// Wait for the background refresh to land.
deadline := time.Now().Add(2 * time.Second)
for hits.Load() == firstHits && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if hits.Load() == firstHits {
t.Fatalf("background refresh did not fire (hits stayed at %d)", firstHits)
}
}
// TestICSSubscriptionEventIDMatchesEventInCalDav proves that the object
// path the CalDAV backend advertises (in ListCalendarObjects) is exactly
// the same string the EventDetailPage-style lookup would use on the web
// side — i.e. both surfaces agree on what icssub.EventID(ev) produces.
func TestICSSubscriptionEventIDMatchesEventInCalDav(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(icsSample))
}))
defer srv.Close()
dir := t.TempDir()
st, err := store.NewStore(filepath.Join(dir, "data"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
dbase, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("db.Open: %v", err)
}
defer dbase.Close()
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil {
t.Fatalf("CreateICSSubscription: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
b := NewBackend(&config.Config{}, st, dbase, logger, icssub.NewCache(time.Hour))
ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"})
objs, err := b.ListCalendarObjects(ctx, "/cal/home/holidays/", nil)
if err != nil || len(objs) == 0 {
t.Fatalf("list: objs=%d err=%v", len(objs), err)
}
gotPath := objs[0].Path
wantID := icssub.EventID(mustCalEvent(t, icsSample))
wantPath := calObjectPath("holidays", wantID)
if gotPath != wantPath {
t.Fatalf("CalDAV object path %q does not match web-side EventID-derived %q", gotPath, wantPath)
}
}
+4 -4
View File
@@ -9,13 +9,13 @@ import (
"strings" "strings"
"time" "time"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store"
vcard "github.com/emersion/go-vcard" vcard "github.com/emersion/go-vcard"
"github.com/emersion/go-webdav" "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/carddav" "github.com/emersion/go-webdav/carddav"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
) )
// sharedNameSep separates the owner from the address book name in the // sharedNameSep separates the owner from the address book name in the
+4 -4
View File
@@ -8,11 +8,11 @@ import (
"strings" "strings"
"testing" "testing"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store"
vcard "github.com/emersion/go-vcard" vcard "github.com/emersion/go-vcard"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
) )
func newTestBackend(t *testing.T) (*Backend, *db.DB) { func newTestBackend(t *testing.T) (*Backend, *db.DB) {
+38 -65
View File
@@ -3,97 +3,70 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"strconv"
"gopkg.in/yaml.v3"
) )
// Config is the top-level server configuration. // Config is the top-level server configuration.
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig
Auth AuthConfig `yaml:"auth"` Auth AuthConfig
Storage StorageConfig `yaml:"storage"` Storage StorageConfig
TLS TLSConfig `yaml:"tls"` Logging LoggingConfig
Logging LoggingConfig `yaml:"logging"`
} }
type ServerConfig struct { type ServerConfig struct {
Host string `yaml:"host"` Host string
Port int `yaml:"port"` Port int
// Base URL used in DAV responses (e.g. https://dav.example.com) BaseURL string
BaseURL string `yaml:"base_url"`
} }
type AuthConfig struct { type AuthConfig struct {
// Realm shown in WWW-Authenticate header Realm string
Realm string `yaml:"realm"`
} }
type StorageConfig struct { type StorageConfig struct {
// Root directory for all data DataDir string
DataDir string `yaml:"data_dir"`
}
type TLSConfig struct {
Enabled bool `yaml:"enabled"`
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
} }
type LoggingConfig struct { type LoggingConfig struct {
Level string `yaml:"level"` // debug | info | warn | error Level string
Format string `yaml:"format"` // text | json Format string
}
// Load reads and parses a YAML config file.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config %q: %w", path, err)
} }
// Load reads and parses environment variables to create the configuration.
func Load() (*Config, error) {
cfg := &Config{} cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parsing config %q: %w", path, err)
}
cfg.applyDefaults() cfg.applyDefaults()
return cfg, nil
return cfg, cfg.validate()
} }
func (c *Config) applyDefaults() { func (c *Config) applyDefaults() {
if c.Server.Host == "" { c.Server.Host = getEnv("NIDUS_HOST", "0.0.0.0")
c.Server.Host = "0.0.0.0" c.Server.Port = getEnvInt("NIDUS_PORT", 8080)
} c.Server.BaseURL = getEnv("NIDUS_BASE_URL", "")
if c.Server.Port == 0 {
c.Server.Port = 8080
}
if c.Server.BaseURL == "" { if c.Server.BaseURL == "" {
scheme := "http" c.Server.BaseURL = fmt.Sprintf("http://%s:%d", c.Server.Host, c.Server.Port)
if c.TLS.Enabled {
scheme = "https"
}
c.Server.BaseURL = fmt.Sprintf("%s://%s:%d", scheme, c.Server.Host, c.Server.Port)
}
if c.Auth.Realm == "" {
c.Auth.Realm = "DAV Server"
}
if c.Storage.DataDir == "" {
c.Storage.DataDir = "./data"
}
if c.Logging.Level == "" {
c.Logging.Level = "info"
}
if c.Logging.Format == "" {
c.Logging.Format = "text"
}
} }
func (c *Config) validate() error { c.Auth.Realm = getEnv("NIDUS_AUTH_REALM", "DAV Server")
if c.TLS.Enabled { c.Storage.DataDir = getEnv("NIDUS_DATA_DIR", "./data")
if c.TLS.CertFile == "" || c.TLS.KeyFile == "" { c.Logging.Level = getEnv("NIDUS_LOG_LEVEL", "info")
return fmt.Errorf("tls.cert_file and tls.key_file are required when tls is enabled") c.Logging.Format = getEnv("NIDUS_LOG_FORMAT", "text")
}
func getEnv(key string, defaultValue string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
if val := os.Getenv(key); val != "" {
if intVal, err := strconv.Atoi(val); err == nil {
return intVal
} }
} }
return nil return defaultValue
} }
-2
View File
@@ -102,8 +102,6 @@ func (d *DB) DisplayName(username string) string {
return u.DisplayName return u.DisplayName
} }
// DeleteUser removes username along with all of its calendars, address // DeleteUser removes username along with all of its calendars, address
// books, and sharing grants (calendars/addressbooks cascade via foreign // books, and sharing grants (calendars/addressbooks cascade via foreign
// key; shares are cleaned up explicitly since they reference usernames as // key; shares are cleaned up explicitly since they reference usernames as
+156 -28
View File
@@ -7,6 +7,8 @@ package icssub
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -16,11 +18,11 @@ import (
ical "github.com/emersion/go-ical" ical "github.com/emersion/go-ical"
"github.com/yourusername/caldav-server/internal/icalfix" "git.arnef.de/arnef/nidus/internal/icalfix"
) )
// DefaultTTL is how long a fetched calendar is cached before being // DefaultTTL is how long a fetched calendar is considered "fresh" before a
// re-fetched on the next access. // Get will kick off a background refresh.
const DefaultTTL = 15 * time.Minute const DefaultTTL = 15 * time.Minute
// fetchTimeout bounds how long a single upstream request may take, so one // fetchTimeout bounds how long a single upstream request may take, so one
@@ -32,54 +34,143 @@ const fetchTimeout = 15 * time.Second
// response. // response.
const maxBodySize = 32 * 1024 * 1024 // 32 MiB const maxBodySize = 32 * 1024 * 1024 // 32 MiB
// entry holds everything the Cache knows about a single upstream URL. All
// fields are only read/written while holding Cache.mu.
type entry struct { type entry struct {
fetchedAt time.Time url string // original URL as supplied by the caller (fetch normalizes)
// cal is the most recent successfully-fetched calendar. Nil until the
// first successful fetch for this URL.
cal *ical.Calendar cal *ical.Calendar
err error // lastErr is the most recent fetch error. Set alongside cal == nil
// (i.e. no successful fetch yet); cleared the moment a fetch succeeds.
lastErr error
// refreshing is true while a fetch (foreground, or background refresh)
// is in flight for this URL.
refreshing bool
// pending is the completion channel for the in-flight fetch. Only valid
// while refreshing is true; it is created fresh for each fetch and
// closed exactly once when that fetch finishes. Callers that see
// refreshing==true read this channel (under the lock) and wait on it.
pending chan struct{}
// fetchedAt is the wall-clock time of the most recent fetch attempt
// (success or failure), used for the TTL freshness check.
fetchedAt time.Time
} }
// Cache fetches remote ICS calendars over HTTP(S), keeping a short-lived // Cache fetches remote ICS calendars over HTTP(S), keeping a shared
// in-memory copy per URL so repeated renders (e.g. every month-view page // in-memory copy per URL. Semantics:
// load, or CalDAV client polling) don't re-fetch the same subscription //
// from origin every time. // - Fresh entry (fetchedAt within TTL): return immediately, no I/O.
// - Stale entry with a cached copy: return the stale copy immediately
// AND spawn at most one background refresher (other callers in the
// same window piggyback on the in-flight refresh).
// - Stale entry with no cached copy (prior fetch failed): return the
// cached error immediately AND spawn a background retry.
// - No entry at all (very first call for this URL): block until a
// foreground fetch finishes (concurrent first-callers wait on a shared
// channel and all get the same result) and return its data.
type Cache struct { type Cache struct {
ttl time.Duration ttl time.Duration
client *http.Client client *http.Client
mu sync.Mutex mu sync.Mutex
entries map[string]entry urls map[string]*entry // keyed by normalizeURL(url)
} }
// NewCache creates a Cache with the given TTL (use DefaultTTL if unsure). // NewCache creates a Cache with the given TTL (use DefaultTTL if unsure).
func NewCache(ttl time.Duration) *Cache { func NewCache(ttl time.Duration) *Cache {
return &Cache{ return &Cache{
ttl: ttl, ttl: ttl,
client: &http.Client{Timeout: fetchTimeout}, client: &http.Client{},
entries: make(map[string]entry), urls: make(map[string]*entry),
} }
} }
// Get returns the parsed calendar fetched from url, using a cached copy // Get returns the most recently successfully-fetched calendar for url, or
// if it's still within the TTL. If a fresh fetch fails but a previously // the most-recent fetch error if no successful copy exists yet (a
// fetched copy exists, the stale copy is returned instead of the error, // background refresher may already be retrying).
// so a transient network issue doesn't blank out the calendar entirely.
func (c *Cache) Get(url string) (*ical.Calendar, error) { func (c *Cache) Get(url string) (*ical.Calendar, error) {
key := normalizeURL(url)
c.mu.Lock() c.mu.Lock()
e, ok := c.entries[url] e := c.urls[key]
fresh := ok && time.Since(e.fetchedAt) < c.ttl if e == nil {
c.mu.Unlock() e = &entry{url: url}
if fresh { c.urls[key] = e
return e.cal, e.err
} }
cal, err := c.fetch(url) switch {
c.mu.Lock() case e.cal == nil && e.lastErr == nil && !e.refreshing:
defer c.mu.Unlock() // Very first request for this URL: do a foreground fetch.
if err != nil && ok && e.cal != nil { e.refreshing = true
return e.cal, nil e.pending = make(chan struct{})
ch := e.pending
c.mu.Unlock()
go c.doFetch(e, ch)
<-ch
return c.snapshot(e)
case e.cal == nil && e.lastErr == nil:
// A foreground fetch is already in flight — wait for it.
ch := e.pending
c.mu.Unlock()
<-ch
return c.snapshot(e)
default:
// We have some data (a cached copy or a cached error).
if time.Since(e.fetchedAt) < c.ttl {
// Fresh — just return.
c.mu.Unlock()
return c.snapshot(e)
} }
c.entries[url] = entry{fetchedAt: time.Now(), cal: cal, err: err} // Stale — return the cached value immediately; spawn at most one
return cal, err // background refresher (or piggyback on one already in flight).
if !e.refreshing {
e.refreshing = true
ch := make(chan struct{})
e.pending = ch
c.mu.Unlock()
go c.doFetch(e, ch)
} else {
c.mu.Unlock()
}
return c.snapshot(e)
}
}
// doFetch performs the network I/O for the entry, updates cal/lastErr and
// the freshness timestamp under the lock, clears the in-flight state, and
// closes the per-fetch completion channel exactly once.
func (c *Cache) doFetch(e *entry, ch chan struct{}) {
cal, err := c.fetch(e.url)
c.mu.Lock()
e.fetchedAt = time.Now()
if err == nil {
e.cal = cal
e.lastErr = nil
} else {
e.lastErr = err
}
e.refreshing = false
e.pending = nil
c.mu.Unlock()
close(ch)
}
// snapshot reads e.cal/e.lastErr under c.mu and returns the same value
// shape Get does. Callers must not hold c.mu.
func (c *Cache) snapshot(e *entry) (*ical.Calendar, error) {
c.mu.Lock()
cal, err := e.cal, e.lastErr
c.mu.Unlock()
if cal != nil {
return cal, nil
}
return nil, err
} }
// fetch downloads and parses url, translating a "webcal://" scheme (used // fetch downloads and parses url, translating a "webcal://" scheme (used
@@ -128,3 +219,40 @@ func normalizeURL(u string) string {
} }
return u return u
} }
// EventID returns a stable, short identifier for a single ical.Event,
// suitable for use as a filesystem object name or URL path segment. It is
// the first 32 hex chars (128 bits) of
// sha256("<DTSTART-value>|<duration>|<SUMMARY>") with a ".ics" suffix.
//
// Two events with the same DTSTART, same duration, and same SUMMARY hash
// to the same ID — this matches the addressing scheme used both by the
// web detail view and the CalDAV backend for ICS-subscription events.
// Returns "" if DTSTART is missing (not addressable).
func EventID(ev ical.Event) string {
start := ev.Props.Get(ical.PropDateTimeStart)
if start == nil {
return ""
}
dur := ""
if end := ev.Props.Get(ical.PropDateTimeEnd); end != nil {
if s, err := start.DateTime(time.UTC); err == nil {
if e, err := end.DateTime(time.UTC); err == nil {
dur = e.Sub(s).Round(time.Second).String()
}
}
}
summary := ""
if p := ev.Props.Get(ical.PropSummary); p != nil {
summary = p.Value
}
var keyBuf strings.Builder
keyBuf.WriteString(start.Value)
keyBuf.WriteRune('|')
keyBuf.WriteString(dur)
keyBuf.WriteRune('|')
keyBuf.WriteString(summary)
sum := sha256.Sum256([]byte(keyBuf.String()))
return hex.EncodeToString(sum[:16]) + ".ics"
}
+199
View File
@@ -0,0 +1,199 @@
package icssub
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
ical "github.com/emersion/go-ical"
)
const sampleICS = "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"PRODID:-//nidus//test//EN\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:ev1@nidus.test\r\n" +
"DTSTAMP:20260101T000000Z\r\n" +
"DTSTART:20260805T090000Z\r\n" +
"DTEND:20260805T100000Z\r\n" +
"SUMMARY:Original title\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
func mustParseEvent(raw string) ical.Event {
cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
if err != nil {
panic("mustParseEvent: " + err.Error())
}
evs := cal.Events()
if len(evs) == 0 {
panic("mustParseEvent: no events")
}
return evs[0]
}
// TestCacheFirstFetchPopulatesEntry verifies that the very first Get for a
// URL does a foreground fetch and returns the parsed calendar.
func TestCacheFirstFetchPopulatesEntry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(sampleICS))
}))
defer srv.Close()
c := NewCache(time.Hour)
cal, err := c.Get(srv.URL)
if err != nil {
t.Fatalf("first Get: %v", err)
}
if len(cal.Events()) == 0 {
t.Fatalf("expected >=1 event, got 0")
}
}
// TestCacheStaleReturnsWithBackgroundRefresh verifies that once a cached
// entry is stale, Get keeps returning the *stale* copy immediately while a
// background refresh is spawned in a separate goroutine (rather than
// blocking the caller on the network).
func TestCacheStaleReturnsWithBackgroundRefresh(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
time.Sleep(50 * time.Millisecond) // make the fetch slow enough to
w.WriteHeader(http.StatusOK) // observe as background work
_, _ = w.Write([]byte(sampleICS))
}))
defer srv.Close()
c := NewCache(1 * time.Millisecond) // very short TTL
if _, err := c.Get(srv.URL); err != nil {
t.Fatalf("first Get: %v", err)
}
firstHits := hits.Load()
// Force staleness.
time.Sleep(3 * time.Millisecond)
// Subsequent Get must return the stale copy immediately without waiting
// for the slow server to respond — if it blocked, this call would take
// >= 50ms, which we bound with a deadline below via the hit counter.
st := time.Now()
cal, err := c.Get(srv.URL)
if err != nil {
t.Fatalf("second Get: %v", err)
}
if len(cal.Events()) == 0 {
t.Fatalf("expected event in stale response")
}
if time.Since(st) > 40*time.Millisecond {
t.Fatalf("second Get appears to have blocked on the network for %v", time.Since(st))
}
// Wait for the background refresh to complete.
deadline := time.Now().Add(2 * time.Second)
for hits.Load() == firstHits && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if hits.Load() == firstHits {
t.Fatalf("background refresh did not fire (hits stayed at %d)", firstHits)
}
}
// TestCacheConcurrentFirstFetchCoalesce ensures N concurrent first calls
// for the same URL coalesce to a small number of actual origin fetches.
func TestCacheConcurrentFirstFetchCoalesce(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
time.Sleep(40 * time.Millisecond) // widen the coalescing window
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(sampleICS))
}))
defer srv.Close()
c := NewCache(time.Hour)
const n = 8
errs := make([]error, n)
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
_, err := c.Get(srv.URL)
errs[i] = err
}(i)
}
close(start)
wg.Wait()
for _, err := range errs {
if err != nil {
t.Fatalf("goroutine error: %v", err)
}
}
// Without coalescing we'd see n=8 hits; with it we expect a small
// constant (the first caller fetches; the rest either return the
// in-progress result or kick off a coalesced background refresh).
if hits.Load() > 3 {
t.Fatalf("expected coalescing to reduce fetches (got %d)", hits.Load())
}
}
// TestEventIDStable verifies that the same event hashes to the same ID
// across calls, and is the right shape (32 hex chars + ".ics").
func TestEventIDStable(t *testing.T) {
ev := mustParseEvent(sampleICS)
id1 := EventID(ev)
id2 := EventID(ev)
if id1 != id2 {
t.Fatalf("EventID not stable: %q vs %q", id1, id2)
}
if !strings.HasSuffix(id1, ".ics") {
t.Fatalf("EventID should end in .ics, got %q", id1)
}
if len(id1) != 32+4 {
t.Fatalf("expected 32 hex chars + .ics, got %q (len %d)", id1, len(id1))
}
}
// TestEventIDDiffersForDifferentEvents verifies that two events with
// different titles or different start times hash to different IDs, while
// two events with only a different UID hash to the same ID.
func TestEventIDDiffersForDifferentEvents(t *testing.T) {
base := EventID(mustParseEvent(sampleICS))
// Different title.
rawA := strings.Replace(sampleICS, "SUMMARY:Original title", "SUMMARY:Other title", 1)
if EventID(mustParseEvent(rawA)) == base {
t.Fatal("same start, different title must hash differently")
}
// Different start time.
rawB := strings.Replace(sampleICS, "DTSTART:20260805T090000Z", "DTSTART:20260806T090000Z", 1)
if EventID(mustParseEvent(rawB)) == base {
t.Fatal("different start times must hash differently")
}
// Same start, same title, different UID → same hash.
rawC := strings.Replace(sampleICS, "UID:ev1@nidus.test", "UID:ev2@nidus.test", 1)
if EventID(mustParseEvent(rawC)) != base {
t.Fatal("UID should not affect the hash — same start/dur/title must be equal")
}
}
// TestEventIDWorksWithoutUID verifies EventID still produces a value (from
// DTSTART+DTEND+SUMMARY) when the event has no UID property.
func TestEventIDWorksWithoutUID(t *testing.T) {
raw := strings.Replace(sampleICS, "UID:ev1@nidus.test\r\n", "", 1)
ev := mustParseEvent(raw)
id := EventID(ev)
if id == "" {
t.Fatal("EventID should be non-empty even without UID")
}
}
+200
View File
@@ -0,0 +1,200 @@
package store
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Migrate restructures the data directory from the old format to the new unified format.
// It is idempotent and can be run multiple times safely.
//
// Old structure:
//
// data/files/<username>/
// data/<username>/cal-<name>/
// data/<username>/card-<name>/
//
// New structure:
//
// data/<username>/files/
// data/<username>/calendars/<name>/
// data/<username>/addressbooks/<name>/
func (s *Store) Migrate() error {
users, err := s.listUserDirectories()
if err != nil {
return fmt.Errorf("listing user directories: %w", err)
}
for _, user := range users {
if err := s.migrateUser(user); err != nil {
return fmt.Errorf("migrating user %q: %w", user, err)
}
}
return nil
}
// listUserDirectories returns all user directories in the data directory.
// It looks for directories that are NOT the old "files" directory,
// and also checks inside the old "files" directory for users who need migrating.
func (s *Store) listUserDirectories() ([]string, error) {
entries, err := os.ReadDir(s.rootDir)
if err != nil {
return nil, err
}
usersMap := make(map[string]bool)
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
// Skip the old files directory (will handle users inside it separately)
if name == "files" {
continue
}
usersMap[name] = true
}
// Also check the old files directory for users
filesDir := filepath.Join(s.rootDir, "files")
if filesEntries, err := os.ReadDir(filesDir); err == nil {
for _, e := range filesEntries {
if e.IsDir() {
usersMap[e.Name()] = true
}
}
}
var users []string
for user := range usersMap {
users = append(users, user)
}
return users, nil
}
// migrateUser migrates a single user's data from old to new structure.
func (s *Store) migrateUser(user string) error {
userDir := filepath.Join(s.rootDir, user)
// Create user directory if it doesn't exist (needed for WebDAV migration)
if err := os.MkdirAll(userDir, 0o755); err != nil {
return fmt.Errorf("creating user directory %q: %w", userDir, err)
}
// Migrate WebDAV files: files/<username> -> <username>/files
if err := s.migrateWebDAV(user); err != nil {
return err
}
// Migrate CalDAV calendars: cal-<name> -> calendars/<name>
if err := s.migrateCalendars(user, userDir); err != nil {
return err
}
// Migrate CardDAV address books: card-<name> -> addressbooks/<name>
if err := s.migrateAddressBooks(user, userDir); err != nil {
return err
}
return nil
}
// migrateWebDAV moves the old files/<username> directory to <username>/files.
func (s *Store) migrateWebDAV(user string) error {
oldPath := filepath.Join(s.rootDir, "files", user)
newPath := filepath.Join(s.rootDir, user, "files")
// Check if old directory exists and new doesn't
if _, err := os.Stat(oldPath); os.IsNotExist(err) {
return nil
}
if _, err := os.Stat(newPath); err == nil {
return nil // Already migrated
}
// Create parent directory if needed
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
return fmt.Errorf("creating directory %q: %w", filepath.Dir(newPath), err)
}
// Move the directory
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("moving %q to %q: %w", oldPath, newPath, err)
}
return nil
}
// migrateCalendars moves old cal-<name> directories to calendars/<name>.
func (s *Store) migrateCalendars(user string, userDir string) error {
entries, err := os.ReadDir(userDir)
if err != nil {
return fmt.Errorf("reading user directory %q: %w", userDir, err)
}
var calendars []string
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), "cal-") {
calendars = append(calendars, e.Name())
}
}
if len(calendars) == 0 {
return nil
}
calendarsDir := filepath.Join(userDir, "calendars")
if err := os.MkdirAll(calendarsDir, 0o755); err != nil {
return fmt.Errorf("creating calendars directory %q: %w", calendarsDir, err)
}
for _, calName := range calendars {
oldPath := filepath.Join(userDir, calName)
newPath := filepath.Join(calendarsDir, strings.TrimPrefix(calName, "cal-"))
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("moving calendar %q: %w", calName, err)
}
}
return nil
}
// migrateAddressBooks moves old card-<name> directories to addressbooks/<name>.
func (s *Store) migrateAddressBooks(user string, userDir string) error {
entries, err := os.ReadDir(userDir)
if err != nil {
return fmt.Errorf("reading user directory %q: %w", userDir, err)
}
var addressBooks []string
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), "card-") {
addressBooks = append(addressBooks, e.Name())
}
}
if len(addressBooks) == 0 {
return nil
}
addressBooksDir := filepath.Join(userDir, "addressbooks")
if err := os.MkdirAll(addressBooksDir, 0o755); err != nil {
return fmt.Errorf("creating addressbooks directory %q: %w", addressBooksDir, err)
}
for _, bookName := range addressBooks {
oldPath := filepath.Join(userDir, bookName)
newPath := filepath.Join(addressBooksDir, strings.TrimPrefix(bookName, "card-"))
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("moving address book %q: %w", bookName, err)
}
}
return nil
}
+116
View File
@@ -0,0 +1,116 @@
package store
import (
"os"
"path/filepath"
"testing"
)
func TestMigrate(t *testing.T) {
tmpDir := t.TempDir()
// Create old structure
// WebDAV: files/<username>/
if err := os.MkdirAll(filepath.Join(tmpDir, "files", "alice"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "files", "alice", "test.txt"), []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
// CalDAV: <username>/cal-<name>/
if err := os.MkdirAll(filepath.Join(tmpDir, "bob", "cal-work"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "bob", "cal-work", "evt.ics"), []byte("calendar"), 0o644); err != nil {
t.Fatal(err)
}
// CardDAV: <username>/card-<name>/
if err := os.MkdirAll(filepath.Join(tmpDir, "bob", "card-contacts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "bob", "card-contacts", "vcard.vcf"), []byte("card"), 0o644); err != nil {
t.Fatal(err)
}
// Create store and migrate
st, err := NewStore(tmpDir)
if err != nil {
t.Fatal(err)
}
if err := st.Migrate(); err != nil {
t.Fatal(err)
}
// Verify WebDAV: <username>/files/
if _, err := os.Stat(filepath.Join(tmpDir, "alice", "files", "test.txt")); err != nil {
t.Errorf("alice files not migrated: %v", err)
}
// Verify CalDAV: <username>/calendars/<name>
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "calendars", "work", "evt.ics")); err != nil {
t.Errorf("bob calendars not migrated: %v", err)
}
// Verify CardDAV: <username>/addressbooks/<name>
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "addressbooks", "contacts", "vcard.vcf")); err != nil {
t.Errorf("bob addressbooks not migrated: %v", err)
}
// Verify old structure is gone
if _, err := os.Stat(filepath.Join(tmpDir, "files", "alice")); err == nil {
t.Error("old files directory not removed")
}
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "cal-work")); err == nil {
t.Error("old cal- directory not removed")
}
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "card-contacts")); err == nil {
t.Error("old card- directory not removed")
}
}
func TestMigrateIdempotent(t *testing.T) {
tmpDir := t.TempDir()
// Create new structure
if err := os.MkdirAll(filepath.Join(tmpDir, "alice", "files"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "alice", "files", "test.txt"), []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
st, err := NewStore(tmpDir)
if err != nil {
t.Fatal(err)
}
// Run migration twice
if err := st.Migrate(); err != nil {
t.Fatal(err)
}
if err := st.Migrate(); err != nil {
t.Fatal(err)
}
// Verify data still there
if _, err := os.Stat(filepath.Join(tmpDir, "alice", "files", "test.txt")); err != nil {
t.Errorf("data not preserved: %v", err)
}
}
func TestMigrateMissingDirectories(t *testing.T) {
tmpDir := t.TempDir()
st, err := NewStore(tmpDir)
if err != nil {
t.Fatal(err)
}
// Should not error on empty directory
if err := st.Migrate(); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
+22 -3
View File
@@ -48,9 +48,26 @@ func (s *Store) lockFor(user string) *sync.RWMutex {
return l return l
} }
// collectionPath returns the filesystem path for a collection. // collectionPath returns the filesystem path for a collection in the new unified format.
// Calendar collections: <username>/calendars/<name>
// Address book collections: <username>/addressbooks/<name>
// WebDAV collections: <username>/files (all files in one directory)
func (s *Store) collectionPath(user, collection string) string { func (s *Store) collectionPath(user, collection string) string {
return filepath.Join(s.rootDir, sanitize(user), sanitize(collection)) userDir := filepath.Join(s.rootDir, sanitize(user))
if strings.HasPrefix(collection, "cal-") {
return filepath.Join(userDir, "calendars", strings.TrimPrefix(collection, "cal-"))
}
if strings.HasPrefix(collection, "card-") {
return filepath.Join(userDir, "addressbooks", strings.TrimPrefix(collection, "card-"))
}
if collection == "files" {
return filepath.Join(userDir, "files")
}
return filepath.Join(userDir, sanitize(collection))
} }
// objectPath returns the filesystem path for an object within a collection. // objectPath returns the filesystem path for an object within a collection.
@@ -68,6 +85,7 @@ func (s *Store) EnsureCollection(user, collection string) error {
} }
// ListCollections returns all collection names for a user. // ListCollections returns all collection names for a user.
// Returns both old-style (cal-*, card-*) and new-style (calendars/*, addressbooks/*) collections.
func (s *Store) ListCollections(user string) ([]string, error) { func (s *Store) ListCollections(user string) ([]string, error) {
l := s.lockFor(user) l := s.lockFor(user)
l.RLock() l.RLock()
@@ -85,7 +103,8 @@ func (s *Store) ListCollections(user string) ([]string, error) {
var names []string var names []string
for _, e := range entries { for _, e := range entries {
if e.IsDir() { if e.IsDir() {
names = append(names, e.Name()) name := e.Name()
names = append(names, name)
} }
} }
return names, nil return names, nil
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"sync" "sync"
"testing" "testing"
"github.com/yourusername/caldav-server/internal/store" "git.arnef.de/arnef/nidus/internal/store"
) )
func TestStoreRoundTrip(t *testing.T) { func TestStoreRoundTrip(t *testing.T) {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
// handleAccount serves GET /account: the current user's own profile and // handleAccount serves GET /account: the current user's own profile and
+118 -13
View File
@@ -15,11 +15,13 @@ import (
"strings" "strings"
"time" "time"
"git.arnef.de/arnef/nidus/internal/birthdays"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icalfix"
"git.arnef.de/arnef/nidus/internal/icssub"
"git.arnef.de/arnef/nidus/internal/store"
"git.arnef.de/arnef/nidus/internal/web/templates"
ical "github.com/emersion/go-ical" ical "github.com/emersion/go-ical"
"github.com/yourusername/caldav-server/internal/birthdays"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/icalfix"
"github.com/yourusername/caldav-server/internal/web/templates"
) )
// eventIDRe validates an event's object ID as it appears in a URL path // eventIDRe validates an event's object ID as it appears in a URL path
@@ -527,7 +529,6 @@ func mondayOf(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset) return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset)
} }
// eventDayRange returns the inclusive [start, end] calendar-day span an // eventDayRange returns the inclusive [start, end] calendar-day span an
// event occupies, in loc, for placing it on the month grid. // event occupies, in loc, for placing it on the month grid.
func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end time.Time, err error) { func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end time.Time, err error) {
@@ -637,6 +638,108 @@ func newEventFormDefaults(calRef, dateParam string) templates.EventFormData {
} }
} }
// handleEventView renders the read-only detail view for a single event. It
// is the landing page when a user clicks an event in the month/week grid;
// writable calendars link from here to the edit page.
func (s *Server) handleEventView(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
ref := r.PathValue("ref")
id := r.PathValue("id")
// For ICS subscriptions, ref is "!<name>" and id is the stable
// icssub.EventID (already ".ics"-suffixed). For regular calendars,
// ref is a bare name or "owner~name" and id is a "<hex>.ics" filename
// from the eventIDRe character set. Both shapes share the same
// "hex/alnum .ics" shape, so we only need the regex check.
if !eventIDRe.MatchString(id) {
http.NotFound(w, r)
return
}
color, form, err := s.eventForDisplay(username, ref, id)
if errors.Is(err, store.ErrNotFound) || errors.Is(err, errCalendarNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.handleCalRefError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventDetail(username, templates.EventDetailData{Form: form, Color: color}).Render(context.Background(), w)
}
// eventForDisplay resolves a calendar reference for read access, loads and
// decodes the event with the given id, populates the form's display fields
// (CalRef, CalendarLabel, Writable), and returns the calendar's color for
// the detail view's header dot. It handles both ordinary (stored) events
// and ICS-subscription events (ref "!<name>").
func (s *Server) eventForDisplay(username, ref, id string) (color string, form templates.EventFormData, err error) {
if strings.HasPrefix(ref, icsRefPrefix) {
return s.icsEventForDisplay(username, ref, id)
}
owner, name, err := s.resolveCalRef(username, ref, false)
if err != nil {
return "", form, err
}
data, err := s.store.GetObject(owner, "cal-"+name, id)
if err != nil {
return "", form, err
}
form, err = eventFormFromICS(id, data)
if err != nil {
s.logger.Error("decoding event", "error", err)
return "", form, err
}
form.CalRef = ref
label := name
if owner != username {
label = name + " (" + s.dbase.DisplayName(owner) + ")"
}
form.CalendarLabel = label
form.Writable = owner == username
if !form.Writable {
if share, err := s.dbase.CalendarShareFor(owner, name, username); err == nil {
form.Writable = share.Permission == db.PermWrite
}
}
color, _ = s.dbase.GetCalendarColor(owner, name)
return color, form, nil
}
// icsEventForDisplay resolves one of username's ICS subscriptions named
// ref[len(!):] and looks up the event whose icssub.EventID hashes to id.
// Returns the subscription's display color and a form with Writable=false.
func (s *Server) icsEventForDisplay(username, ref, id string) (color string, form templates.EventFormData, err error) {
name := strings.TrimPrefix(ref, icsRefPrefix)
sub, err := s.dbase.GetICSSubscription(username, name)
if err != nil {
return "", form, errCalendarNotFound
}
cal, err := s.icsCache.Get(sub.URL)
if err != nil {
return "", form, err
}
for _, ev := range cal.Events() {
if icssub.EventID(ev) != id {
continue
}
form, err = eventFormFromComponent(id, ev)
if err != nil {
return "", form, err
}
form.CalRef = ref
form.CalendarLabel = name
form.Writable = false
return sub.Color, form, nil
}
return "", form, errCalendarNotFound
}
func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
username := userFromContext(r.Context()) username := userFromContext(r.Context())
ref := r.PathValue("ref") ref := r.PathValue("ref")
@@ -1200,11 +1303,11 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta
} }
// addICSEvents fetches entry's remote ICS/webcal calendar (via s.icsCache, // addICSEvents fetches entry's remote ICS/webcal calendar (via s.icsCache,
// which caches it for a while so every month-view render doesn't re-fetch // which uses stale-while-revalidate so a month-view render never blocks on
// from origin) and places each VEVENT's occurrence onto the month grid, // network I/O) and places each VEVENT's occurrence onto the month grid,
// the same way a stored calendar object would be. There's no per-event // the same way a stored calendar object would be. Each event's ID is the
// edit page for these (the source is external and read-only), so each // stable icssub.EventID hash, which routes through the read-only detail
// event's LinkURL is left pointing nowhere useful ("#"). // page (eventForDisplay → icsEventForDisplay).
func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time, loc *time.Location, dayIndex map[string]int, days []templates.MonthDay) error { func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time, loc *time.Location, dayIndex map[string]int, days []templates.MonthDay) error {
cal, err := s.icsCache.Get(entry.ICSURL) cal, err := s.icsCache.Get(entry.ICSURL)
if err != nil { if err != nil {
@@ -1212,8 +1315,11 @@ func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time,
} }
const totalDays = 42 const totalDays = 42
for i, ev := range cal.Events() { for _, ev := range cal.Events() {
id := fmt.Sprintf("ics-%d", i) id := icssub.EventID(ev)
if id == "" {
continue
}
form, err := eventFormFromComponent(id, ev) form, err := eventFormFromComponent(id, ev)
if err != nil { if err != nil {
continue continue
@@ -1247,7 +1353,6 @@ func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time,
Summary: form.Summary, Summary: form.Summary,
TimeText: timeText, TimeText: timeText,
AllDay: form.AllDay, AllDay: form.AllDay,
LinkURL: "#",
}) })
} }
} }
+34 -46
View File
@@ -15,9 +15,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.arnef.de/arnef/nidus/internal/store"
"git.arnef.de/arnef/nidus/internal/web/templates"
vcard "github.com/emersion/go-vcard" vcard "github.com/emersion/go-vcard"
"github.com/yourusername/caldav-server/internal/store"
"github.com/yourusername/caldav-server/internal/web/templates"
) )
// contactIDRe validates a contact's object ID as it appears in a URL path // contactIDRe validates a contact's object ID as it appears in a URL path
@@ -239,7 +239,8 @@ func (s *Server) handleContactEdit(w http.ResponseWriter, r *http.Request) {
// contactFormInput holds the parsed, validated-ish values submitted by the // contactFormInput holds the parsed, validated-ish values submitted by the
// contact form, before they're translated into vCard fields. // contact form, before they're translated into vCard fields.
type contactFormInput struct { type contactFormInput struct {
FullName string Forename string
Surname string
Organization string Organization string
Note string Note string
Birthday string Birthday string
@@ -284,7 +285,8 @@ func parseContactForm(r *http.Request) (contactFormInput, error) {
get := func(key string) []string { return form[key] } get := func(key string) []string { return form[key] }
in := contactFormInput{ in := contactFormInput{
FullName: strings.TrimSpace(formValue(get("full_name"), 0)), Forename: strings.TrimSpace(formValue(get("forename"), 0)),
Surname: strings.TrimSpace(formValue(get("surname"), 0)),
Organization: strings.TrimSpace(formValue(get("organization"), 0)), Organization: strings.TrimSpace(formValue(get("organization"), 0)),
Note: strings.TrimSpace(formValue(get("note"), 0)), Note: strings.TrimSpace(formValue(get("note"), 0)),
Birthday: strings.TrimSpace(formValue(get("birthday"), 0)), Birthday: strings.TrimSpace(formValue(get("birthday"), 0)),
@@ -309,25 +311,13 @@ func parseContactForm(r *http.Request) (contactFormInput, error) {
in.Emails = append(in.Emails, templates.LabeledValue{Type: formValue(emailTypes, i), Value: v}) in.Emails = append(in.Emails, templates.LabeledValue{Type: formValue(emailTypes, i), Value: v})
} }
addrTypes := get("address_type") addrTypes, addrValues := get("address_type"), get("address_value")
streets := get("address_street") for i, v := range addrValues {
cities := get("address_city") v = strings.TrimSpace(v)
postalCodes := get("address_postal_code") if v == "" {
regions := get("address_region")
countries := get("address_country")
for i := range streets {
street := strings.TrimSpace(formValue(streets, i))
city := strings.TrimSpace(formValue(cities, i))
postalCode := strings.TrimSpace(formValue(postalCodes, i))
region := strings.TrimSpace(formValue(regions, i))
country := strings.TrimSpace(formValue(countries, i))
if street == "" && city == "" && postalCode == "" && region == "" && country == "" {
continue continue
} }
in.Addresses = append(in.Addresses, templates.AddressValue{ in.Addresses = append(in.Addresses, templates.AddressValue{Type: formValue(addrTypes, i), Value: v})
Type: formValue(addrTypes, i), Street: street, City: city,
Region: region, PostalCode: postalCode, Country: country,
})
} }
if r.MultipartForm != nil { if r.MultipartForm != nil {
@@ -358,11 +348,12 @@ func (s *Server) saveContactFromForm(w http.ResponseWriter, r *http.Request, use
return return
} }
if in.FullName == "" { if in.Forename == "" && in.Surname == "" {
form := templates.ContactFormData{ form := templates.ContactFormData{
Book: book, Book: book,
ID: id, ID: id,
FullName: in.FullName, Forename: in.Forename,
Surname: in.Surname,
Organization: in.Organization, Organization: in.Organization,
Note: in.Note, Note: in.Note,
Birthday: in.Birthday, Birthday: in.Birthday,
@@ -371,7 +362,7 @@ func (s *Server) saveContactFromForm(w http.ResponseWriter, r *http.Request, use
Addresses: ensureAtLeastOne(in.Addresses), Addresses: ensureAtLeastOne(in.Addresses),
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.ContactForm(username, form, "Full name is required").Render(context.Background(), w) _ = templates.ContactForm(username, form, "Name is required").Render(context.Background(), w)
return return
} }
@@ -422,16 +413,10 @@ func buildCard(uid string, in contactFormInput, existingPhoto string) vcard.Card
if uid != "" { if uid != "" {
card.SetValue(vcard.FieldUID, uid) card.SetValue(vcard.FieldUID, uid)
} }
card.SetValue(vcard.FieldFormattedName, in.FullName) if fn := strings.TrimSpace(in.Forename + " " + in.Surname); fn != "" {
card.SetValue(vcard.FieldFormattedName, fn)
name := &vcard.Name{}
parts := strings.Fields(in.FullName)
if len(parts) > 0 {
name.GivenName = parts[0]
}
if len(parts) > 1 {
name.FamilyName = strings.Join(parts[1:], " ")
} }
name := &vcard.Name{GivenName: in.Forename, FamilyName: in.Surname}
card.SetName(name) card.SetName(name)
if in.Organization != "" { if in.Organization != "" {
@@ -459,14 +444,7 @@ func buildCard(uid string, in contactFormInput, existingPhoto string) vcard.Card
card.Add(vcard.FieldEmail, f) card.Add(vcard.FieldEmail, f)
} }
for _, a := range in.Addresses { for _, a := range in.Addresses {
addr := &vcard.Address{ addr := &vcard.Address{Field: &vcard.Field{}, StreetAddress: a.Value}
Field: &vcard.Field{},
StreetAddress: a.Street,
Locality: a.City,
Region: a.Region,
PostalCode: a.PostalCode,
Country: a.Country,
}
if a.Type != "" { if a.Type != "" {
addr.Params = vcard.Params{vcard.ParamType: {a.Type}} addr.Params = vcard.Params{vcard.ParamType: {a.Type}}
} }
@@ -533,10 +511,8 @@ func addressValues(card vcard.Card) []templates.AddressValue {
if a.Params != nil { if a.Params != nil {
typ = a.Params.Get(vcard.ParamType) typ = a.Params.Get(vcard.ParamType)
} }
out = append(out, templates.AddressValue{ value := strings.Join([]string{a.StreetAddress, a.Locality, a.Region, a.PostalCode, a.Country}, ", ")
Type: typ, Street: a.StreetAddress, City: a.Locality, out = append(out, templates.AddressValue{Type: typ, Value: value})
Region: a.Region, PostalCode: a.PostalCode, Country: a.Country,
})
} }
return out return out
} }
@@ -547,10 +523,22 @@ func contactFormFromCard(book, id string, data []byte) (templates.ContactFormDat
if err != nil { if err != nil {
return templates.ContactFormData{}, err return templates.ContactFormData{}, err
} }
var forename, surname string
if n := card.Name(); n != nil && (n.GivenName != "" || n.FamilyName != "") {
forename, surname = n.GivenName, n.FamilyName
} else if fn := strings.TrimSpace(card.PreferredValue(vcard.FieldFormattedName)); fn != "" {
parts := strings.Fields(fn)
if len(parts) > 1 {
forename, surname = parts[0], strings.Join(parts[1:], " ")
} else {
forename = parts[0]
}
}
return templates.ContactFormData{ return templates.ContactFormData{
Book: book, Book: book,
ID: id, ID: id,
FullName: card.PreferredValue(vcard.FieldFormattedName), Forename: forename,
Surname: surname,
Organization: card.PreferredValue(vcard.FieldOrganization), Organization: card.PreferredValue(vcard.FieldOrganization),
Note: card.PreferredValue(vcard.FieldNote), Note: card.PreferredValue(vcard.FieldNote),
Birthday: card.PreferredValue(vcard.FieldBirthday), Birthday: card.PreferredValue(vcard.FieldBirthday),
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
+209
View File
@@ -0,0 +1,209 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.arnef.de/arnef/nidus/internal/db"
)
// seedEvent stores an all-day event (one-day span) in owner's calendar cal
// under object id, with the given summary/location/description. Used to set
// up events that handleEventView should render.
func seedEvent(t *testing.T, s *Server, owner, cal, id, summary, location, description string) {
t.Helper()
data := "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"PRODID:-//nidus//test//EN\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:" + strings.TrimSuffix(id, ".ics") + "\r\n" +
"DTSTAMP:20260101T000000Z\r\n" +
"SUMMARY:" + summary + "\r\n" +
"LOCATION:" + location + "\r\n" +
"DESCRIPTION:" + description + "\r\n" +
"DTSTART;VALUE=DATE:20260805\r\n" +
"DTEND;VALUE=DATE:20260806\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
if err := s.store.PutObject(owner, "cal-"+cal, id, []byte(data)); err != nil {
t.Fatalf("PutObject: %v", err)
}
}
// getEventDetail issues GET /calendar/{ref}/{id} as the session identified by
// cookie and returns the recorded response.
func getEventDetail(t *testing.T, handler http.Handler, cookie *http.Cookie, ref, id string) *httptest.ResponseRecorder {
t.Helper()
path := "/calendar/" + url.PathEscape(ref) + "/" + url.PathEscape(id)
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
return rr
}
func TestEventDetailShowsOwnEvent(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
id := "aabbccddeeff.ics"
seedEvent(t, s, "alice", "work", id, "Team standup", "Meetroom A", "Daily sync with the team")
rr := getEventDetail(t, handler, cookie, "work", id)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{
"Team standup", // summary / title
"Meetroom A", // location
"Daily sync with the team", // description
"work", // calendar label
">Edit", // writable → Edit link present
"Export .ics",
} {
if !strings.Contains(body, want) {
t.Errorf("expected body to contain %q, got:\n%s", want, body)
}
}
}
func TestEventDetailReadOnlySharedHasNoEdit(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
aliceCookie := loginAs(t, handler, "alice", "password")
bobCookie := loginAs(t, handler, "bob", "password")
id := "123456.ics"
seedEvent(t, s, "bob", "personal", id, "Bob lunch", "Cafe", "Lunch plans")
if err := s.dbase.ShareCalendar("bob", "personal", "alice", db.PermRead); err != nil {
t.Fatalf("ShareCalendar: %v", err)
}
// alice (read share) can view but not edit.
rr := getEventDetail(t, handler, aliceCookie, "bob~personal", id)
if rr.Code != http.StatusOK {
t.Fatalf("alice view: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, "Bob lunch") {
t.Errorf("alice: expected body to contain summary, got:\n%s", body)
}
if !strings.Contains(body, "shared with you as read-only") {
t.Errorf("alice: expected read-only notice, got:\n%s", body)
}
if strings.Contains(body, ">Edit") {
t.Errorf("alice: Edit link should not be present on a read-only share, got:\n%s", body)
}
// bob (owner) can still see the Edit link.
rrBob := getEventDetail(t, handler, bobCookie, "personal", id)
if rrBob.Code != http.StatusOK {
t.Fatalf("bob view own: expected 200, got %d", rrBob.Code)
}
if !strings.Contains(rrBob.Body.String(), ">Edit") {
t.Errorf("bob: expected Edit link, got:\n%s", rrBob.Body.String())
}
}
func TestEventDetailWriteShareHasEditLink(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
seedEvent(t, s, "bob", "personal", "a1b2c3.ics", "Bob meeting", "Office", "Sync")
if err := s.dbase.ShareCalendar("bob", "personal", "alice", db.PermWrite); err != nil {
t.Fatalf("ShareCalendar: %v", err)
}
rr := getEventDetail(t, handler, cookie, "bob~personal", "a1b2c3.ics")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, ">Edit") {
t.Errorf("write share should expose Edit link, got:\n%s", body)
}
if strings.Contains(body, "shared with you as read-only") {
t.Errorf("write share should not show read-only notice, got:\n%s", body)
}
}
func TestEventDetailNotFound(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
// Valid calendar, missing object.
if rr := getEventDetail(t, handler, cookie, "work", "doesnotexist.ics"); rr.Code != http.StatusNotFound {
t.Fatalf("missing event: expected 404, got %d", rr.Code)
}
// Unknown calendar ref.
if rr := getEventDetail(t, handler, cookie, "does_not_exist", "0000.ics"); rr.Code != http.StatusNotFound {
t.Fatalf("unknown calendar: expected 404, got %d", rr.Code)
}
// Invalid id shape (rejected by eventIDRe before store access).
if rr := getEventDetail(t, handler, cookie, "work", "bad/../etc/passwd.ics"); rr.Code != http.StatusNotFound {
t.Fatalf("invalid id: expected 404, got %d", rr.Code)
}
}
func TestEventDetailRequiresLogin(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
req := httptest.NewRequest(http.MethodGet, "/calendar/work/anything.ics", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther {
t.Fatalf("expected redirect to login, got %d", rr.Code)
}
}
func TestEventDetailRejectsNonGet(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
req := httptest.NewRequest(http.MethodPost, "/calendar/work/anything.ics", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405 for POST, got %d", rr.Code)
}
}
// TestEditRouteStillResolves guards against the new detail route
// (/calendar/{ref}/{id}) shadowing the more specific edit/delete/export
// routes under the same {ref}+{id} prefix in Go's ServeMux.
func TestEditRouteStillResolves(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
id := "cafebabe00.ics"
seedEvent(t, s, "alice", "work", id, "Edit me", "Room", "Note")
// GET the edit form — must still hit handleEventEdit, not the detail view.
req := httptest.NewRequest(http.MethodGet, "/calendar/work/cafebabe00.ics/edit", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("edit GET: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "Edit event") || !strings.Contains(rr.Body.String(), "<form") {
t.Fatalf("expected the edit form to render, got:\n%s", rr.Body.String())
}
// The delete route still works (redirect to /web/calendar on success).
req = httptest.NewRequest(http.MethodPost, "/calendar/work/cafebabe00.ics/delete", nil)
req.AddCookie(cookie)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther {
t.Fatalf("delete: expected 303 redirect, got %d: %s", rr.Code, rr.Body.String())
}
}
+48 -4
View File
@@ -12,7 +12,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
// maxUploadMemory bounds how much of a multipart upload is buffered in // maxUploadMemory bounds how much of a multipart upload is buffered in
@@ -24,7 +24,7 @@ const maxUploadMemory = 32 << 20 // 32 MiB
// (internal/webdav) serves at /files/, so the web UI is just another view // (internal/webdav) serves at /files/, so the web UI is just another view
// onto the same files. // onto the same files.
func (s *Server) filesRoot(username string) string { func (s *Server) filesRoot(username string) string {
return filepath.Join(s.cfg.Storage.DataDir, "files", username) return filepath.Join(s.cfg.Storage.DataDir, username, "files")
} }
// sanitizeRelPath cleans a slash-separated relative path (as received from // sanitizeRelPath cleans a slash-separated relative path (as received from
@@ -142,7 +142,7 @@ func (s *Server) handleFilesMkdir(w http.ResponseWriter, r *http.Request, root,
} }
name := strings.TrimSpace(r.PostForm.Get("name")) name := strings.TrimSpace(r.PostForm.Get("name"))
if !resourceNameRe.MatchString(name) { if !resourceNameRe.MatchString(name) {
http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest) http.Error(w, "name must be 1-64 letters, digits, dots, '-' or '_'", http.StatusBadRequest)
return return
} }
@@ -211,6 +211,16 @@ func (s *Server) handleFilesGet(w http.ResponseWriter, r *http.Request, username
return return
} }
// Determine sort order (name asc/desc) from query param, default to asc
sortBy := r.URL.Query().Get("sort_by")
sortDir := r.URL.Query().Get("sort_dir")
if sortBy == "" {
sortBy = "name"
}
if sortDir == "" {
sortDir = "asc"
}
// os.ReadDir already returns entries sorted by filename, so filtering // os.ReadDir already returns entries sorted by filename, so filtering
// into two passes keeps each group (directories, then files) // into two passes keeps each group (directories, then files)
// alphabetically sorted while grouping directories first. // alphabetically sorted while grouping directories first.
@@ -234,6 +244,40 @@ func (s *Server) handleFilesGet(w http.ResponseWriter, r *http.Request, username
files = append(files, entry) files = append(files, entry)
} }
} }
// Sort entries based on sortBy and sortDir
less := func(i, j templates.FileEntry) bool {
switch sortBy {
case "name":
if sortDir == "desc" {
return i.Name > j.Name
}
return i.Name < j.Name
default:
if sortDir == "desc" {
return i.ModTime > j.ModTime
}
return i.ModTime < j.ModTime
}
}
// Sort dirs
for i := 0; i < len(dirs)-1; i++ {
for j := i + 1; j < len(dirs); j++ {
if less(dirs[j], dirs[i]) {
dirs[i], dirs[j] = dirs[j], dirs[i]
}
}
}
// Sort files
for i := 0; i < len(files)-1; i++ {
for j := i + 1; j < len(files); j++ {
if less(files[j], files[i]) {
files[i], files[j] = files[j], files[i]
}
}
}
entries := append(dirs, files...) entries := append(dirs, files...)
var breadcrumbs []templates.Breadcrumb var breadcrumbs []templates.Breadcrumb
@@ -248,7 +292,7 @@ func (s *Server) handleFilesGet(w http.ResponseWriter, r *http.Request, username
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.FilesPage(username, breadcrumbs, entries, relPath).Render(context.Background(), w) _ = templates.FilesPage(username, breadcrumbs, entries, relPath, sortBy, sortDir).Render(context.Background(), w)
} }
func (s *Server) handleFilesUpload(w http.ResponseWriter, r *http.Request, root, fullPath string) { func (s *Server) handleFilesUpload(w http.ResponseWriter, r *http.Request, root, fullPath string) {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
// icsURLValid does a light sanity check on a subscription URL: it must be // icsURLValid does a light sanity check on a subscription URL: it must be
+176
View File
@@ -0,0 +1,176 @@
package web
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icssub"
"git.arnef.de/arnef/nidus/internal/store"
ical "github.com/emersion/go-ical"
)
// icsDetailSample is a minimal valid ICS feed with one VEVENT on Aug 5, 2026.
const icsDetailSample = "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"PRODID:-//nidus//test//EN\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:detail1@nidus.test\r\n" +
"DTSTAMP:20260101T000000Z\r\n" +
"DTSTART:20260805T090000Z\r\n" +
"DTEND:20260805T100000Z\r\n" +
"SUMMARY:ICS detail event\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
// newServerWithICSCache builds a Server wired to an upstream ICS server
// (for deterministic tests) and returns it, sharing one icssub.Cache the
// same way cmd/server/main.go does in production.
func newServerWithICSCache(t *testing.T, upstreamURL string) *Server {
t.Helper()
dir := t.TempDir()
st, err := store.NewStore(filepath.Join(dir, "data"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
d, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { d.Close() })
cfg := &config.Config{}
if err := d.CreateUser("alice", "password", "", ""); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := d.CreateICSSubscription("alice", "holidays", upstreamURL, "#123abc"); err != nil {
t.Fatalf("CreateICSSubscription: %v", err)
}
shared := icssub.NewCache(time.Hour)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewServer(cfg, st, d, logger, shared)
}
// TestICSDetailRouteRendersReadonlyEvent verifies the full ICS detail path:
// the user clicks an ICS event from the month/week grid, the browser lands
// on GET /calendar/!holidays/<icssub.EventID>/, and the handler returns 200
// with the event's fields rendered and NO Edit link (ICS subs are read-only).
func TestICSDetailRouteRendersReadonlyEvent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(icsDetailSample))
}))
defer srv.Close()
s := newServerWithICSCache(t, srv.URL)
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
id := icssub.EventID(mustParseICS(t, icsDetailSample))
if id == "" {
t.Fatal("EventID must be non-empty")
}
req := httptest.NewRequest(http.MethodGet, "/calendar/"+url.PathEscape("!holidays")+"/"+id, nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{
"ICS detail event", // summary
"holidays", // calendar label
} {
if !strings.Contains(body, want) {
t.Errorf("expected body to contain %q, got:\n%s", want, body)
}
}
if strings.Contains(body, ">Edit") {
t.Errorf("Edit link should not be present on a read-only ICS detail page, got:\n%s", body)
}
}
// TestICSDetailRouteBadIDReturns404 verifies a request with a malformed
// event ID (missing .ics suffix) gets a 404 rather than rendering.
func TestICSDetailRouteBadIDReturns404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(icsDetailSample))
}))
defer srv.Close()
s := newServerWithICSCache(t, srv.URL)
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
req := httptest.NewRequest(http.MethodGet, "/calendar/!holidays/not-a-valid-id", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404 for malformed event id, got %d", rr.Code)
}
}
// TestMonthGridLinksICSEventToDetailPage exercises the full user path: the
// month/week grid renders an ICS-subscription event as a link to a detail
// page (not "#" and not a direct edit URL).
func TestMonthGridLinksICSEventToDetailPage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(icsDetailSample))
}))
defer srv.Close()
s := newServerWithICSCache(t, srv.URL)
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
req := httptest.NewRequest(http.MethodGet, "/calendar?year=2026&month=8", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, "ICS detail event") {
t.Fatalf("month grid should list ICS events, got:\n%s", body)
}
id := icssub.EventID(mustParseICS(t, icsDetailSample))
// The grid renders the detail link with a "/web/calendar/!<ref>/<id>"
// shape (see eventLinkURL in calendar.templ). The "!" in the ref is
// not %-escaped by templ.URL — we observed un-escaped output in the
// rendered HTML and pin the exact shape below.
wantHref := "href=\"/web/calendar/!holidays/" + id + "\""
if !strings.Contains(body, wantHref) {
t.Fatalf("month grid should link ICS event to %q, but did not", wantHref)
}
if strings.Contains(body, `href="#"`) {
t.Fatalf("ICS events should not link to '#'")
}
}
// mustParseICS parses raw and returns its first VEVENT (panic on error).
func mustParseICS(t *testing.T, raw string) ical.Event {
t.Helper()
cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
if err != nil {
t.Fatalf("ical decode: %v", err)
}
evs := cal.Events()
if len(evs) == 0 {
t.Fatal("no events in ICS")
}
return evs[0]
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
func renderLogin(w http.ResponseWriter, errMsg string) { func renderLogin(w http.ResponseWriter, errMsg string) {
+4 -3
View File
@@ -6,13 +6,14 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
// resourceNameRe restricts calendar/address book names to characters that // resourceNameRe restricts calendar/address book names to characters that
// are safe as both a URL path segment and a filesystem directory name. // are safe as both a URL path segment and a filesystem directory name.
var resourceNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) // Includes dots for hidden files/folders (Unix convention).
var resourceNameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`)
// hexColorRe validates the 6-digit hex color format produced by an HTML // hexColorRe validates the 6-digit hex color format produced by an HTML
// <input type="color">, e.g. "#3b82f6". // <input type="color">, e.g. "#3b82f6".
+15 -7
View File
@@ -10,10 +10,10 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/icssub" "git.arnef.de/arnef/nidus/internal/icssub"
"github.com/yourusername/caldav-server/internal/store" "git.arnef.de/arnef/nidus/internal/store"
) )
// Server holds the dependencies needed by the web UI handlers. // Server holds the dependencies needed by the web UI handlers.
@@ -25,9 +25,16 @@ type Server struct {
icsCache *icssub.Cache icsCache *icssub.Cache
} }
// NewServer constructs a web UI Server. // NewServer constructs a web UI Server. icsCache may be nil, in which
func NewServer(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Server { // case a private default-TTL cache is created — prefer sharing a single
return &Server{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icssub.NewCache(icssub.DefaultTTL)} // *icssub.Cache with the CALDAV/CardDAV backends (e.g. from
// cmd/server/main.go) so the web calendar page and the DAV protocol
// serve identical events for the same ICS subscription.
func NewServer(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger, icsCache *icssub.Cache) *Server {
if icsCache == nil {
icsCache = icssub.NewCache(icssub.DefaultTTL)
}
return &Server{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icsCache}
} }
// Handler returns the http.Handler serving the web UI, mounted at "/web/" // Handler returns the http.Handler serving the web UI, mounted at "/web/"
@@ -65,6 +72,7 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler {
mux.HandleFunc("/calendar", s.requireLogin(s.handleCalendarMonth)) mux.HandleFunc("/calendar", s.requireLogin(s.handleCalendarMonth))
mux.HandleFunc("/calendar/week", s.requireLogin(s.handleCalendarWeek)) mux.HandleFunc("/calendar/week", s.requireLogin(s.handleCalendarWeek))
mux.HandleFunc("/calendar/new", s.requireLogin(s.handleEventNew)) mux.HandleFunc("/calendar/new", s.requireLogin(s.handleEventNew))
mux.HandleFunc("/calendar/{ref}/{id}", s.requireLogin(s.handleEventView))
mux.HandleFunc("/calendar/{ref}/import", s.requireLogin(s.handleCalendarImport)) mux.HandleFunc("/calendar/{ref}/import", s.requireLogin(s.handleCalendarImport))
mux.HandleFunc("/calendar/{ref}/export", s.requireLogin(s.handleCalendarExportAll)) mux.HandleFunc("/calendar/{ref}/export", s.requireLogin(s.handleCalendarExportAll))
mux.HandleFunc("/calendar/{ref}/{id}/edit", s.requireLogin(s.handleEventEdit)) mux.HandleFunc("/calendar/{ref}/{id}/edit", s.requireLogin(s.handleEventEdit))
+4 -5
View File
@@ -11,9 +11,9 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/yourusername/caldav-server/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/store" "git.arnef.de/arnef/nidus/internal/store"
) )
func newTestServer(t *testing.T) *Server { func newTestServer(t *testing.T) *Server {
@@ -47,7 +47,7 @@ func newTestServer(t *testing.T) *Server {
t.Fatalf("CreateCalendar bob/personal: %v", err) t.Fatalf("CreateCalendar bob/personal: %v", err)
} }
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewServer(cfg, st, dbase, logger) return NewServer(cfg, st, dbase, logger, nil)
} }
// loginAs performs a login request against handler and returns the // loginAs performs a login request against handler and returns the
@@ -316,4 +316,3 @@ func TestAccountChangePassword(t *testing.T) {
t.Fatal("expected password to have changed") t.Fatal("expected password to have changed")
} }
} }
+2 -2
View File
@@ -46,7 +46,7 @@ func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
Value: token, Value: token,
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: s.cfg.TLS.Enabled, Secure: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(7 * 24 * time.Hour), Expires: time.Now().Add(7 * 24 * time.Hour),
}) })
@@ -58,7 +58,7 @@ func (s *Server) clearSessionCookie(w http.ResponseWriter) {
Value: "", Value: "",
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: s.cfg.TLS.Enabled, Secure: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
MaxAge: -1, MaxAge: -1,
}) })
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
) )
// handleCalendarShare handles POST (create/update share) and DELETE // handleCalendarShare handles POST (create/update share) and DELETE
+126 -4
View File
@@ -3,6 +3,7 @@ package templates
import "fmt" import "fmt"
import "strconv" import "strconv"
import "strings" import "strings"
import "time"
// CalendarSummary is one calendar (own or shared) shown in the combined // CalendarSummary is one calendar (own or shared) shown in the combined
// month view's legend. // month view's legend.
@@ -24,9 +25,9 @@ type EventSummary struct {
Summary string Summary string
TimeText string // e.g. "14:00" or "" for all-day events TimeText string // e.g. "14:00" or "" for all-day events
AllDay bool AllDay bool
// LinkURL overrides the default "/web/calendar/{CalRef}/{ID}/edit" // LinkURL overrides the default "/web/calendar/{CalRef}/{ID}" (detail
// link target, used by virtual/read-only calendars (e.g. birthdays) // view) link target, used by virtual/read-only calendars (e.g.
// that don't have an editable event object of their own. // birthdays) that don't have an editable event object of their own.
LinkURL string LinkURL string
} }
@@ -100,6 +101,14 @@ type EventFormData struct {
EndTime string // "HH:MM", empty when AllDay EndTime string // "HH:MM", empty when AllDay
} }
// EventDetailData is everything the read-only event detail view needs: the
// decoded event itself (Form, including its ID/CalRef/Writable display
// metadata) plus the calendar's color for the header dot.
type EventDetailData struct {
Form EventFormData
Color string // calendar color, "" if unset
}
templ MonthView(username string, data MonthViewData) { templ MonthView(username string, data MonthViewData) {
@Layout("Calendar", username) { @Layout("Calendar", username) {
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap"> <div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
@@ -301,7 +310,7 @@ func eventLinkURL(ev EventSummary) string {
if ev.LinkURL != "" { if ev.LinkURL != "" {
return ev.LinkURL return ev.LinkURL
} }
return "/web/calendar/" + ev.CalRef + "/" + ev.ID + "/edit" return "/web/calendar/" + ev.CalRef + "/" + ev.ID
} }
// eventTextColor returns a color derived from hex, darkened if needed so // eventTextColor returns a color derived from hex, darkened if needed so
@@ -355,6 +364,119 @@ func clampByte(v float64) int {
// EventDetail is the read-only detail view for a single event, opened when
// the user clicks an event in the month or week grid. It shows the
// event's fields and, when the calendar is writable, offers an Edit link.
templ EventDetail(username string, data EventDetailData) {
@Layout("Calendar", username) {
<div class="flex items-center justify-between gap-3 flex-wrap mb-6">
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">&larr; Calendar</a>
<div class="flex items-center gap-3">
if data.Form.Writable {
<a href={ templ.URL("/web/calendar/" + data.Form.CalRef + "/" + data.Form.ID + "/edit") }
class="bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
Edit
</a>
}
<a href={ templ.URL("/web/calendar/" + data.Form.CalRef + "/" + data.Form.ID + "/export") }
class="bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
Export .ics
</a>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6 max-w-2xl">
<div class="flex items-start gap-3 border-b border-gray-100 pb-5 mb-5">
<span class="w-3 h-3 rounded-full mt-2 shrink-0 ring-1 ring-inset ring-black/10" style={ "background-color: " + colorOrDefault(data.Color) }></span>
<div class="min-w-0">
<h1 class="text-2xl font-semibold break-words leading-tight">{ data.Form.Summary }</h1>
<p class="text-sm text-gray-500 mt-1">{ data.Form.CalendarLabel }</p>
</div>
</div>
if !data.Form.Writable {
<p class="mb-5 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
This calendar was shared with you as read-only you can view this event but not change it.
</p>
}
<dl class="space-y-4">
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">When</dt>
<dd class="text-base text-gray-900">{ eventRangeText(data.Form) }</dd>
</div>
if data.Form.Location != "" {
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Location</dt>
<dd class="text-base text-gray-900 break-words">{ data.Form.Location }</dd>
</div>
}
if data.Form.Description != "" {
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Description</dt>
<dd class="text-base text-gray-900 whitespace-pre-wrap break-words">{ data.Form.Description }</dd>
</div>
}
</dl>
</div>
}
}
// eventRangeText renders a human-readable "when" string for an event:
// a single timed event reads "Aug 5, 2026, 14:00 15:00"; a multi-day
// range reads "Aug 5 7, 2026"; a single all-day date reads
// "August 5, 2026".
func eventRangeText(f EventFormData) string {
if f.AllDay {
if f.StartDate != f.EndDate {
s, err := dateOnly(f.StartDate)
if err != nil {
return f.StartDate
}
e, err := dateOnly(f.EndDate)
if err != nil {
return f.EndDate
}
if s.Year() == e.Year() && s.Month() == e.Month() {
return fmt.Sprintf("%s %s, %d", s.Format("Jan 2"), e.Format("2"), s.Year())
}
return fmt.Sprintf("%s %s", s.Format("Jan 2, 2006"), e.Format("Jan 2, 2006"))
}
d, err := dateOnly(f.StartDate)
if err != nil {
return f.StartDate
}
return d.Format("January 2, 2006")
}
s, err := dateTime(f.StartDate, f.StartTime)
if err != nil {
return f.StartDate
}
e, err := dateTime(f.EndDate, f.EndTime)
if err != nil {
return s.Format("January 2, 2006, 15:04")
}
if s.Day() == e.Day() {
return fmt.Sprintf("%s, %s %s", s.Format("January 2, 2006"), s.Format("15:04"), e.Format("15:04"))
}
if s.Year() == e.Year() && s.Month() == e.Month() {
return fmt.Sprintf("%s %s, %d", s.Format("Jan 2, 15:04"), e.Format("15:04"), s.Year())
}
return fmt.Sprintf("%s %s", s.Format("Jan 2, 2006, 15:04"), e.Format("Jan 2, 2006, 15:04"))
}
func dateOnly(ds string) (time.Time, error) {
return time.ParseInLocation("2006-01-02", ds, time.Local)
}
func dateTime(ds, ts string) (time.Time, error) {
if ts == "" {
return time.ParseInLocation("2006-01-02", ds, time.Local)
}
return time.ParseInLocation("2006-01-02T15:04", ds+"T"+ts, time.Local)
}
templ EventForm(username string, data EventFormData, errMsg string) { templ EventForm(username string, data EventFormData, errMsg string) {
@Layout("Calendar", username) { @Layout("Calendar", username) {
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">&larr; Calendar</a> <a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">&larr; Calendar</a>
File diff suppressed because it is too large Load Diff
+48
View File
@@ -33,6 +33,54 @@ func TestEventTextColorDarkensLightColors(t *testing.T) {
} }
} }
// TestEventRangeText pins the human-readable "When" strings shown on the
// event detail view so any change to date formatting is intentional.
func TestEventRangeText(t *testing.T) {
cases := []struct {
name string
form EventFormData
want string
}{
{
name: "single allday date",
form: EventFormData{AllDay: true, StartDate: "2026-08-05", EndDate: "2026-08-05"},
want: "August 5, 2026",
},
{
name: "all-day multi-day same month",
form: EventFormData{AllDay: true, StartDate: "2026-08-05", EndDate: "2026-08-07"},
want: "Aug 5 7, 2026",
},
{
name: "all-day multi-day different months",
form: EventFormData{AllDay: true, StartDate: "2026-08-30", EndDate: "2026-09-02"},
want: "Aug 30, 2026 Sep 2, 2026",
},
{
name: "timed same day",
form: EventFormData{AllDay: false, StartDate: "2026-08-05", StartTime: "14:00", EndDate: "2026-08-05", EndTime: "15:00"},
want: "August 5, 2026, 14:00 15:00",
},
{
name: "timed different days same month",
form: EventFormData{AllDay: false, StartDate: "2026-08-05", StartTime: "23:30", EndDate: "2026-08-06", EndTime: "01:00"},
want: "Aug 5, 23:30 01:00, 2026",
},
{
name: "timed different months",
form: EventFormData{AllDay: false, StartDate: "2026-08-31", StartTime: "10:00", EndDate: "2026-09-01", EndTime: "11:00"},
want: "Aug 31, 2026, 10:00 Sep 1, 2026, 11:00",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := eventRangeText(c.form); got != c.want {
t.Errorf("eventRangeText(%+v) = %q, want %q", c.form, got, c.want)
}
})
}
}
func TestEventTextColorFallsBackForInvalidInput(t *testing.T) { func TestEventTextColorFallsBackForInvalidInput(t *testing.T) {
if got := eventTextColor(""); got != colorOrDefault("") { if got := eventTextColor(""); got != colorOrDefault("") {
t.Errorf("eventTextColor(\"\") = %s, want the same default colorOrDefault returns", got) t.Errorf("eventTextColor(\"\") = %s, want the same default colorOrDefault returns", got)
+47 -41
View File
@@ -26,14 +26,11 @@ type LabeledValue struct {
Value string Value string
} }
// AddressValue is one ADR entry with its TYPE parameter. // AddressValue is one ADR entry with its TYPE parameter; the full address
// is captured as a single free-form string.
type AddressValue struct { type AddressValue struct {
Type string Type string
Street string Value string
City string
Region string
PostalCode string
Country string
} }
// ContactFormData pre-fills the create/edit contact form. Phones, Emails, // ContactFormData pre-fills the create/edit contact form. Phones, Emails,
@@ -42,7 +39,8 @@ type AddressValue struct {
type ContactFormData struct { type ContactFormData struct {
Book string Book string
ID string // empty when creating a new contact ID string // empty when creating a new contact
FullName string Forename string
Surname string
Organization string Organization string
Birthday string // "YYYY-MM-DD", empty if not set Birthday string // "YYYY-MM-DD", empty if not set
Note string Note string
@@ -158,49 +156,50 @@ templ ContactsList(username, book string, contacts []ContactSummary) {
} }
} }
// typeSelect renders the TYPE dropdown shared by phone/email/address rows. // typeSelect renders the TYPE dropdown shared by email/address rows.
templ typeSelect(name, selected string) { templ typeSelect(name, selected string) {
<select name={ name } class="rounded-md border-gray-300 border px-2 py-1.5 text-sm"> <select name={ name } class="rounded-md border-gray-300 border px-2 py-1.5 text-sm">
<option value="" selected?={ selected == "" }>Sonstige</option> <option value="" selected?={ selected == "" }>Other</option>
<option value="home" selected?={ selected == "home" }>Privat</option> <option value="home" selected?={ selected == "home" }>Home</option>
<option value="work" selected?={ selected == "work" }>Geschäftlich</option> <option value="work" selected?={ selected == "work" }>Work</option>
</select>
}
// phoneTypeSelect renders the phone TYPE dropdown. An unspecified number is
// shown as "Mobile" (the vCard "cell" type), matching how mobile clients
// label a contact's main number.
templ phoneTypeSelect(name, selected string) {
<select name={ name } class="rounded-md border-gray-300 border px-2 py-1.5 text-sm">
<option value="cell" selected?={ selected == "cell" || selected == "" }>Mobile</option>
<option value="home" selected?={ selected == "home" }>Home</option>
<option value="work" selected?={ selected == "work" }>Work</option>
</select> </select>
} }
templ phoneRow(p LabeledValue) { templ phoneRow(p LabeledValue) {
<div class="form-row flex gap-2 items-center"> <div class="form-row flex gap-2 items-center">
@typeSelect("phone_type", p.Type) @phoneTypeSelect("phone_type", p.Type)
<input name="phone_value" type="tel" value={ p.Value } placeholder="Telefonnummer" <input name="phone_value" type="tel" value={ p.Value } placeholder="Phone number"
class="flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/> class="flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
<button type="button" class="remove-row-btn text-red-600 hover:underline text-xs shrink-0">Entfernen</button> <button type="button" class="remove-row-btn text-red-600 hover:underline text-xs shrink-0">Remove</button>
</div> </div>
} }
templ emailRow(p LabeledValue) { templ emailRow(p LabeledValue) {
<div class="form-row flex gap-2 items-center"> <div class="form-row flex gap-2 items-center">
@typeSelect("email_type", p.Type) @typeSelect("email_type", p.Type)
<input name="email_value" type="email" value={ p.Value } placeholder="E-Mail-Adresse" <input name="email_value" type="email" value={ p.Value } placeholder="Email address"
class="flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/> class="flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
<button type="button" class="remove-row-btn text-red-600 hover:underline text-xs shrink-0">Entfernen</button> <button type="button" class="remove-row-btn text-red-600 hover:underline text-xs shrink-0">Remove</button>
</div> </div>
} }
templ addressRow(a AddressValue) { templ addressRow(a AddressValue) {
<div class="form-row grid grid-cols-2 gap-2 items-start bg-gray-50 rounded-md p-3"> <div class="form-row flex gap-2 items-center">
<div class="col-span-2">
@typeSelect("address_type", a.Type) @typeSelect("address_type", a.Type)
</div> <input name="address_value" type="text" value={ a.Value } placeholder="Street, city, postal code, country"
<input name="address_street" type="text" value={ a.Street } placeholder="Straße und Hausnummer" class="flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
class="col-span-2 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/> <button type="button" class="remove-row-btn text-red-600 hover:underline text-xs shrink-0">Remove</button>
<input name="address_city" type="text" value={ a.City } placeholder="Stadt"
class="rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
<input name="address_postal_code" type="text" value={ a.PostalCode } placeholder="PLZ"
class="rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
<input name="address_region" type="text" value={ a.Region } placeholder="Bundesland/Region"
class="rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
<input name="address_country" type="text" value={ a.Country } placeholder="Land"
class="rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
<button type="button" class="remove-row-btn text-red-600 hover:underline text-xs col-span-2 text-left">Entfernen</button>
</div> </div>
} }
@@ -230,67 +229,74 @@ templ ContactForm(username string, data ContactFormData, errMsg string) {
<img id="photo-preview" src="" alt="" class="w-20 h-20 rounded-full object-cover hidden"/> <img id="photo-preview" src="" alt="" class="w-20 h-20 rounded-full object-cover hidden"/>
<div class="space-y-1"> <div class="space-y-1">
<label class="cursor-pointer inline-block bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"> <label class="cursor-pointer inline-block bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
Foto wählen Choose photo
<input id="photo-input" name="photo" type="file" accept="image/*" class="hidden"/> <input id="photo-input" name="photo" type="file" accept="image/*" class="hidden"/>
</label> </label>
if data.PhotoDataURL != "" { if data.PhotoDataURL != "" {
<label class="flex items-center gap-1 text-xs text-gray-500"> <label class="flex items-center gap-1 text-xs text-gray-500">
<input id="remove-photo" name="remove_photo" type="checkbox" value="1"/> <input id="remove-photo" name="remove_photo" type="checkbox" value="1"/>
Foto entfernen Remove photo
</label> </label>
} }
</div> </div>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label class="block text-sm font-medium text-gray-700">Full name</label> <label class="block text-sm font-medium text-gray-700">First name</label>
<input name="full_name" type="text" required value={ data.FullName } <input name="forename" type="text" value={ data.Forename }
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/> class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700">Last name</label>
<input name="surname" type="text" value={ data.Surname }
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div>
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700">Organization</label> <label class="block text-sm font-medium text-gray-700">Organization</label>
<input name="organization" type="text" value={ data.Organization } <input name="organization" type="text" value={ data.Organization }
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/> class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700">Geburtstag</label> <label class="block text-sm font-medium text-gray-700">Birthday</label>
<input name="birthday" type="date" value={ data.Birthday } <input name="birthday" type="date" value={ data.Birthday }
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/> class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Telefonnummern</label> <label class="block text-sm font-medium text-gray-700 mb-2">Phones</label>
<div id="phones-container" class="space-y-2"> <div id="phones-container" class="space-y-2">
for _, p := range data.Phones { for _, p := range data.Phones {
@phoneRow(p) @phoneRow(p)
} }
</div> </div>
<button type="button" data-add-target="phones-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline"> <button type="button" data-add-target="phones-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline">
+ Telefonnummer hinzufügen + Add phone number
</button> </button>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">E-Mail-Adressen</label> <label class="block text-sm font-medium text-gray-700 mb-2">Emails</label>
<div id="emails-container" class="space-y-2"> <div id="emails-container" class="space-y-2">
for _, e := range data.Emails { for _, e := range data.Emails {
@emailRow(e) @emailRow(e)
} }
</div> </div>
<button type="button" data-add-target="emails-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline"> <button type="button" data-add-target="emails-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline">
+ E-Mail-Adresse hinzufügen + Add email address
</button> </button>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Adressen</label> <label class="block text-sm font-medium text-gray-700 mb-2">Addresses</label>
<div id="addresses-container" class="space-y-2"> <div id="addresses-container" class="space-y-2">
for _, a := range data.Addresses { for _, a := range data.Addresses {
@addressRow(a) @addressRow(a)
} }
</div> </div>
<button type="button" data-add-target="addresses-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline"> <button type="button" data-add-target="addresses-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline">
+ Adresse hinzufügen + Add address
</button> </button>
</div> </div>
+249 -215
View File
@@ -34,14 +34,11 @@ type LabeledValue struct {
Value string Value string
} }
// AddressValue is one ADR entry with its TYPE parameter. // AddressValue is one ADR entry with its TYPE parameter; the full address
// is captured as a single free-form string.
type AddressValue struct { type AddressValue struct {
Type string Type string
Street string Value string
City string
Region string
PostalCode string
Country string
} }
// ContactFormData pre-fills the create/edit contact form. Phones, Emails, // ContactFormData pre-fills the create/edit contact form. Phones, Emails,
@@ -50,7 +47,8 @@ type AddressValue struct {
type ContactFormData struct { type ContactFormData struct {
Book string Book string
ID string // empty when creating a new contact ID string // empty when creating a new contact
FullName string Forename string
Surname string
Organization string Organization string
Birthday string // "YYYY-MM-DD", empty if not set Birthday string // "YYYY-MM-DD", empty if not set
Note string Note string
@@ -115,7 +113,7 @@ func ContactsHome(username string, books []AddressBookSummary) templ.Component {
var templ_7745c5c3_Var3 templ.SafeURL var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + b.Name)) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + b.Name))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 67, Col: 52} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 65, Col: 52}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -128,7 +126,7 @@ func ContactsHome(username string, books []AddressBookSummary) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(b.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(b.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 67, Col: 115} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 65, Col: 115}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -141,7 +139,7 @@ func ContactsHome(username string, books []AddressBookSummary) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d contact(s)", b.Count)) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d contact(s)", b.Count))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 68, Col: 73} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 66, Col: 73}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -204,7 +202,7 @@ func avatar(photoDataURL, sizeClass string) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(photoDataURL) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(photoDataURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 81, Col: 25} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 79, Col: 25}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -295,7 +293,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(book) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(book)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 94, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 92, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -308,7 +306,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var15 templ.SafeURL var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/new")) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/new"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 97, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 95, Col: 57}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -321,7 +319,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var16 templ.SafeURL var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/export")) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/export"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 101, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 99, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -334,7 +332,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var17 templ.SafeURL var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/import")) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/import"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 107, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 105, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -360,7 +358,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var18 templ.SafeURL var templ_7745c5c3_Var18 templ.SafeURL
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/edit")) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/edit"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 137, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 135, Col: 75}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -373,7 +371,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(c.FullName) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(c.FullName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 137, Col: 130} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 135, Col: 130}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -386,7 +384,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(c.Organization) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(c.Organization)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 139, Col: 92} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 137, Col: 92}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -399,7 +397,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(c.Phone) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(c.Phone)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 140, Col: 91} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 138, Col: 91}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -412,7 +410,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(c.Email) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(c.Email)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 141, Col: 85} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 139, Col: 85}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -425,7 +423,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var23 templ.SafeURL var templ_7745c5c3_Var23 templ.SafeURL
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/export")) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/export"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 143, Col: 77} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 141, Col: 77}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -438,7 +436,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
var templ_7745c5c3_Var24 templ.SafeURL var templ_7745c5c3_Var24 templ.SafeURL
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/delete")) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/delete"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 144, Col: 96} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 142, Col: 96}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -469,7 +467,7 @@ func ContactsList(username, book string, contacts []ContactSummary) templ.Compon
}) })
} }
// typeSelect renders the TYPE dropdown shared by phone/email/address rows. // typeSelect renders the TYPE dropdown shared by email/address rows.
func typeSelect(name, selected string) templ.Component { func typeSelect(name, selected string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -498,7 +496,7 @@ func typeSelect(name, selected string) templ.Component {
var templ_7745c5c3_Var26 string var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(name) templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 163, Col: 20} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 161, Col: 20}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -514,7 +512,7 @@ func typeSelect(name, selected string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Sonstige</option> <option value=\"home\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Other</option> <option value=\"home\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -524,7 +522,7 @@ func typeSelect(name, selected string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Privat</option> <option value=\"work\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Home</option> <option value=\"work\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -534,7 +532,82 @@ func typeSelect(name, selected string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Geschäftlich</option></select>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Work</option></select>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// phoneTypeSelect renders the phone TYPE dropdown. An unspecified number is
// shown as "Mobile" (the vCard "cell" type), matching how mobile clients
// label a contact's main number.
func phoneTypeSelect(name, selected string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var27 := templ.GetChildren(ctx)
if templ_7745c5c3_Var27 == nil {
templ_7745c5c3_Var27 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<select name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 172, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"><option value=\"cell\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if selected == "cell" || selected == "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Mobile</option> <option value=\"home\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if selected == "home" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Home</option> <option value=\"work\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if selected == "work" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Work</option></select>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -558,33 +631,33 @@ func phoneRow(p LabeledValue) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var27 := templ.GetChildren(ctx) templ_7745c5c3_Var29 := templ.GetChildren(ctx)
if templ_7745c5c3_Var27 == nil { if templ_7745c5c3_Var29 == nil {
templ_7745c5c3_Var27 = templ.NopComponent templ_7745c5c3_Var29 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"form-row flex gap-2 items-center\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"form-row flex gap-2 items-center\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = typeSelect("phone_type", p.Type).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = phoneTypeSelect("phone_type", p.Type).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<input name=\"phone_value\" type=\"tel\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<input name=\"phone_value\" type=\"tel\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var28 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Value) templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 173, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 182, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" placeholder=\"Telefonnummer\" class=\"flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <button type=\"button\" class=\"remove-row-btn text-red-600 hover:underline text-xs shrink-0\">Entfernen</button></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" placeholder=\"Phone number\" class=\"flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <button type=\"button\" class=\"remove-row-btn text-red-600 hover:underline text-xs shrink-0\">Remove</button></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -608,12 +681,12 @@ func emailRow(p LabeledValue) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var29 := templ.GetChildren(ctx) templ_7745c5c3_Var31 := templ.GetChildren(ctx)
if templ_7745c5c3_Var29 == nil { if templ_7745c5c3_Var31 == nil {
templ_7745c5c3_Var29 = templ.NopComponent templ_7745c5c3_Var31 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div class=\"form-row flex gap-2 items-center\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"form-row flex gap-2 items-center\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -621,20 +694,20 @@ func emailRow(p LabeledValue) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<input name=\"email_value\" type=\"email\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<input name=\"email_value\" type=\"email\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var30 string var templ_7745c5c3_Var32 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Value) templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 182, Col: 56} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 191, Col: 56}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" placeholder=\"E-Mail-Adresse\" class=\"flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <button type=\"button\" class=\"remove-row-btn text-red-600 hover:underline text-xs shrink-0\">Entfernen</button></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" placeholder=\"Email address\" class=\"flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <button type=\"button\" class=\"remove-row-btn text-red-600 hover:underline text-xs shrink-0\">Remove</button></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -658,12 +731,12 @@ func addressRow(a AddressValue) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var31 := templ.GetChildren(ctx) templ_7745c5c3_Var33 := templ.GetChildren(ctx)
if templ_7745c5c3_Var31 == nil { if templ_7745c5c3_Var33 == nil {
templ_7745c5c3_Var31 = templ.NopComponent templ_7745c5c3_Var33 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div class=\"form-row grid grid-cols-2 gap-2 items-start bg-gray-50 rounded-md p-3\"><div class=\"col-span-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div class=\"form-row flex gap-2 items-center\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -671,72 +744,20 @@ func addressRow(a AddressValue) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div><input name=\"address_street\" type=\"text\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<input name=\"address_value\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Street)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 193, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" placeholder=\"Straße und Hausnummer\" class=\"col-span-2 rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <input name=\"address_city\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.City)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 195, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" placeholder=\"Stadt\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <input name=\"address_postal_code\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var34 string var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.PostalCode) templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 197, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 200, Col: 57}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" placeholder=\"PLZ\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <input name=\"address_region\" type=\"text\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" placeholder=\"Street, city, postal code, country\" class=\"flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <button type=\"button\" class=\"remove-row-btn text-red-600 hover:underline text-xs shrink-0\">Remove</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Region)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 199, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" placeholder=\"Bundesland/Region\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <input name=\"address_country\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Country)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 201, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" placeholder=\"Land\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <button type=\"button\" class=\"remove-row-btn text-red-600 hover:underline text-xs col-span-2 text-left\">Entfernen</button></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -760,12 +781,12 @@ func ContactForm(username string, data ContactFormData, errMsg string) templ.Com
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var37 := templ.GetChildren(ctx) templ_7745c5c3_Var35 := templ.GetChildren(ctx)
if templ_7745c5c3_Var37 == nil { if templ_7745c5c3_Var35 == nil {
templ_7745c5c3_Var37 = templ.NopComponent templ_7745c5c3_Var35 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var38 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_Var36 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer { if !templ_7745c5c3_IsBuffer {
@@ -777,84 +798,84 @@ func ContactForm(username string, data ContactFormData, errMsg string) templ.Com
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var39 templ.SafeURL var templ_7745c5c3_Var37 templ.SafeURL
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + data.Book)) templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + data.Book))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 209, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 208, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" class=\"text-sm text-indigo-600 hover:underline\">&larr; ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(data.Book)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 208, Col: 120}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</a><h1 class=\"text-2xl font-semibold mt-2 mb-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.ID == "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "New contact")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "Edit contact")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if errMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<p class=\"mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 217, Col: 98}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" class=\"text-sm text-indigo-600 hover:underline\">&larr; ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</p>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var40 string }
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(data.Book) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " <form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 209, Col: 120} return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 templ.SafeURL
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(contactFormAction(data))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 221, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</a><h1 class=\"text-2xl font-semibold mt-2 mb-6\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" enctype=\"multipart/form-data\" class=\"bg-white rounded-lg border border-gray-200 p-6 space-y-6 max-w-xl\"><div class=\"flex items-center gap-4\"><span id=\"current-avatar\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.ID == "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "New contact")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "Edit contact")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if errMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<p class=\"mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 218, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " <form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 templ.SafeURL
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(contactFormAction(data))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 222, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" enctype=\"multipart/form-data\" class=\"bg-white rounded-lg border border-gray-200 p-6 space-y-6 max-w-xl\"><div class=\"flex items-center gap-4\"><span id=\"current-avatar\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -862,56 +883,69 @@ func ContactForm(username string, data ContactFormData, errMsg string) templ.Com
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span> <img id=\"photo-preview\" src=\"\" alt=\"\" class=\"w-20 h-20 rounded-full object-cover hidden\"><div class=\"space-y-1\"><label class=\"cursor-pointer inline-block bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50\">Foto wählen <input id=\"photo-input\" name=\"photo\" type=\"file\" accept=\"image/*\" class=\"hidden\"></label> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</span> <img id=\"photo-preview\" src=\"\" alt=\"\" class=\"w-20 h-20 rounded-full object-cover hidden\"><div class=\"space-y-1\"><label class=\"cursor-pointer inline-block bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50\">Choose photo <input id=\"photo-input\" name=\"photo\" type=\"file\" accept=\"image/*\" class=\"hidden\"></label> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if data.PhotoDataURL != "" { if data.PhotoDataURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<label class=\"flex items-center gap-1 text-xs text-gray-500\"><input id=\"remove-photo\" name=\"remove_photo\" type=\"checkbox\" value=\"1\"> Foto entfernen</label>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<label class=\"flex items-center gap-1 text-xs text-gray-500\"><input id=\"remove-photo\" name=\"remove_photo\" type=\"checkbox\" value=\"1\"> Remove photo</label>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</div></div><div><label class=\"block text-sm font-medium text-gray-700\">Full name</label> <input name=\"full_name\" type=\"text\" required value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div></div><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-3\"><div><label class=\"block text-sm font-medium text-gray-700\">First name</label> <input name=\"forename\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Forename)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 247, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" class=\"mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm\"></div><div><label class=\"block text-sm font-medium text-gray-700\">Last name</label> <input name=\"surname\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Surname)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 252, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" class=\"mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm\"></div></div><div><label class=\"block text-sm font-medium text-gray-700\">Organization</label> <input name=\"organization\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var43 string var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.FullName) templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Organization)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 247, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 258, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" class=\"mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm\"></div><div><label class=\"block text-sm font-medium text-gray-700\">Organization</label> <input name=\"organization\" type=\"text\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" class=\"mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm\"></div><div><label class=\"block text-sm font-medium text-gray-700\">Birthday</label> <input name=\"birthday\" type=\"date\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var44 string var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Organization) templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Birthday)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 252, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 263, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm\"></div><div><label class=\"block text-sm font-medium text-gray-700\">Geburtstag</label> <input name=\"birthday\" type=\"date\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" class=\"mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm\"></div><div><label class=\"block text-sm font-medium text-gray-700 mb-2\">Phones</label><div id=\"phones-container\" class=\"space-y-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Birthday)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 257, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" class=\"mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm\"></div><div><label class=\"block text-sm font-medium text-gray-700 mb-2\">Telefonnummern</label><div id=\"phones-container\" class=\"space-y-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -921,7 +955,7 @@ func ContactForm(username string, data ContactFormData, errMsg string) templ.Com
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div><button type=\"button\" data-add-target=\"phones-container\" class=\"add-row-btn mt-2 text-sm text-indigo-600 hover:underline\">+ Telefonnummer hinzufügen</button></div><div><label class=\"block text-sm font-medium text-gray-700 mb-2\">E-Mail-Adressen</label><div id=\"emails-container\" class=\"space-y-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</div><button type=\"button\" data-add-target=\"phones-container\" class=\"add-row-btn mt-2 text-sm text-indigo-600 hover:underline\">+ Add phone number</button></div><div><label class=\"block text-sm font-medium text-gray-700 mb-2\">Emails</label><div id=\"emails-container\" class=\"space-y-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -931,7 +965,7 @@ func ContactForm(username string, data ContactFormData, errMsg string) templ.Com
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</div><button type=\"button\" data-add-target=\"emails-container\" class=\"add-row-btn mt-2 text-sm text-indigo-600 hover:underline\">+ E-Mail-Adresse hinzufügen</button></div><div><label class=\"block text-sm font-medium text-gray-700 mb-2\">Adressen</label><div id=\"addresses-container\" class=\"space-y-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</div><button type=\"button\" data-add-target=\"emails-container\" class=\"add-row-btn mt-2 text-sm text-indigo-600 hover:underline\">+ Add email address</button></div><div><label class=\"block text-sm font-medium text-gray-700 mb-2\">Addresses</label><div id=\"addresses-container\" class=\"space-y-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -941,39 +975,39 @@ func ContactForm(username string, data ContactFormData, errMsg string) templ.Com
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div><button type=\"button\" data-add-target=\"addresses-container\" class=\"add-row-btn mt-2 text-sm text-indigo-600 hover:underline\">+ Adresse hinzufügen</button></div><div><label class=\"block text-sm font-medium text-gray-700\">Note</label> <textarea name=\"note\" rows=\"3\" class=\"mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</div><button type=\"button\" data-add-target=\"addresses-container\" class=\"add-row-btn mt-2 text-sm text-indigo-600 hover:underline\">+ Add address</button></div><div><label class=\"block text-sm font-medium text-gray-700\">Note</label> <textarea name=\"note\" rows=\"3\" class=\"mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var46 string var templ_7745c5c3_Var45 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(data.Note) templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(data.Note)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 300, Col: 96} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 306, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</textarea></div><div class=\"flex gap-2\"><button type=\"submit\" class=\"bg-indigo-600 text-white rounded-md px-4 py-2 text-sm font-medium hover:bg-indigo-700\">Save</button> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var46 templ.SafeURL
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + data.Book))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 312, Col: 53}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</textarea></div><div class=\"flex gap-2\"><button type=\"submit\" class=\"bg-indigo-600 text-white rounded-md px-4 py-2 text-sm font-medium hover:bg-indigo-700\">Save</button> <a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\" class=\"rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50\">Cancel</a></div></form><script type=\"module\" src=\"/web/static/contacts.js\"></script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var47 templ.SafeURL
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + data.Book))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 306, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" class=\"rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50\">Cancel</a></div></form><script type=\"module\" src=\"/web/static/contacts.js\"></script>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
return nil return nil
}) })
templ_7745c5c3_Err = Layout("Contacts", username).Render(templ.WithChildren(ctx, templ_7745c5c3_Var38), templ_7745c5c3_Buffer) templ_7745c5c3_Err = Layout("Contacts", username).Render(templ.WithChildren(ctx, templ_7745c5c3_Var36), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+29 -4
View File
@@ -16,7 +16,7 @@ type FileEntry struct {
RelPath string // relative path (no leading slash) used to build the link RelPath string // relative path (no leading slash) used to build the link
} }
templ FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, currentPath string) { templ FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, currentPath string, sortBy string, sortDir string) {
@Layout("Files", username) { @Layout("Files", username) {
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap"> <div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<h1 class="text-2xl font-semibold">Files</h1> <h1 class="text-2xl font-semibold">Files</h1>
@@ -33,6 +33,10 @@ templ FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry,
Upload folder Upload folder
<input id="upload-folder-input" type="file" webkitdirectory multiple class="hidden"/> <input id="upload-folder-input" type="file" webkitdirectory multiple class="hidden"/>
</label> </label>
<select id="sort-select" class="rounded-md border-gray-300 border px-2 py-1.5 text-sm text-gray-700">
<option value="name_asc" selected?={ sortBy == "name" && sortDir == "asc" }>Name (asc)</option>
<option value="name_desc" selected?={ sortBy == "name" && sortDir == "desc" }>Name (desc)</option>
</select>
</div> </div>
</div> </div>
@@ -48,6 +52,8 @@ templ FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry,
id="file-drop-zone" id="file-drop-zone"
data-current-path={ currentPath } data-current-path={ currentPath }
data-upload-url={ "/web/files/" + currentPath } data-upload-url={ "/web/files/" + currentPath }
data-sort-by={ sortBy }
data-sort-dir={ sortDir }
class="bg-white rounded-lg border-2 border-dashed border-gray-200 p-2 transition-colors" class="bg-white rounded-lg border-2 border-dashed border-gray-200 p-2 transition-colors"
> >
<!-- Table layout for wider screens (sm and up). --> <!-- Table layout for wider screens (sm and up). -->
@@ -60,9 +66,29 @@ templ FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry,
</colgroup> </colgroup>
<thead> <thead>
<tr class="text-left text-gray-400 border-b border-gray-100"> <tr class="text-left text-gray-400 border-b border-gray-100">
<th class="py-2 px-3 font-medium">Name</th> <th class="py-2 px-3 font-medium">
if sortBy == "name" {
if sortDir == "asc" {
<a href={ templ.URL("/web/files/" + currentPath + "?sort_by=name&sort_dir=desc") } class="hover:text-indigo-600">Name <span class="ml-1 text-xs"></span></a>
} else {
<a href={ templ.URL("/web/files/" + currentPath + "?sort_by=name&sort_dir=asc") } class="hover:text-indigo-600">Name <span class="ml-1 text-xs"></span></a>
}
} else {
<a href={ templ.URL("/web/files/" + currentPath + "?sort_by=name&sort_dir=asc") } class="hover:text-indigo-600">Name</a>
}
</th>
<th class="py-2 px-3 font-medium">Size</th> <th class="py-2 px-3 font-medium">Size</th>
<th class="py-2 px-3 font-medium">Modified</th> <th class="py-2 px-3 font-medium">
if sortBy == "modtime" {
if sortDir == "asc" {
<a href={ templ.URL("/web/files/" + currentPath + "?sort_by=modtime&sort_dir=desc") } class="hover:text-indigo-600">Modified <span class="ml-1 text-xs"></span></a>
} else {
<a href={ templ.URL("/web/files/" + currentPath + "?sort_by=modtime&sort_dir=asc") } class="hover:text-indigo-600">Modified <span class="ml-1 text-xs"></span></a>
}
} else {
<a href={ templ.URL("/web/files/" + currentPath + "?sort_by=modtime&sort_dir=asc") } class="hover:text-indigo-600">Modified</a>
}
</th>
<th class="py-2 px-3 font-medium"></th> <th class="py-2 px-3 font-medium"></th>
</tr> </tr>
</thead> </thead>
@@ -163,4 +189,3 @@ templ fileDeleteButton(e FileEntry) {
Delete Delete
</button> </button>
} }
+296 -128
View File
@@ -24,7 +24,7 @@ type FileEntry struct {
RelPath string // relative path (no leading slash) used to build the link RelPath string // relative path (no leading slash) used to build the link
} }
func FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, currentPath string) templ.Component { func FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, currentPath string, sortBy string, sortDir string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -57,176 +57,276 @@ func FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, c
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex items-center justify-between mb-6 gap-4 flex-wrap\"><h1 class=\"text-2xl font-semibold\">Files</h1><div class=\"flex gap-2 flex-wrap\"><button id=\"new-folder-button\" type=\"button\" class=\"bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50\">New folder</button> <label class=\"cursor-pointer bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Upload files <input id=\"upload-files-input\" type=\"file\" multiple class=\"hidden\"></label> <label class=\"cursor-pointer bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Upload folder <input id=\"upload-folder-input\" type=\"file\" webkitdirectory multiple class=\"hidden\"></label></div></div><nav class=\"text-sm text-gray-500 mb-4 flex flex-wrap items-center gap-1\"><a href=\"/web/files/\" class=\"hover:underline text-indigo-600\">home</a> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex items-center justify-between mb-6 gap-4 flex-wrap\"><h1 class=\"text-2xl font-semibold\">Files</h1><div class=\"flex gap-2 flex-wrap\"><button id=\"new-folder-button\" type=\"button\" class=\"bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50\">New folder</button> <label class=\"cursor-pointer bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Upload files <input id=\"upload-files-input\" type=\"file\" multiple class=\"hidden\"></label> <label class=\"cursor-pointer bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Upload folder <input id=\"upload-folder-input\" type=\"file\" webkitdirectory multiple class=\"hidden\"></label> <select id=\"sort-select\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm text-gray-700\"><option value=\"name_asc\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if sortBy == "name" && sortDir == "asc" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ">Name (asc)</option> <option value=\"name_desc\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if sortBy == "name" && sortDir == "desc" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, ">Name (desc)</option></select></div></div><nav class=\"text-sm text-gray-500 mb-4 flex flex-wrap items-center gap-1\"><a href=\"/web/files/\" class=\"hover:underline text-indigo-600\">home</a> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, bc := range breadcrumbs { for _, bc := range breadcrumbs {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span>/</span> <a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span>/</span> <a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 templ.SafeURL var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + bc.Path + "/")) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + bc.Path + "/"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 43, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 47, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" class=\"hover:underline text-indigo-600\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" class=\"hover:underline text-indigo-600\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(bc.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(bc.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 43, Col: 106} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 47, Col: 106}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</nav><div id=\"file-drop-zone\" data-current-path=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</nav><div id=\"file-drop-zone\" data-current-path=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 49, Col: 34} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 53, Col: 34}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" data-upload-url=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" data-upload-url=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/web/files/" + currentPath) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/web/files/" + currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 50, Col: 48} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 54, Col: 48}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" class=\"bg-white rounded-lg border-2 border-dashed border-gray-200 p-2 transition-colors\"><!-- Table layout for wider screens (sm and up). --><table class=\"hidden sm:table w-full text-sm table-fixed\"><colgroup><col class=\"w-auto\"> <col class=\"w-20\"> <col class=\"w-36\"> <col class=\"w-16\"></colgroup> <thead><tr class=\"text-left text-gray-400 border-b border-gray-100\"><th class=\"py-2 px-3 font-medium\">Name</th><th class=\"py-2 px-3 font-medium\">Size</th><th class=\"py-2 px-3 font-medium\">Modified</th><th class=\"py-2 px-3 font-medium\"></th></tr></thead> <tbody>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-sort-by=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, e := range entries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<tr class=\"border-b border-gray-50 hover:bg-gray-50\"><td class=\"py-2 px-3 break-all\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fileEntryLink(e).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</td><td class=\"py-2 px-3 text-gray-500 whitespace-nowrap\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(e.Size) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(sortBy)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 75, Col: 69} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 55, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</td><td class=\"py-2 px-3 text-gray-500 whitespace-nowrap\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-sort-dir=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(e.ModTime) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(sortDir)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 76, Col: 72} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 56, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</td><td class=\"py-2 px-3 text-right whitespace-nowrap\"><div class=\"flex items-center justify-end gap-3\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"bg-white rounded-lg border-2 border-dashed border-gray-200 p-2 transition-colors\"><!-- Table layout for wider screens (sm and up). --><table class=\"hidden sm:table w-full text-sm table-fixed\"><colgroup><col class=\"w-auto\"> <col class=\"w-20\"> <col class=\"w-36\"> <col class=\"w-16\"></colgroup> <thead><tr class=\"text-left text-gray-400 border-b border-gray-100\"><th class=\"py-2 px-3 font-medium\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = fileActions(e).Render(ctx, templ_7745c5c3_Buffer) if sortBy == "name" {
if sortDir == "asc" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div></td></tr>") var templ_7745c5c3_Var9 templ.SafeURL
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + currentPath + "?sort_by=name&sort_dir=desc"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 72, Col: 89}
}
}
if len(entries) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<tr><td colspan=\"4\" class=\"py-6 px-3 text-center text-gray-400\">This folder is empty — drag &amp; drop files or folders here, or use the upload buttons above.</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</tbody></table><!-- Stacked card layout for narrow screens: same information as\n\t\t\t the table above (name, size, modified, delete), just\n\t\t\t wrapped onto its own lines instead of squeezed into\n\t\t\t columns, so nothing needs to be hidden or scrolled to. --><ul class=\"sm:hidden divide-y divide-gray-100\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, e := range entries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<li class=\"py-2.5 px-1\"><div class=\"break-all\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fileEntryLink(e).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div><div class=\"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 pl-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if e.Size != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(e.Size)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 106, Col: 22}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" class=\"hover:text-indigo-600\">Name <span class=\"ml-1 text-xs\">↓</span></a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 templ.SafeURL
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(e.ModTime) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + currentPath + "?sort_by=name&sort_dir=asc"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 108, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 74, Col: 88}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> <span class=\"ml-auto flex items-center gap-3\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"hover:text-indigo-600\">Name <span class=\"ml-1 text-xs\">↑</span></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 templ.SafeURL
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + currentPath + "?sort_by=name&sort_dir=asc"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 77, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"hover:text-indigo-600\">Name</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</th><th class=\"py-2 px-3 font-medium\">Size</th><th class=\"py-2 px-3 font-medium\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if sortBy == "modtime" {
if sortDir == "asc" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + currentPath + "?sort_by=modtime&sort_dir=desc"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 84, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" class=\"hover:text-indigo-600\">Modified <span class=\"ml-1 text-xs\">↓</span></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + currentPath + "?sort_by=modtime&sort_dir=asc"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 86, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"hover:text-indigo-600\">Modified <span class=\"ml-1 text-xs\">↑</span></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + currentPath + "?sort_by=modtime&sort_dir=asc"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 89, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" class=\"hover:text-indigo-600\">Modified</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</th><th class=\"py-2 px-3 font-medium\"></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, e := range entries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<tr class=\"border-b border-gray-50 hover:bg-gray-50\"><td class=\"py-2 px-3 break-all\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fileEntryLink(e).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</td><td class=\"py-2 px-3 text-gray-500 whitespace-nowrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(e.Size)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 101, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</td><td class=\"py-2 px-3 text-gray-500 whitespace-nowrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(e.ModTime)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 102, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</td><td class=\"py-2 px-3 text-right whitespace-nowrap\"><div class=\"flex items-center justify-end gap-3\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -234,18 +334,86 @@ func FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, c
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span></div></li>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></td></tr>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if len(entries) == 0 { if len(entries) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<li class=\"py-6 px-3 text-center text-gray-400 text-sm\">This folder is empty — drag &amp; drop files or folders here, or use the upload buttons above.</li>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<tr><td colspan=\"4\" class=\"py-6 px-3 text-center text-gray-400\">This folder is empty — drag &amp; drop files or folders here, or use the upload buttons above.</td></tr>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</ul></div><p id=\"upload-status\" class=\"mt-3 text-sm text-gray-500\"></p><script type=\"module\" src=\"/web/static/files.js\"></script>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</tbody></table><!-- Stacked card layout for narrow screens: same information as\n\t\t\t the table above (name, size, modified, delete), just\n\t\t\t wrapped onto its own lines instead of squeezed into\n\t\t\t columns, so nothing needs to be hidden or scrolled to. --><ul class=\"sm:hidden divide-y divide-gray-100\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, e := range entries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<li class=\"py-2.5 px-1\"><div class=\"break-all\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fileEntryLink(e).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div><div class=\"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 pl-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if e.Size != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(e.Size)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 132, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(e.ModTime)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 134, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</span> <span class=\"ml-auto flex items-center gap-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fileActions(e).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span></div></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(entries) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<li class=\"py-6 px-3 text-center text-gray-400 text-sm\">This folder is empty — drag &amp; drop files or folders here, or use the upload buttons above.</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</ul></div><p id=\"upload-status\" class=\"mt-3 text-sm text-gray-500\"></p><script type=\"module\" src=\"/web/static/files.js\"></script>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -275,70 +443,70 @@ func fileEntryLink(e FileEntry) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var11 := templ.GetChildren(ctx) templ_7745c5c3_Var19 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil { if templ_7745c5c3_Var19 == nil {
templ_7745c5c3_Var11 = templ.NopComponent templ_7745c5c3_Var19 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
if e.IsDir { if e.IsDir {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 templ.SafeURL var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath + "/")) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath + "/"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 129, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 155, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"flex items-center gap-2 text-indigo-600 hover:underline\"><span aria-hidden=\"true\">📁</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" class=\"flex items-center gap-2 text-indigo-600 hover:underline\"><span aria-hidden=\"true\">📁</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 130, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 156, Col: 47}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 templ.SafeURL var templ_7745c5c3_Var22 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath)) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 133, Col: 48} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 159, Col: 48}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" target=\"_blank\" rel=\"noopener\" class=\"flex items-center gap-2 text-gray-700 hover:underline\"><span aria-hidden=\"true\">📄</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" target=\"_blank\" rel=\"noopener\" class=\"flex items-center gap-2 text-gray-700 hover:underline\"><span aria-hidden=\"true\">📄</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 134, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 160, Col: 47}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -368,26 +536,26 @@ func fileActions(e FileEntry) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var16 := templ.GetChildren(ctx) templ_7745c5c3_Var24 := templ.GetChildren(ctx)
if templ_7745c5c3_Var16 == nil { if templ_7745c5c3_Var24 == nil {
templ_7745c5c3_Var16 = templ.NopComponent templ_7745c5c3_Var24 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
if !e.IsDir { if !e.IsDir {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 templ.SafeURL var templ_7745c5c3_Var25 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath + "?download=1")) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath + "?download=1"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 147, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 173, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"text-gray-500 hover:text-indigo-600 hover:underline text-xs font-medium\">Download</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" class=\"text-gray-500 hover:text-indigo-600 hover:underline text-xs font-medium\">Download</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -416,38 +584,38 @@ func fileDeleteButton(e FileEntry) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var18 := templ.GetChildren(ctx) templ_7745c5c3_Var26 := templ.GetChildren(ctx)
if templ_7745c5c3_Var18 == nil { if templ_7745c5c3_Var26 == nil {
templ_7745c5c3_Var18 = templ.NopComponent templ_7745c5c3_Var26 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<button type=\"button\" class=\"delete-entry-button text-red-500 hover:text-red-700 text-xs font-medium\" data-path=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<button type=\"button\" class=\"delete-entry-button text-red-500 hover:text-red-700 text-xs font-medium\" data-path=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.RelPath) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.RelPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 160, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 186, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" data-name=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" data-name=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var28 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Name) templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 161, Col: 20} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 187, Col: 20}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">Delete</button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">Delete</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+4 -4
View File
@@ -7,14 +7,14 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"github.com/yourusername/caldav-server/internal/auth" "git.arnef.de/arnef/nidus/internal/auth"
"github.com/yourusername/caldav-server/internal/config" "git.arnef.de/arnef/nidus/internal/config"
xwebdav "golang.org/x/net/webdav" xwebdav "golang.org/x/net/webdav"
) )
// NewHandler returns an http.Handler that provides standard WebDAV file access, // NewHandler returns an http.Handler that provides standard WebDAV file access,
// mounted at the fixed URL /files/ for every user and rooted at // mounted at the fixed URL /files/ for every user and rooted at
// dataDir/files/<username>/ on disk. The URL is the same for all users — // dataDir/<username>/files on disk. The URL is the same for all users —
// which user's directory is served is resolved from the Basic Auth identity // which user's directory is served is resolved from the Basic Auth identity
// in the request context, not from the URL. // in the request context, not from the URL.
// //
@@ -38,7 +38,7 @@ func NewHandler(cfg *config.Config, dataDir string, logger *slog.Logger) http.Ha
h, ok := handlers[p.Username] h, ok := handlers[p.Username]
if !ok { if !ok {
username := p.Username username := p.Username
userDir := filepath.Join(dataDir, "files", username) userDir := filepath.Join(dataDir, username, "files")
if err := os.MkdirAll(userDir, 0o755); err != nil { if err := os.MkdirAll(userDir, 0o755); err != nil {
mu.Unlock() mu.Unlock()
logger.Error("creating user WebDAV dir", "user", username, "error", err) logger.Error("creating user WebDAV dir", "user", username, "error", err)
+5 -5
View File
@@ -9,8 +9,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/yourusername/caldav-server/internal/auth" "git.arnef.de/arnef/nidus/internal/auth"
filewebdav "github.com/yourusername/caldav-server/internal/webdav" filewebdav "git.arnef.de/arnef/nidus/internal/webdav"
) )
func testLogger() *slog.Logger { func testLogger() *slog.Logger {
@@ -54,10 +54,10 @@ func TestPerUserIsolationAndPrefix(t *testing.T) {
t.Fatalf("expected bob to get 404 for alice's file, got %d", bobRec.Code) t.Fatalf("expected bob to get 404 for alice's file, got %d", bobRec.Code)
} }
// Confirm the file physically landed under dataDir/files/alice/, not // Confirm the file physically landed under dataDir/alice/files/, not
// nested under an extra files/files/... path. // nested under an extra files/files/... path.
if _, err := os.Stat(dir + "/files/alice/note.txt"); err != nil { if _, err := os.Stat(dir + "/alice/files/note.txt"); err != nil {
t.Fatalf("expected file at dataDir/files/alice/note.txt: %v", err) t.Fatalf("expected file at dataDir/alice/files/note.txt: %v", err)
} }
} }
+62
View File
@@ -0,0 +1,62 @@
// Command migrate is a tool to restructure the data directory from the
// old format to the new unified format.
package main
import (
"flag"
"fmt"
"log/slog"
"os"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/store"
)
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
verbose := fs.Bool("verbose", false, "enable verbose output")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
return 1
}
logger := buildLogger(*verbose)
st, err := store.NewStore(cfg.Storage.DataDir)
if err != nil {
fmt.Fprintf(os.Stderr, "error opening store %q: %v\n", cfg.Storage.DataDir, err)
return 1
}
logger.Info("starting migration", "data_dir", cfg.Storage.DataDir)
if err := st.Migrate(); err != nil {
logger.Error("migration failed", "error", err)
fmt.Fprintf(os.Stderr, "migration failed: %v\n", err)
return 1
}
logger.Info("migration completed successfully")
fmt.Println("Migration completed successfully")
return 0
}
func buildLogger(verbose bool) *slog.Logger {
level := slog.LevelInfo
if verbose {
level = slog.LevelDebug
}
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
})
return slog.New(handler)
}
+54 -48
View File
@@ -8,14 +8,13 @@
package main package main
import ( import (
"flag"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"github.com/yourusername/caldav-server/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"github.com/yourusername/caldav-server/internal/store" "git.arnef.de/arnef/nidus/internal/store"
) )
func main() { func main() {
@@ -23,18 +22,12 @@ func main() {
} }
func run(args []string) int { func run(args []string) int {
fs := flag.NewFlagSet("nidusctl", flag.ContinueOnError) if len(args) < 1 {
cfgPath := fs.String("config", "config.yaml", "path to configuration file")
if err := fs.Parse(args); err != nil {
return 2
}
rest := fs.Args()
if len(rest) < 1 {
usage() usage()
return 2 return 2
} }
cfg, err := config.Load(*cfgPath) cfg, err := config.Load()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
return 1 return 1
@@ -54,18 +47,20 @@ func run(args []string) int {
return 1 return 1
} }
switch rest[0] { switch args[0] {
case "user": case "user":
return runUser(dbase, rest[1:]) return runUser(dbase, args[1:])
case "calendar", "cal": case "calendar", "cal":
return runCalendar(dbase, st, rest[1:]) return runCalendar(dbase, st, args[1:])
case "addressbook", "card": case "addressbook", "card":
return runAddressBook(dbase, st, rest[1:]) return runAddressBook(dbase, st, args[1:])
case "migrate":
return runMigrate(st, args[1:])
case "help", "-h", "--help": case "help", "-h", "--help":
usage() usage()
return 0 return 0
default: default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", rest[0]) fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
usage() usage()
return 2 return 2
} }
@@ -75,25 +70,32 @@ func usage() {
fmt.Fprint(os.Stderr, `nidusctl - manage nidus DAV server users, resources, and sharing grants fmt.Fprint(os.Stderr, `nidusctl - manage nidus DAV server users, resources, and sharing grants
Usage: Usage:
nidusctl [-config config.yaml] user create <username> [--display-name NAME] [--email EMAIL] [--password PW] nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PW]
nidusctl [-config config.yaml] user delete <username> nidusctl user delete <username>
nidusctl [-config config.yaml] user list nidusctl user list
nidusctl [-config config.yaml] user passwd <username> [--password PW] nidusctl user passwd <username> [--password PW]
nidusctl [-config config.yaml] calendar create <owner> <calendar> [--color '#RRGGBB'] nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']
nidusctl [-config config.yaml] calendar color <owner> <calendar> <hex-color> nidusctl calendar color <owner> <calendar> <hex-color>
nidusctl [-config config.yaml] calendar delete <owner> <calendar> nidusctl calendar delete <owner> <calendar>
nidusctl [-config config.yaml] calendar list <owner> nidusctl calendar list <owner>
nidusctl [-config config.yaml] calendar share <owner> <calendar> <user> <read|write> nidusctl calendar share <owner> <calendar> <user> <read|write>
nidusctl [-config config.yaml] calendar unshare <owner> <calendar> <user> nidusctl calendar unshare <owner> <calendar> <user>
nidusctl [-config config.yaml] calendar shares <owner> <calendar> nidusctl calendar shares <owner> <calendar>
nidusctl [-config config.yaml] addressbook create <owner> <book> nidusctl addressbook create <owner> <book>
nidusctl [-config config.yaml] addressbook delete <owner> <book> nidusctl addressbook delete <owner> <book>
nidusctl [-config config.yaml] addressbook list <owner> nidusctl addressbook list <owner>
nidusctl [-config config.yaml] addressbook share <owner> <book> <user> <read|write> nidusctl addressbook share <owner> <book> <user> <read|write>
nidusctl [-config config.yaml] addressbook unshare <owner> <book> <user> nidusctl addressbook unshare <owner> <book> <user>
nidusctl [-config config.yaml] addressbook shares <owner> <book> nidusctl addressbook shares <owner> <book>
nidusctl migrate [--verbose]
nidusctl help
Configuration is done via environment variables:
NIDUS_DATA_DIR - data directory (default: ./data)
Examples: Examples:
nidusctl user create alice --display-name "Alice Smith" --email alice@example.com nidusctl user create alice --display-name "Alice Smith" --email alice@example.com
@@ -101,16 +103,10 @@ Examples:
nidusctl calendar share alice work bob write nidusctl calendar share alice work bob write
nidusctl calendar shares alice work nidusctl calendar shares alice work
nidusctl calendar unshare alice work bob nidusctl calendar unshare alice work bob
nidusctl migrate
`) `)
} }
// newFlagSet creates a flag.FlagSet configured for subcommand parsing
// (flags may appear before or after positional args, since callers parse
// flags first with fs.Parse then read fs.Args() for the rest).
func newFlagSet(name string) *flag.FlagSet {
return flag.NewFlagSet(name, flag.ContinueOnError)
}
func runCalendar(dbase *db.DB, st *store.Store, args []string) int { func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
if len(args) < 1 { if len(args) < 1 {
usage() usage()
@@ -118,17 +114,17 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
} }
switch args[0] { switch args[0] {
case "create": case "create":
fs := newFlagSet("calendar create") color := ""
color := fs.String("color", "", "hex color like #3b82f6 (optional)") if len(args) > 2 && args[1] == "--color" {
if err := fs.Parse(args[1:]); err != nil { color = args[2]
return 2 args = args[:1]
} }
if fs.NArg() != 2 { if len(args) != 3 {
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']") fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']")
return 2 return 2
} }
owner, calName := fs.Arg(0), fs.Arg(1) owner, calName := args[1], args[2]
if err := dbase.CreateCalendarWithColor(owner, calName, *color); err != nil { if err := dbase.CreateCalendarWithColor(owner, calName, color); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }
@@ -359,6 +355,16 @@ func runAddressBook(dbase *db.DB, st *store.Store, args []string) int {
} }
} }
func runMigrate(st *store.Store, args []string) int {
// Ignore args for now (could add --verbose flag in future if needed)
if err := st.Migrate(); err != nil {
fmt.Fprintf(os.Stderr, "migration failed: %v\n", err)
return 1
}
fmt.Println("Migration completed successfully")
return 0
}
// warnIfUnknownUser reports (without failing) if username is not a // warnIfUnknownUser reports (without failing) if username is not a
// registered user — the share is still recorded, since a user could be // registered user — the share is still recorded, since a user could be
// created afterwards. // created afterwards.
+19 -39
View File
@@ -9,28 +9,9 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
) )
// writeTestConfig creates a minimal config.yaml in dir and returns its path.
func writeTestConfig(t *testing.T, dir string) string {
t.Helper()
cfgPath := filepath.Join(dir, "config.yaml")
dataDir := filepath.Join(dir, "data")
content := "storage:\n data_dir: " + dataDir + "\n" +
"users:\n" +
" alice:\n" +
" password: \"$2a$10$9.WEs0uz5TaNJLQbSTLrX.Te.BIe8XTTykVRzZbSHQMEkyFb8Sq/O\"\n" +
" bob:\n" +
" password: \"$2a$10$9.WEs0uz5TaNJLQbSTLrX.Te.BIe8XTTykVRzZbSHQMEkyFb8Sq/O\"\n"
if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil {
t.Fatalf("writing test config: %v", err)
}
return cfgPath
}
// runCLI runs the CLI's run() function, capturing stdout/stderr, and
// returns (exit code, combined stdout+stderr).
func runCLI(t *testing.T, args ...string) (int, string) { func runCLI(t *testing.T, args ...string) (int, string) {
t.Helper() t.Helper()
@@ -56,9 +37,9 @@ func runCLI(t *testing.T, args ...string) (int, string) {
func TestCalendarShareUnshareLifecycle(t *testing.T) { func TestCalendarShareUnshareLifecycle(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "bob", "write") code, out := runCLI(t, "calendar", "share", "alice", "work", "bob", "write")
if code != 0 { if code != 0 {
t.Fatalf("share exit code = %d, output: %s", code, out) t.Fatalf("share exit code = %d, output: %s", code, out)
} }
@@ -66,7 +47,7 @@ func TestCalendarShareUnshareLifecycle(t *testing.T) {
t.Errorf("output = %q, want to contain 'shared'", out) t.Errorf("output = %q, want to contain 'shared'", out)
} }
code, out = runCLI(t, "-config", cfgPath, "calendar", "shares", "alice", "work") code, out = runCLI(t, "calendar", "shares", "alice", "work")
if code != 0 { if code != 0 {
t.Fatalf("shares exit code = %d, output: %s", code, out) t.Fatalf("shares exit code = %d, output: %s", code, out)
} }
@@ -74,12 +55,12 @@ func TestCalendarShareUnshareLifecycle(t *testing.T) {
t.Errorf("output = %q, want to contain bob/write", out) t.Errorf("output = %q, want to contain bob/write", out)
} }
code, out = runCLI(t, "-config", cfgPath, "calendar", "unshare", "alice", "work", "bob") code, out = runCLI(t, "calendar", "unshare", "alice", "work", "bob")
if code != 0 { if code != 0 {
t.Fatalf("unshare exit code = %d, output: %s", code, out) t.Fatalf("unshare exit code = %d, output: %s", code, out)
} }
code, out = runCLI(t, "-config", cfgPath, "calendar", "shares", "alice", "work") code, out = runCLI(t, "calendar", "shares", "alice", "work")
if code != 0 { if code != 0 {
t.Fatalf("shares (after unshare) exit code = %d, output: %s", code, out) t.Fatalf("shares (after unshare) exit code = %d, output: %s", code, out)
} }
@@ -90,9 +71,9 @@ func TestCalendarShareUnshareLifecycle(t *testing.T) {
func TestCalendarShareInvalidPermission(t *testing.T) { func TestCalendarShareInvalidPermission(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "bob", "admin") code, out := runCLI(t, "calendar", "share", "alice", "work", "bob", "admin")
if code != 2 { if code != 2 {
t.Errorf("exit code = %d, want 2; output: %s", code, out) t.Errorf("exit code = %d, want 2; output: %s", code, out)
} }
@@ -103,9 +84,9 @@ func TestCalendarShareInvalidPermission(t *testing.T) {
func TestCalendarUnshareNotFound(t *testing.T) { func TestCalendarUnshareNotFound(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "unshare", "alice", "ghost", "bob") code, out := runCLI(t, "calendar", "unshare", "alice", "ghost", "bob")
if code != 1 { if code != 1 {
t.Errorf("exit code = %d, want 1; output: %s", code, out) t.Errorf("exit code = %d, want 1; output: %s", code, out)
} }
@@ -116,14 +97,14 @@ func TestCalendarUnshareNotFound(t *testing.T) {
func TestAddressBookShareUnshareLifecycle(t *testing.T) { func TestAddressBookShareUnshareLifecycle(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "addressbook", "share", "alice", "contacts", "bob", "read") code, out := runCLI(t, "addressbook", "share", "alice", "contacts", "bob", "read")
if code != 0 { if code != 0 {
t.Fatalf("share exit code = %d, output: %s", code, out) t.Fatalf("share exit code = %d, output: %s", code, out)
} }
code, out = runCLI(t, "-config", cfgPath, "addressbook", "shares", "alice", "contacts") code, out = runCLI(t, "addressbook", "shares", "alice", "contacts")
if code != 0 { if code != 0 {
t.Fatalf("shares exit code = %d, output: %s", code, out) t.Fatalf("shares exit code = %d, output: %s", code, out)
} }
@@ -131,7 +112,7 @@ func TestAddressBookShareUnshareLifecycle(t *testing.T) {
t.Errorf("output = %q, want to contain bob/read", out) t.Errorf("output = %q, want to contain bob/read", out)
} }
code, out = runCLI(t, "-config", cfgPath, "addressbook", "unshare", "alice", "contacts", "bob") code, out = runCLI(t, "addressbook", "unshare", "alice", "contacts", "bob")
if code != 0 { if code != 0 {
t.Fatalf("unshare exit code = %d, output: %s", code, out) t.Fatalf("unshare exit code = %d, output: %s", code, out)
} }
@@ -139,9 +120,9 @@ func TestAddressBookShareUnshareLifecycle(t *testing.T) {
func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) { func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "carol", "read") code, out := runCLI(t, "calendar", "share", "alice", "work", "carol", "read")
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, output: %s", code, out) t.Fatalf("exit code = %d, output: %s", code, out)
} }
@@ -154,10 +135,9 @@ func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) {
} }
func TestNoArgsShowsUsage(t *testing.T) { func TestNoArgsShowsUsage(t *testing.T) {
dir := t.TempDir()
// No config needed since usage() is printed before config.Load for // No config needed since usage() is printed before config.Load for
// missing subcommands. // missing subcommands.
code, out := runCLI(t, "-config", filepath.Join(dir, "missing.yaml")) code, out := runCLI(t)
if code != 2 { if code != 2 {
t.Errorf("exit code = %d, want 2", code) t.Errorf("exit code = %d, want 2", code)
} }
@@ -168,9 +148,9 @@ func TestNoArgsShowsUsage(t *testing.T) {
func TestUnknownCommand(t *testing.T) { func TestUnknownCommand(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "bogus") code, out := runCLI(t, "bogus")
if code != 2 { if code != 2 {
t.Errorf("exit code = %d, want 2", code) t.Errorf("exit code = %d, want 2", code)
} }
+45 -21
View File
@@ -6,7 +6,7 @@ import (
"os" "os"
"strings" "strings"
"github.com/yourusername/caldav-server/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"golang.org/x/term" "golang.org/x/term"
) )
@@ -31,31 +31,47 @@ func runUser(dbase *db.DB, args []string) int {
} }
func userCreate(dbase *db.DB, args []string) int { func userCreate(dbase *db.DB, args []string) int {
fs := newFlagSet("nidusctl user create") var displayName, email, password string
displayName := fs.String("display-name", "", "display name shown in DAV clients") var rest []string
email := fs.String("email", "", "email address")
password := fs.String("password", "", "password (omit to be prompted, recommended)") for i := 0; i < len(args); i++ {
if err := fs.Parse(args); err != nil { switch args[i] {
return 2 case "--display-name":
if i+1 < len(args) {
displayName = args[i+1]
i++
} }
rest := fs.Args() case "--email":
if i+1 < len(args) {
email = args[i+1]
i++
}
case "--password":
if i+1 < len(args) {
password = args[i+1]
i++
}
default:
rest = append(rest, args[i])
}
}
if len(rest) != 1 { if len(rest) != 1 {
fmt.Fprintln(os.Stderr, "usage: nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PASSWORD]") fmt.Fprintln(os.Stderr, "usage: nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PASSWORD]")
return 2 return 2
} }
username := rest[0] username := rest[0]
pw := *password if password == "" {
if pw == "" {
var err error var err error
pw, err = promptPassword(username) password, err = promptPassword(username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error reading password: %v\n", err) fmt.Fprintf(os.Stderr, "error reading password: %v\n", err)
return 1 return 1
} }
} }
if err := dbase.CreateUser(username, pw, *displayName, *email); err != nil { if err := dbase.CreateUser(username, password, displayName, email); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }
@@ -98,29 +114,37 @@ func userList(dbase *db.DB, args []string) int {
} }
func userPasswd(dbase *db.DB, args []string) int { func userPasswd(dbase *db.DB, args []string) int {
fs := newFlagSet("nidusctl user passwd") var password string
password := fs.String("password", "", "new password (omit to be prompted, recommended)") var rest []string
if err := fs.Parse(args); err != nil {
return 2 for i := 0; i < len(args); i++ {
switch args[i] {
case "--password":
if i+1 < len(args) {
password = args[i+1]
i++
} }
rest := fs.Args() default:
rest = append(rest, args[i])
}
}
if len(rest) != 1 { if len(rest) != 1 {
fmt.Fprintln(os.Stderr, "usage: nidusctl user passwd <username> [--password PASSWORD]") fmt.Fprintln(os.Stderr, "usage: nidusctl user passwd <username> [--password PASSWORD]")
return 2 return 2
} }
username := rest[0] username := rest[0]
pw := *password if password == "" {
if pw == "" {
var err error var err error
pw, err = promptPassword(username) password, err = promptPassword(username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error reading password: %v\n", err) fmt.Fprintf(os.Stderr, "error reading password: %v\n", err)
return 1 return 1
} }
} }
if err := dbase.SetPassword(username, pw); err != nil { if err := dbase.SetPassword(username, password); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }
+1 -1
View File
File diff suppressed because one or more lines are too long
+18 -12
View File
@@ -9,6 +9,7 @@
const filesInput = document.getElementById("upload-files-input"); const filesInput = document.getElementById("upload-files-input");
const folderInput = document.getElementById("upload-folder-input"); const folderInput = document.getElementById("upload-folder-input");
const newFolderButton = document.getElementById("new-folder-button"); const newFolderButton = document.getElementById("new-folder-button");
const sortSelect = document.getElementById("sort-select");
const status = document.getElementById("upload-status"); const status = document.getElementById("upload-status");
if (!dropZone) { if (!dropZone) {
return; return;
@@ -25,11 +26,6 @@
} }
const formData = new FormData(); const formData = new FormData();
for (const file of files) { for (const file of files) {
// A folder-selected/dropped file's relative path (e.g.
// "photos/2024/img.jpg") is sent as a parallel "paths" field
// (same order as "files") since the server strips any
// directory component from the file's own filename per the
// multipart spec — see internal/web/files.go.
const relPath = file.webkitRelativePath || file.name; const relPath = file.webkitRelativePath || file.name;
formData.append("files", file, file.name); formData.append("files", file, file.name);
formData.append("paths", relPath); formData.append("paths", relPath);
@@ -56,7 +52,7 @@
void upload(Array.from(folderInput.files || [])); void upload(Array.from(folderInput.files || []));
folderInput.value = ""; folderInput.value = "";
}); });
const folderNameRe = /^[a-zA-Z0-9_-]{1,64}$/; const folderNameRe = /^[a-zA-Z0-9._-]{1,64}$/;
newFolderButton?.addEventListener("click", async () => { newFolderButton?.addEventListener("click", async () => {
const name = window.prompt("New folder name:"); const name = window.prompt("New folder name:");
if (!name) { if (!name) {
@@ -106,10 +102,22 @@
} }
}); });
}); });
// Recursively walk a dropped DataTransferItem (file or directory) into if (sortSelect) {
// a flat list of File objects, using the browser's non-standard but sortSelect.addEventListener("change", () => {
// widely-supported webkitGetAsEntry/FileSystemEntry APIs to support const value = sortSelect.value;
// dragging & dropping whole folders. let sortBy = "name";
let sortDir = "asc";
if (value === "name_asc") {
sortBy = "name";
sortDir = "asc";
}
else if (value === "name_desc") {
sortBy = "name";
sortDir = "desc";
}
window.location.href = `${uploadUrl}?sort_by=${sortBy}&sort_dir=${sortDir}`;
});
}
function readEntry(entry) { function readEntry(entry) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (entry.isFile) { if (entry.isFile) {
@@ -163,8 +171,6 @@
} }
} }
if (entries.length === 0) { if (entries.length === 0) {
// Fallback for browsers without webkitGetAsEntry support: flat
// files only, no folder traversal.
void upload(Array.from(e.dataTransfer?.files || [])); void upload(Array.from(e.dataTransfer?.files || []));
return; return;
} }
+18 -12
View File
@@ -8,6 +8,7 @@
const filesInput = document.getElementById("upload-files-input") as HTMLInputElement | null; const filesInput = document.getElementById("upload-files-input") as HTMLInputElement | null;
const folderInput = document.getElementById("upload-folder-input") as HTMLInputElement | null; const folderInput = document.getElementById("upload-folder-input") as HTMLInputElement | null;
const newFolderButton = document.getElementById("new-folder-button") as HTMLButtonElement | null; const newFolderButton = document.getElementById("new-folder-button") as HTMLButtonElement | null;
const sortSelect = document.getElementById("sort-select") as HTMLSelectElement | null;
const status = document.getElementById("upload-status") as HTMLParagraphElement | null; const status = document.getElementById("upload-status") as HTMLParagraphElement | null;
if (!dropZone) { if (!dropZone) {
return; return;
@@ -27,11 +28,6 @@
} }
const formData = new FormData(); const formData = new FormData();
for (const file of files) { for (const file of files) {
// A folder-selected/dropped file's relative path (e.g.
// "photos/2024/img.jpg") is sent as a parallel "paths" field
// (same order as "files") since the server strips any
// directory component from the file's own filename per the
// multipart spec — see internal/web/files.go.
const relPath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; const relPath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
formData.append("files", file, file.name); formData.append("files", file, file.name);
formData.append("paths", relPath); formData.append("paths", relPath);
@@ -61,7 +57,7 @@
folderInput.value = ""; folderInput.value = "";
}); });
const folderNameRe = /^[a-zA-Z0-9_-]{1,64}$/; const folderNameRe = /^[a-zA-Z0-9._-]{1,64}$/;
newFolderButton?.addEventListener("click", async () => { newFolderButton?.addEventListener("click", async () => {
const name = window.prompt("New folder name:"); const name = window.prompt("New folder name:");
@@ -112,10 +108,22 @@
}); });
}); });
// Recursively walk a dropped DataTransferItem (file or directory) into if (sortSelect) {
// a flat list of File objects, using the browser's non-standard but sortSelect.addEventListener("change", () => {
// widely-supported webkitGetAsEntry/FileSystemEntry APIs to support const value = sortSelect.value;
// dragging & dropping whole folders. let sortBy = "name";
let sortDir = "asc";
if (value === "name_asc") {
sortBy = "name";
sortDir = "asc";
} else if (value === "name_desc") {
sortBy = "name";
sortDir = "desc";
}
window.location.href = `${uploadUrl}?sort_by=${sortBy}&sort_dir=${sortDir}`;
});
}
function readEntry(entry: FileSystemEntry): Promise<File[]> { function readEntry(entry: FileSystemEntry): Promise<File[]> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (entry.isFile) { if (entry.isFile) {
@@ -170,8 +178,6 @@
} }
} }
if (entries.length === 0) { if (entries.length === 0) {
// Fallback for browsers without webkitGetAsEntry support: flat
// files only, no folder traversal.
void upload(Array.from(e.dataTransfer?.files || [])); void upload(Array.from(e.dataTransfer?.files || []));
return; return;
} }