Skip to content

Releases: Calnode/calnode

v0.10.1

Choose a tag to compare

@github-actions github-actions released this 29 Sep 06:00

Added

  • NethServer 8 module (packaging/ns8). Calnode ships as a one-click NS8
    app: host-based Traefik route with cluster TLS, SQLite on a persistent
    volume, generated encryption key preserved across reconfigures, and release
    tags that pin module and app to the same version. Preview-grade: installed
    and configured paths are covered by robot tests but no live node has run
    one end to end yet — see packaging/ns8/README.md.

Fixed

  • Public booking lookup is now rate-limited. GET /v1/bookings/{id} needs no
    auth by design (it carries no PII), but it was the one public route outside
    any rate limiter — an enumeration free-for-all. It shares the manage-token
    bucket now.
  • CalDAV connect failures no longer distinguish error classes. Refused vs
    timeout vs TLS vs auth failures were surfaced verbatim to the member form, a
    usable LAN-scan oracle. The form returns one generic message and logs the
    detail server-side; timing side-channels are accepted as residual.
  • CalDAV no longer sends Basic credentials on cross-origin redirects. A
    redirect to another origin now drops the Authorization header instead of
    forwarding the app password to a server the user never configured.
  • A short GOOGLE_CLIENT_ID no longer panics at boot. The startup log
    sliced the first 20 characters unconditionally; unset-or-short values
    crashed the process instead of logging the usual "not configured" warning.
  • Video room explains host takeover instead of silently dropping controls.
    Sharing the host link lets anyone take over as host, and the demoted side
    just lost its controls with no explanation. Host-link holders are now warned
    pre-join not to share it, and a demotion names who took over with a reclaim
    hint for owners.

v0.10.0

Choose a tag to compare

@github-actions github-actions released this 27 Sep 21:26

Security

  • Moving or cancelling a CalDAV booking no longer sends another account's app password
    to the server holding the event.
    A host can connect several CalDAV accounts, and
    rescheduling or cancelling authenticated as whichever account was the destination at
    the time, not the one the event was written to. After a host moved their destination
    from an account on one server to an account on another, every reschedule or cancel of
    an older booking sent the new account's username and app password to the old account's
    server. A server that refused them left the calendar unchanged and the reconciler
    retried, sending them again every sweep for a cancelled booking and until the end time
    for a moved one. A server that answered 404 instead was taken at its word: the event
    counted as already gone and stayed where it was.

    Update and cancel now authenticate as the account that holds the event, found from what
    the booking stored: the calendar recorded at creation, or failing that the connected
    calendar whose URL contains the event's URL (same scheme, host and port). If no single
    account can be established, nothing is sent, and the reconciler logs one warning and stops
    retrying that event rather than refusing it again every sweep; the event stays where it
    is. Hosts who moved a
    CalDAV destination between accounts on different servers should consider rotating the
    app password of the account they moved to.

  • A booker's email address is validated where it enters, and is never written into an
    email header unparsed.
    The To: header was the one header field assembled from
    caller-supplied input with no encoder in front of it: buildRaw parsed each recipient
    with net/mail and, when that failed, appended the raw string anyway, so a CR/LF inside
    an address would have ended the To: line and started a header of the sender's
    choosing. Subject and the From display name already go through mime.QEncoding
    (which renders CR/LF as =0D/=0A) and attachment filenames through %q.

    Not exploitable as shipped: Send issues c.Rcpt(to) before DATA, and net/smtp
    runs validateLine inside Rcpt, refusing any CR or LF - so a CRLF-bearing address
    aborted the exchange at RCPT TO and never reached the body. That protection is
    incidental, lives one call away in the standard library, and covers only this
    transport. buildRaw now refuses an unparsed address (and an empty recipient list)
    outright, returning mailer.ErrInvalidRecipient before anything is dialed; the
    rejected value is kept out of the error, which is logged.

    The public booking paths validate at intake rather than relying on the mailer: the REST
    handler (POST /v1/bookings) answers 400 "email must be a valid email address", and
    the shared core behind the conversational assistant's book tool and the MCP
    create_booking tool checks the address before it persists anything. Both store the
    parsed bare address, so a pasted Bob <bob@example.com> is recorded as
    bob@example.com - a small deliberate behaviour change, matching what the hourly
    throttle, the per-invitee cap and the To: header already assume they hold.

