diff --git a/pkg/routes/api/v2/huma.go b/pkg/routes/api/v2/huma.go index 7a7dc3514..9c674c1a7 100644 --- a/pkg/routes/api/v2/huma.go +++ b/pkg/routes/api/v2/huma.go @@ -104,6 +104,14 @@ func NewAPI(e *echo.Echo, g *echo.Group) huma.API { Scheme: "bearer", Description: "Vikunja API token (tk_ prefix) with scoped permissions. Created via /api/v1/tokens.", } + // HTTP Basic, used only by the notifications Atom feed: feed readers can't + // carry a bearer header, so the feed accepts the API token as the Basic + // password (username = token owner). See notifications_feed.go. + oapi.Components.SecuritySchemes["BasicAuth"] = &huma.SecurityScheme{ + Type: "http", + Scheme: "basic", + Description: "HTTP Basic auth used by the notifications Atom feed: the username is the token owner and the password is a feeds-scoped Vikunja API token (tk_ prefix).", + } // Applied globally; public endpoints (spec, docs) opt out with an empty Security list. oapi.Security = []map[string][]string{ {"JWTKeyAuth": {}}, diff --git a/pkg/routes/api/v2/notifications_feed.go b/pkg/routes/api/v2/notifications_feed.go new file mode 100644 index 000000000..d6195def2 --- /dev/null +++ b/pkg/routes/api/v2/notifications_feed.go @@ -0,0 +1,103 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/modules/humaecho5" + "code.vikunja.io/api/pkg/routes/feeds" + + "github.com/danielgtaylor/huma/v2" + "github.com/labstack/echo/v5" +) + +// RegisterNotificationsFeedRoutes wires the Atom notifications feed onto the +// Huma API. It documents HTTP Basic auth (a feeds-scoped API token) because +// feed readers can't carry a bearer header. +func RegisterNotificationsFeedRoutes(api huma.API) { + Register(api, huma.Operation{ + OperationID: "notifications-atom-feed", + Summary: "Notifications Atom feed", + Description: "Returns the authenticated user's latest notifications as an Atom feed. Authenticated with HTTP Basic auth: the username is the token owner and the password is a feeds-scoped Vikunja API token (tk_ prefix) — password and LDAP credentials are rejected because feed URLs are commonly shared or cached. Fetching the feed does not mark notifications as read.", + Method: http.MethodGet, + Path: "/notifications.atom", + Tags: []string{"service"}, + // This op carries its own HTTP Basic auth instead of the global bearer + // schemes; the path is in unauthenticatedAPIPaths so the JWT middleware + // lets it through and the handler authenticates itself. + Security: []map[string][]string{{"BasicAuth": {}}}, + Responses: map[string]*huma.Response{ + "200": { + Description: "The notifications Atom feed.", + Content: map[string]*huma.MediaType{ + "application/atom+xml": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, notificationsAtomFeed) +} + +func init() { AddRouteRegistrar(RegisterNotificationsFeedRoutes) } + +// notificationsAtomFeed authenticates with HTTP Basic (sharing the feeds +// validator) and streams the Atom feed; there is no handler.Do* for a non-JSON +// body and the auth can't ride the group's JWT middleware. +func notificationsAtomFeed(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { + c, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context) + if !ok { + return nil, huma.Error500InternalServerError("could not resolve request context") + } + + username, password, ok := (*c).Request().BasicAuth() + if !ok { + return nil, basicAuthChallenge(c) + } + + s := db.NewSession() + defer s.Close() + + u, err := feeds.AuthenticateFeedToken(s, username, password) + if err != nil { + return nil, translateDomainError(err) + } + if u == nil { + return nil, basicAuthChallenge(c) + } + + atom, err := feeds.BuildNotificationsAtomFeed(s, u) + if err != nil { + return nil, translateDomainError(err) + } + + return &huma.StreamResponse{Body: func(hctx huma.Context) { + ec := humaecho5.Unwrap(hctx) + (*ec).Response().Header().Set(echo.HeaderContentType, feeds.AtomContentType) + _, _ = (*ec).Response().Write([]byte(atom)) + }}, nil +} + +// basicAuthChallenge returns a 401 carrying a WWW-Authenticate Basic challenge, +// mirroring v1's BasicAuth middleware so feed readers prompt for credentials. +func basicAuthChallenge(c *echo.Context) error { + (*c).Response().Header().Set(echo.HeaderWWWAuthenticate, `Basic realm="Restricted"`) + return huma.Error401Unauthorized(http.StatusText(http.StatusUnauthorized)) +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 089b9d246..5cb8cf1cb 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -368,6 +368,10 @@ var unauthenticatedAPIPaths = map[string]bool{ // Public infra healthcheck (a Huma op that opts out of the global auth). "/api/v2/health": true, + + // Atom feed (a Huma op) authenticates itself with HTTP Basic auth (a + // feeds-scoped API token), like its /feeds counterpart, not a JWT. + "/api/v2/notifications.atom": true, } // collectRoutesForAPITokens collects all routes for API token permission checking.