Compare commits
22
Commits
v1.0.0
...
f34041d889
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f34041d889 | ||
|
|
e18c83b618 | ||
|
|
1f383878ab | ||
|
|
c945e785c8 | ||
|
|
ea917b4148 | ||
|
|
995441e917 | ||
|
|
36e3fecc11 | ||
|
|
6a95e27e31 | ||
|
|
e8c4dadfea | ||
|
|
2e43e32a9f | ||
|
|
9b9cbe2f6f | ||
|
|
0ee0874458 | ||
|
|
b364bee265 | ||
|
|
dd77a61b18 | ||
|
|
d3780e9e32 | ||
|
|
e96a0b8c92 | ||
|
|
0567fea729 | ||
|
|
5af3a8508b | ||
|
|
8d2d8d3972 | ||
|
|
2fd39c8180 | ||
|
|
f463c01f0f | ||
|
|
cd0761c9d1 |
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 |
|
|
||||||
|
|||||||
+28
-24
@@ -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.ListenAndServe()
|
||||||
err = srv.ListenAndServeTLS(cfg.TLS.CertFile, cfg.TLS.KeyFile)
|
|
||||||
} else {
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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
@@ -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,4 +1,4 @@
|
|||||||
module github.com/yourusername/caldav-server
|
module git.arnef.de/arnef/nidus
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/">`+
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+37
-64
@@ -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.
|
// Load reads and parses environment variables to create the configuration.
|
||||||
func Load(path string) (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("reading config %q: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Auth.Realm = getEnv("NIDUS_AUTH_REALM", "DAV Server")
|
||||||
|
c.Storage.DataDir = getEnv("NIDUS_DATA_DIR", "./data")
|
||||||
|
c.Logging.Level = getEnv("NIDUS_LOG_LEVEL", "info")
|
||||||
|
c.Logging.Format = getEnv("NIDUS_LOG_FORMAT", "text")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) validate() error {
|
func getEnv(key string, defaultValue string) string {
|
||||||
if c.TLS.Enabled {
|
if val := os.Getenv(key); val != "" {
|
||||||
if c.TLS.CertFile == "" || c.TLS.KeyFile == "" {
|
return val
|
||||||
return fmt.Errorf("tls.cert_file and tls.key_file are required when tls is enabled")
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+158
-30
@@ -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 {
|
||||||
|
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
|
||||||
|
// 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
|
fetchedAt time.Time
|
||||||
cal *ical.Calendar
|
|
||||||
err error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
}
|
||||||
|
// Stale — return the cached value immediately; spawn at most one
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
c.entries[url] = entry{fetchedAt: time.Now(), cal: cal, err: err}
|
}
|
||||||
return cal, err
|
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">← 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">← Calendar</a>
|
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">← Calendar</a>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
<input name="address_value" type="text" value={ a.Value } placeholder="Street, city, postal code, country"
|
||||||
</div>
|
class="flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
|
||||||
<input name="address_street" type="text" value={ a.Street } placeholder="Straße und Hausnummer"
|
<button type="button" class="remove-row-btn text-red-600 hover:underline text-xs shrink-0">Remove</button>
|
||||||
class="col-span-2 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
|
|
||||||
<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,22 +229,29 @@ 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>
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<label class="block text-sm font-medium text-gray-700">Full name</label>
|
<div>
|
||||||
<input name="full_name" type="text" required value={ data.FullName }
|
<label class="block text-sm font-medium text-gray-700">First name</label>
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
|
<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"/>
|
||||||
|
</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>
|
<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>
|
||||||
@@ -253,44 +259,44 @@ templ ContactForm(username string, data ContactFormData, errMsg string) {
|
|||||||
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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_Var39))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||||
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\">← ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" class=\"text-sm text-indigo-600 hover:underline\">← ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var40 string
|
var templ_7745c5c3_Var38 string
|
||||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(data.Book)
|
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(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: 120}
|
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))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " <form method=\"POST\" action=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, e := range entries {
|
var templ_7745c5c3_Var7 string
|
||||||
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\">")
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(sortBy)
|
||||||
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: 55, Col: 24}
|
||||||
}
|
|
||||||
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 {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var7 string
|
|
||||||
templ_7745c5c3_Var7, 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: 75, Col: 69}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
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\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var8 string
|
|
||||||
templ_7745c5c3_Var8, 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: 76, Col: 72}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
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\">")
|
|
||||||
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, 12, "</div></td></tr>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if len(entries) == 0 {
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
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 & 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, e := range entries {
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-sort-dir=\"")
|
||||||
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 {
|
||||||
if templ_7745c5c3_Err != nil {
|
return templ_7745c5c3_Err
|
||||||
return templ_7745c5c3_Err
|
}
|
||||||
}
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Err = fileEntryLink(e).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(sortDir)
|
||||||
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: 56, Col: 26}
|
||||||
}
|
}
|
||||||
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\">")
|
_, 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
|
||||||
}
|
}
|
||||||
if e.Size != "" {
|
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\">")
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span>")
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 templ.SafeURL
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(e.Size)
|
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.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 106, Col: 22}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 72, Col: 89}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var10, 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: 74, Col: 88}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span>")
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<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_Var11 templ.SafeURL
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(e.ModTime)
|
templ_7745c5c3_Var11, 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: 77, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
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, 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 & 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 & 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 & 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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++
|
||||||
|
}
|
||||||
|
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])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rest := fs.Args()
|
|
||||||
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++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
rest = append(rest, args[i])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rest := fs.Args()
|
|
||||||
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
File diff suppressed because one or more lines are too long
+18
-12
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user