Added

  • Sign out everywhere. POST /v1/auth/sessions/revoke-all ends every session you
    have except the one you asked from, so losing a laptop no longer means waiting out a
    30-day cookie. Pass {"user_id": "..."} and an admin can do the same for someone
    else: an admin may revoke a member, only the owner may revoke another admin, and the
    owner's own sessions can only be ended by the owner.

    It also revokes that person's MCP OAuth tokens, which is the part that makes it an
    offboarding tool rather than a convenience. A connected agent authenticates with a
    bearer token and not the session cookie, so ending the sessions alone would have left
    it holding exactly the access that was just withdrawn.

  • Canadian French (fr-CA) on the booker-facing surfaces. A visitor whose browser asks
    for fr-CA now gets Canadian French rather than the France copy; fr and fr-FR are
    unaffected. It is the first regional locale, and a separate file rather than a fallback
    because the differences are real: courriel rather than e-mail, reporter/report
    rather than reprogrammer, renseignements personnels (the Quebec statutory term) rather
    than données personnelles, no space before ! ? ; where France puts one, and CLDR
    itself spells July juill. here against juil. in France.

    ⚠️ The wording is an unreviewed draft, like every non-English locale in this
    repository: the structure is verified by the same three guards (same keys, printf-verb
    parity, date tables cross-checked against CLDR), but no native Canadian French speaker has
    read the copy. Corrections are welcome and easy to merge — see CONTRIBUTING.

  • FRAME_ANCESTORS: embed the admin UI in your own console. Space-separated origins
    (https://console.example.com 'self'); when set, /admin/ sends
    Content-Security-Policy: frame-ancestors <list>. The public booking pages are
    untouched and still deny framing outright — this is about the console, not the pages
    that take card details.

    Two deliberate refusals. An entry that is not https://host[:port] or 'self' stops
    the app booting rather than being ignored, because a browser drops a source list it
    cannot parse, which would leave the admin UI more embeddable than the setting being
    unset. And no X-Frame-Options is sent beside it: that header has no allow-list form,
    so the only value it could carry is SAMEORIGIN, which browsers honour instead of the
    CSP and would break the embedding this exists for.

  • Per-person and team booking pages. A handle set in profile settings buys
    /u/{handle}: every public, active event type the person owns or hosts, with
    duration, location and price per type. Teams get /team/{slug} with their types
    plus a member roster linking to each member's page. Archived users 404, and handles
    are slugified, unique, and cleared by blanking the field. Answers
    #94.

  • Custom-hours date overrides take several blocks. A date override used to hold a
    single window while weekly rules take as many as you like, so a split day (open
    morning and afternoon around a mid-day appointment) had no honest expression. Dates
    now hold any number of custom blocks; a blocking override replaces the customs on
    its date, an exact duplicate still 409s, and the list flags blocks that overlap
    and merge. Answers
    #95.

  • SMTP through a separate relay address. Some providers require the SMTP session
    to open against a relay host distinct from the mail domain: the relay address is
    now configured explicitly instead of derived. Thanks
    @marijnbent (#65).

Fixed

  • SMTP email works with servers that offer AUTH LOGIN but not AUTH PLAIN.
    Calnode previously used PLAIN for every authenticated SMTP connection, which
    failed against servers such as Microsoft 365 that advertise LOGIN XOAUTH2
    after STARTTLS. It now prefers PLAIN when offered and uses LOGIN otherwise.
    LOGIN requires TLS, and servers offering neither supported method return a
    clear error.
  • Rescheduling on the manage page works again. Its slot list called an esc()
    helper that only ever existed on the booking page, so any day with availability
    threw before rendering and could not be rescheduled. The helper is now defined
    on both surfaces.
  • Removing a member no longer 500s on booking history, and no longer leaves
    their MCP tokens valid.
    booking_hosts.user_id had no ON DELETE action;
    both it and the token tables now cascade. The upcoming-booking guard covers
    group seats too, and primary-host history (past or present) blocks with a 409
    naming reassignment instead of failing in SQL.
  • Cancelling a paid booking cannot double-refund. The refund row is claimed
    with a conditional update before Stripe is called, the call carries an
    idempotency key, and a Stripe failure reverts the claim so a later cancel
    retries instead of silently keeping the money.
  • A lost confirmation email is retried once and then flagged, not just logged.
    Confirmation sends get one retry; a final failure sets confirm_failed on the
    booking, surfaced on the booking JSON for follow-up.
  • The slots response says when its busy data is incomplete. A provider outage
    used to read as free time while booking stayed fail-closed, offering slots that
    could not be sold. Slots now carry degraded: true, and all three booking
    surfaces show a warning.
  • Rescheduling or cancelling after a destination change acts on the provider that
    holds the event.
    The booking stored which calendar its event was written to but
    not which provider wrote it, so a destination move handed old event ids to a
    provider that never issued them: silent orphans one way, endless reconciler
    retries the other. Each host event now stamps its provider at creation; updates
    and cancels prefer the stamp, then id recognition (CalDAV URLs), then the current
    destination for pre-stamp rows. Answers
    #58.
  • The calendar picker no longer drops calendars past the first page. Microsoft
    requested $top=100 calendars once and Google maxResults=250 once, so anything
    beyond silently vanished: unreachable for conflict checks and unchoosable as the
    destination. Both listings now follow @odata.nextLink / nextPageToken to the
    end. Answers #59.
  • The Zoom setup text no longer promises that an unpublished app works for "your own
    team".
    Zoom only lets users inside the Zoom account that owns an unpublished app
    authorize it, so a member wi...
Read more

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 10 Sep 01:15

Added

  • Duplicate an event type. POST /v1/event-types/{slug}/duplicate, and a Duplicate
    action on each row of the event-types list. Closes
    #17.

    The copy carries everything that hangs off the original - intake questions, host
    assignments, event-type-specific availability rules, the reminder schedule, and the
    custom email subjects and notes - as a single transaction, so a half-built copy can
    never be left behind. It is created inactive, under a generated <slug>-copy (then
    -copy-2, -copy-3, …) slug, and keeps price_cents/currency verbatim: zeroing a
    copied price is how a paid meeting quietly starts selling for nothing. Bookings are not
    copied.

  • Empty days and minimum-notice gaps now explain themselves on all three booking
    surfaces (booking page, manage/reschedule page, embed widget). Closes
    #20.

    A day with nothing on it names the day, and the host when the event type has exactly
    one, instead of the bare "No available times." that never said whether another day would
    help. And when min_notice_minutes is what removed the nearest starts, the surfaces say
    so rather than leaving the visitor to guess - the most common "why can't I see those
    times".

    The engine decides that, not the front ends: GET /slots gains
    min_notice: {minutes, dates} listing the booker-local days the policy actually cost
    something. A start that is simply in the past, one a booking took away, and one no host
    pool could satisfy are all excluded, so the explanation never appears attached to the
    wrong cause. Three new/changed keys in all eight locales.

Fixed

  • Constraint violations are recognised by SQLite's error code rather than by its
    English message.
    Thirteen call sites asked strings.Contains(err.Error(), "UNIQUE constraint failed"), and SQLite reports a PRIMARY KEY collision
    (SQLITE_CONSTRAINT_PRIMARYKEY, 1555) with that exact message while giving it a
    different code from an ordinary unique violation (SQLITE_CONSTRAINT_UNIQUE, 2067).
    The text could not tell the two apart, so nothing that needed to distinguish them
    could.

    db.IsUniqueViolation, db.IsCheckViolation and db.IsForeignKeyViolation answer
    from the driver's code, falling back to the message only for an error that arrives
    without its driver type still attached. A driver error whose code does not match is a
    definite no rather than a fall-through, so an error cannot be classified by whether
    its text happened to contain an English phrase.

    Each class is provoked against the real schema in a test rather than constructed by
    hand, including the primary-key case, which is the one a code match written from the
    message alone would get wrong.

  • TRUSTED_PROXY_CIDRS: per-IP rate limits that work behind a CDN. Rate limits key
    on the TCP peer, which is right for a directly-reachable instance and useless behind a
    fronting CDN, where every visitor arrives from the same handful of addresses and shares
    one bucket. List the networks you control, a fronting CDN's own ranges included, and
    the client IP is taken from X-Forwarded-For walked right to left past those hops.

    Nothing changes if you do not set it: a header from a peer you have not listed is still
    not read at all, because it is a value the client chose. Within the header the leftmost
    entry is likewise client-chosen, so the walk stops at the rightmost address one of your
    proxies actually observed, and a malformed hop ends the walk on the peer rather than
    being stepped over. Repeated X-Forwarded-For field lines are joined in order rather
    than only the first being read, so a client's own line in front of a proxy that adds a
    second one cannot hide the hop that matters. Single-value vendor headers (CF-Connecting-IP, X-Real-IP,
    True-Client-IP) are never read, from any peer: the setting names networks rather than
    CDNs, and a plain reverse proxy in the list forwards whatever the client sent.

    Follow-ups on the above: the empty-day message keeps its call to action as well as
    naming the day ("No available times on Monday, 14 September. Try another date."), in
    all eight locales; the minimum-notice line gets its own .notice-hint style, a shade
    darker than the placeholder text it used to be indistinguishable from; and the notice
    gap is now computed only for callers that asked for it, so the MCP tool and the booking
    assistant stop paying for a presentation aid they never render.

  • An event type's booking link can be renamed until its first booking. PATCH /v1/event-types/{slug} now accepts slug, and the editor exposes it as "Booking link".
    Refused with 409 once bookings exist, because by then the link is in circulation and
    somebody's manage link resolves through it. Mainly this is what makes a duplicate
    usable: it arrives as <slug>-copy and there was previously no way to give it a real
    name short of deleting and recreating it.

  • An event type can no longer be created in a state the editor refuses to save. Three
    related fixes: creating one without a location defaulted to Zoom without checking whether
    the owner had connected Zoom (it now falls back to in-person, which needs nothing);
    PATCH validated the location whenever the request mentioned it, which the editor does on
    every save, so a stored value the current rules reject locked the operator out of every
    other field (it now validates only when the location actually changes); and the demo
    seeder wrote link with no URL, so a demo visitor's first edit failed on a field they had
    never touched.

Removed

  • BookingLogic.bookableDayKeys and book.html's bookableDates, which were written in
    0.8.0 and never read by anything.

v0.8.0

Choose a tag to compare

@github-actions github-actions released this 03 Sep 01:27

Added

  • Booked times can be shown struck through instead of hidden. Off by default, and
    enabled per event type under Visibility. Requested in
    #14, tracked as
    #19.

    For a public-hours use case - an intro call, a clinic, a tutor - a visibly busy
    calendar communicates demand, and an empty-looking list reads as "nothing here". It
    stays off by default because the slots endpoint is public and unauthenticated, so
    turning it on makes a host's booked hours legible to anyone with the link. That is a
    fair trade when the hours are already public and a privacy regression when they are
    not, so it is never inherited by upgrading.

    Only starts a booking or calendar conflict removed are shown. Times outside the host's
    working hours are never rendered, and times withheld by the minimum-notice rule are
    never shown as taken - nobody booked those, and saying so would corrupt the signal the
    feature exists to send. Booked times cannot be selected on any surface, and agents
    using the MCP tools or the booking assistant continue to see only bookable times.

    GET /v1/event-types/{slug}/slots gains a taken array for opted-in event types,
    absent otherwise. Event types gain show_taken_slots (migration 00057).

v0.7.0

Choose a tag to compare

@github-actions github-actions released this 02 Sep 22:02

Added

  • Filter and page the bookings list. The bookings page now filters by event type,
    host, team and status alongside the existing Upcoming/Past and Mine/All toggles, and
    pages through results 25 at a time instead of rendering everything at once. Requested
    in #15, tracked as
    #18.

    GET /v1/bookings gained event_type, host, team, status, when, from,
    to, order, limit and offset query parameters, and its response now carries
    total, counts and the active limit/offset beside items. MCP list_bookings
    gained team_id, limit and offset, and returns total.

Fixed

  • A running instance now reports which commit it is. /version reported
    commit: unknown on every container, because the image is built from a copied
    source tree with no .git for the Go toolchain to read VCS metadata from. That was
    survivable while only tagged releases were deployed; it is not now that branch
    images can be, since those report version: dev and nothing else identified the
    build. The commit is stamped explicitly at build time instead.
  • Webhook deliveries are no longer kept forever. Nothing ever purged
    webhook_deliveries, so on a busy instance the table grew for the life of the
    deployment, inside the SQLite file Litestream replicates offsite. The worker now
    sweeps finished deliveries after 30 days, alongside the five other tables it already
    purged. Only rows that reached success or failed are removed: a pending delivery
    still has a job pointing at it, and deleting one would turn a deliverable webhook
    into a permanent failure. The deliveries view only ever showed the 50 most recent, so
    nothing visible changes.
  • status=cancelled returned nothing, on every surface. Both booking list queries
    hardcoded an exclusion of cancelled bookings and then filtered on top of that result,
    so asking for cancelled bookings could never match anything - including through the
    MCP tool whose own schema advertises cancelled as a valid value. There was no way at
    all to view a cancelled booking. An explicit status now replaces the default exclusion
    instead of being applied after it; omitting it still hides cancelled bookings.
  • Filtering by host missed the meetings that person attends but doesn't lead. The
    host filter compared bookings.host_id only, while visibility has always counted a
    user as hosting a booking if they are the primary host or an assigned host. Group
    meetings someone was on were therefore invisible when filtering to them.

Changed

  • Bookings are selected in SQL rather than in the browser. GET /v1/bookings and
    MCP list_bookings previously loaded every booking the caller could see and then
    filtered and sorted the result in Go or in Svelte, running follow-up queries whose
    IN clause held every booking id returned, against a single-connection pool. Both now
    share one filtered, ordered, paginated query.
  • Indexed the bookings list. The only indexes on bookings both led on host_id
    and were partial, so every listing planned as a full scan plus a temporary B-tree
    sort of the whole matching set to return one page. Paginating the API alone would
    have made the response smaller without making the work smaller. (start_at, id) and
    (event_type_id, start_at, id) (migration 00056) turn the page query into an index
    walk that stops at the limit. The Upcoming/Past counts are an aggregate and still
    scan by design.

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 30 Aug 04:42

Fixed

  • Slot interval is now configurable, and defaults to the meeting length. Reported as
    "bookable timeslots are always 30 minutes apart regardless of duration" (#13). Interval
    and duration are deliberately separate settings - interval is how often a booking may
    start, duration is how long it runs - but the interval was not exposed anywhere in
    the admin UI, so every event type was stuck on the schema default of 30 unless you drove
    the REST API by hand. It now appears in the event-type editor, and new event types
    default it to their duration instead of a fixed 30, which was the wrong guess in both
    directions: a 15-minute event offered slots every 30 minutes, and a 90-minute one offered
    starts it could not honour. Existing event types keep their stored value until edited.
  • slot_interval_minutes is validated on create and update. Slot generation refuses a
    non-positive interval, so a 0 previously left an event type with no bookable times and
    nothing explaining why.

Added

  • The Connected apps page now shows the MCP connector URL, with a copy button. It
    listed what was connected but never said how to connect anything: the only guidance was
    in the empty state, referred to "its URL" without showing one, and vanished once the
    first app was approved.

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 26 Aug 04:21

Fixed

  • "calendar connection not found" when choosing where bookings are written. The
    destination endpoint looked the account up by its calendar_connections row id, but that
    id is recreated on every OAuth token refresh - and opening the calendar picker can trigger
    one - so a page loaded moments earlier held a dead id. Now keyed on the account identity,
    as the calendar endpoints already were.
  • Disconnecting a calendar could silently do nothing. The same stale-id lookup, but its
    miss branch returned success, so the API answered 204 having deleted nothing and the
    account simply stayed on the page with no error. Now keyed on account identity, and a
    genuinely unknown account is reported rather than swallowed.
  • Disconnecting left the account's calendar selections behind. connection_calendars
    has no foreign key on purpose (one would cascade-delete a user's selections on every token
    refresh), so disconnect flows have to clear the rows themselves - and none did, despite
    migration 00049 stating they did. Reconnecting the same address silently inherited stale
    picks, including a write target pointing at a calendar the user may no longer have.
  • The public booking page rendered blank for any event type with a dropdown
    question.
    book.html built the dropdown's placeholder with .T inside the questions
    range, where the dot is the question rather than the page, so the template aborted
    partway through writing the response. The result was a 200 with correct headers and a
    truncated body
    : everything up to the dropdown was present and the calendar, the slot
    picker and every script were silently missing, so the event type could not be booked at
    all. Introduced in 0.3.0 with the i18n work and not caught because no test rendered a
    select question.

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 23 Aug 20:24

Added

  • Email can now be delivered over Resend's HTTPS API instead of SMTP. Set a Resend
    API key under Settings → Email and mail goes out over port 443. This exists because
    several hosting platforms block outbound SMTP on their cheaper plans (Railway below
    Pro among them) by dropping the packets rather than refusing the connection - which
    looks like a hang, then like a wrong password, and cannot be fixed by changing any SMTP
    setting. Ports 25/465/587/2525 are all affected and it is not provider-specific.
  • The transport follows the credentials you supply: an API key selects HTTPS, otherwise
    SMTP, otherwise nothing. It does not probe and silently switch. Settings → Email
    badges which path is actually live, so filled-in SMTP fields are never mistaken for SMTP
    delivery, and "Remove key" switches back.

Security

  • All three image uploads now check dimensions before decoding. The 5 MB body limit
    bounds bytes on the wire, not pixels: a highly compressed PNG of 30000x30000 is a few
    hundred KB and decodes to gigabytes. Both the logo and banner endpoints now read the
    image header first and reject anything over 25 megapixels. The branding logo and banner
    are admin-only, but the user avatar upload is not - any authenticated member could
    send a ~160 KB file that decoded to hundreds of megabytes, and an out-of-memory kill
    takes down the process holding the single SQLite connection. It did not need a malicious
    user either: a genuine large camera photo is well under 5 MB compressed.

Fixed

  • Checkbox answers in the admin bookings list are matched liberally. Answers are
    canonicalised to yes/no on the way in, but rows created before that landed hold
    whatever the surface sent (the embed widget sent Yes), and a strict comparison rendered
    those as No - the opposite of what the guest ticked, which matters for consent
    checkboxes. Historic rows now display correctly without rewriting stored data.
  • Branding uploads read the content-type sniff buffer with io.ReadFull. A short read
    could hand the sniffer a truncated prefix and reject a valid image.
  • A failed SMTP dial could hang for ~2 minutes. defaultSMTPTimeout was applied only
    after the connection was established, so the dial itself fell back to the OS SYN-retry
    limit. Against a host that drops SMTP packets this stalled the background job queue,
    which shares a single SQLite connection, delaying every queued email behind it; the
    email test button also appeared to hang rather than fail.
  • The email test button now explains failures instead of reporting "failed to send test
    email". An unreachable server names the platform-block possibility and points at the API
    key; a timeout after connecting points at the port/TLS mode; provider rejections are
    shown verbatim.

v0.3.0

Choose a tag to compare

@github-actions github-actions released this 20 Aug 07:25

Added

  • Multi-language public surfaces (8 locales). The booking page, the manage
    (reschedule/cancel) page, the embed widget, all four emails, the calendar invite
    title/description, and the conversational booking assistant are now translated into
    English, Spanish, French, German, Italian, Portuguese, Dutch and Swedish. The
    locale is negotiated from Accept-Language (so de-AT resolves to de), overridable
    by a footer language switcher (?lang=), with an operator-configurable fallback
    language in Settings → Branding for visitors whose language is not shipped.
  • The booker's locale is stored on the booking (migration 00051), so later emails -
    reminders, cancellations, reschedule notices - arrive in the language they booked in
    rather than the language of whoever triggered the send. Host-facing sends stay English.
  • Editable assistant greeting (migration 00052) and fallback-language setting
    (migration 00053).
  • Adding a language requires no code change - dropping
    internal/i18n/locales/<code>.json in place is the entire task; the switcher, the
    fallback dropdown and the public API payload all read SupportedLocales(). See
    docs/ARCHITECTURE.md §23.

Fixed

  • Paid (Stripe) bookings always sent English email regardless of the language the booker
    used, because the confirmation query did not select the stored attendee locale.
  • Required checkboxes were not enforced. A custom question of type checkbox marked
    required could be submitted unticked, on both the booking page and the embed widget.
    Now enforced client- and server-side, and the stored answer is canonicalised to
    yes/no instead of varying by surface.
  • The public event-type endpoint returned language-dependent content without a Vary
    header, so a shared cache could serve one visitor's language to another.

Notes

  • Non-English translations are LLM drafts without native review. Structure is
    verified in CI (key parity, printf-verb parity, and a CLDR cross-check of the date
    tables against Intl); wording is not. Corrections via PR are welcome.
  • The built-in video room and the admin UI remain English-only.

v0.2.3

Choose a tag to compare

@github-actions github-actions released this 18 Aug 03:24

Security

  • Bumped the Go toolchain from 1.26.5 to 1.26.6, closing 8 known stdlib CVEs
    (net/http, encoding/xml, encoding/asn1, golang.org/x/net/idna) that were
    reachable from Calnode's own code paths (CalDAV free/busy parsing, DB schema
    version checks, Zoom/Google HTTP clients).
  • Bumped golang.org/x/image to v0.45.0, closing a VP8L (WebP) decode
    memory-exhaustion CVE (GO-2026-6222) reachable through the branding logo/banner
    upload endpoints, which accept WebP images.

Added

  • Banner option on the Branding settings page. Same upload/crop/opacity flow
    as the logo, shown full width below the logo (matching the email content
    container and the public booking form's width) on the booking page, manage
    page, and confirmation emails. Hidden entirely when not set; independent of the
    logo (either, both, or neither can be shown).
  • A small link to the GitHub releases page in the admin sidebar footer, so
    self-hosted operators always have an easy way to check what version they're
    running against. The released Docker image now stamps its actual version at
    build time (-ldflags -X buildinfo.Version=...), which it previously didn't -
    every image, including past tagged releases, reported "dev".