Merge remote-tracking branch 'origin/main' into feat/custom-fields

This commit is contained in:
Elias Schneider
2026-05-31 16:12:20 +02:00
116 changed files with 1023 additions and 565 deletions
+1
View File
@@ -10,6 +10,7 @@ require (
github.com/aws/smithy-go v1.25.1
github.com/caarlos0/env/v11 v11.4.1
github.com/cenkalti/backoff/v5 v5.0.3
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1
+2
View File
@@ -65,6 +65,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+74 -40
View File
@@ -15,6 +15,7 @@ import (
"sync/atomic"
"time"
"github.com/coreos/go-systemd/activation"
"github.com/fsnotify/fsnotify"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin"
@@ -162,24 +163,26 @@ func initServer(r *gin.Engine) (*serverConfig, error) {
return nil, err
}
network, addr := listenerNetworkAndAddr()
listener, err := net.Listen(network, addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
var socketFn func() (*socket, error)
switch {
case common.EnvConfig.SystemdSocket:
socketFn = systemdSocket
case common.EnvConfig.UnixSocket != "":
socketFn = unixSocket
default:
socketFn = tcpSocket
}
if err := setUnixSocketMode(network, addr); err != nil {
listener.Close()
socket, err := socketFn()
if err != nil {
return nil, err
}
return &serverConfig{
addr: addr,
certProvider: certProvider,
listener: listener,
server: newHTTPServer(r, protocols),
tlsConfig: tlsConfig,
}, nil
addr := socket.addr
listener := socket.listener
server := newHTTPServer(r, protocols)
return &serverConfig{addr, certProvider, listener, server, tlsConfig}, nil
}
func initServerProtocols() (*http.Protocols, *tls.Config, *tlsCertProvider, error) {
@@ -207,6 +210,64 @@ func initServerProtocols() (*http.Protocols, *tls.Config, *tlsCertProvider, erro
return protocols, tlsConfig, certProvider, nil
}
type socket struct {
addr string
listener net.Listener
}
func systemdSocket() (*socket, error) {
listeners, err := activation.Listeners()
if err != nil {
return nil, fmt.Errorf("failed to receive socket from systemd: %w", err)
}
if len(listeners) == 0 {
return nil, errors.New("did not receive any sockets from systemd")
}
if len(listeners) > 1 {
return nil, errors.New("received too many sockets from systemd")
}
return &socket{"(systemd)", listeners[0]}, nil
}
func unixSocket() (*socket, error) {
addr := common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
listener, err := net.Listen("unix", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create UNIX socket: %w", err)
}
if common.EnvConfig.UnixSocketMode != "" {
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
if err != nil {
listener.Close()
return nil, fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
listener.Close()
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
}
return &socket{addr, listener}, nil
}
func tcpSocket() (*socket, error) {
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
listener, err := net.Listen("tcp", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create TCP socket: %w", err)
}
return &socket{addr, listener}, nil
}
func newHTTPServer(r *gin.Engine, protocols *http.Protocols) *http.Server {
return &http.Server{
MaxHeaderBytes: 1 << 20,
@@ -226,33 +287,6 @@ func newHTTPServer(r *gin.Engine, protocols *http.Protocols) *http.Server {
}
}
func listenerNetworkAndAddr() (string, string) {
if common.EnvConfig.UnixSocket == "" {
return "tcp", net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
}
addr := common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
return "unix", addr
}
func setUnixSocketMode(network, addr string) error {
if network != "unix" || common.EnvConfig.UnixSocketMode == "" {
return nil
}
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
if err != nil {
return fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
return fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
return nil
}
func runServer(ctx context.Context, config *serverConfig) error {
slog.Info("Server listening", slog.String("addr", config.addr), slog.Bool("tls", config.tlsConfig != nil))
+55 -32
View File
@@ -2,9 +2,12 @@ package cmds
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
@@ -13,8 +16,14 @@ import (
)
type healthcheckFlags struct {
Endpoint string
Verbose bool
Endpoint string
UnixSocket string
Verbose bool
}
type healthcheckResult struct {
StatusCode int
URL string
}
func init() {
@@ -29,47 +38,26 @@ func init() {
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
defer cancel()
url := flags.Endpoint + "/healthz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if flags.UnixSocket == "" && !cmd.Flags().Changed("endpoint") {
flags.UnixSocket = common.EnvConfig.UnixSocket
}
result, err := healthcheck(ctx, flags)
if err != nil {
slog.ErrorContext(ctx,
"Failed to create request object",
"Healthcheck failed",
"error", err,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
slog.ErrorContext(ctx,
"Failed to perform request",
"error", err,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
if err != nil {
slog.ErrorContext(ctx,
"Healthcheck failed",
"status", res.StatusCode,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
}
if flags.Verbose {
slog.InfoContext(ctx,
"Healthcheck succeeded",
"status", res.StatusCode,
"url", url,
"status", result.StatusCode,
"url", result.URL,
"unixSocket", flags.UnixSocket,
"ms", time.Since(start).Milliseconds(),
)
}
@@ -77,7 +65,42 @@ func init() {
}
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID")
healthcheckCmd.Flags().StringVar(&flags.UnixSocket, "unix-socket", "", "UNIX socket path for Pocket ID")
healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode")
rootCmd.AddCommand(healthcheckCmd)
}
func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResult, error) {
url := strings.TrimRight(flags.Endpoint, "/") + "/healthz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request object for %q: %w", url, err)
}
client := http.DefaultClient
if flags.UnixSocket != "" {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := net.Dialer{}
return dialer.DialContext(ctx, "unix", flags.UnixSocket)
}
client = &http.Client{Transport: transport}
}
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to perform request to %q: %w", url, err)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("unexpected status %d from %q", res.StatusCode, url)
}
return &healthcheckResult{
StatusCode: res.StatusCode,
URL: url,
}, nil
}
+79
View File
@@ -0,0 +1,79 @@
package cmds
import (
"context"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHealthcheckTCPSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/healthz", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
result, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: server.URL,
})
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, result.StatusCode)
require.Equal(t, server.URL+"/healthz", result.URL)
}
func TestHealthcheckFailsOnUnexpectedStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
_, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: server.URL,
})
require.Error(t, err)
require.ErrorContains(t, err, "unexpected status 500")
}
func TestHealthcheckUnixSocket(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "pocket-id.sock")
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "unix", socketPath)
require.NoError(t, err)
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/healthz", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}),
ReadHeaderTimeout: time.Second,
}
errCh := make(chan error, 1)
go func() {
errCh <- server.Serve(listener)
}()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
require.NoError(t, server.Shutdown(ctx))
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
})
result, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: "http://localhost:1411",
UnixSocket: socketPath,
})
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, result.StatusCode)
require.Equal(t, "http://localhost:1411/healthz", result.URL)
}
+4
View File
@@ -68,6 +68,7 @@ type EnvConfigSchema struct {
Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
SystemdSocket bool `env:"SYSTEMD_SOCKET"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
TLSCertFile string `env:"TLS_CERT" options:"file"`
@@ -153,6 +154,9 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
if err := validateFileBackend(config); err != nil {
return err
}
if config.SystemdSocket && config.UnixSocket != "" {
return errors.New("SYSTEMD_SOCKET and UNIX_SOCKET are mutually exclusive")
}
if err := validateLocalIPv6Ranges(config.LocalIPv6Ranges); err != nil {
return err
}
+2 -2
View File
@@ -124,11 +124,11 @@ func ValidateCallbackURLPattern(raw string) bool {
}
// ValidateResponseMode validates response_mode parameter
// If responseMode is present, it must be "form_post" or "query"
// If responseMode is present, it must be "form_post", "query", or "fragment"
// Empty responseMode is allowed (field not provided, use default)
func ValidateResponseMode(responseMode string) bool {
switch responseMode {
case "form_post", "query":
case "form_post", "query", "fragment":
return true
case "":
return true
+2 -1
View File
@@ -102,8 +102,9 @@ func TestValidateResponseMode(t *testing.T) {
}{
{"valid form_post", "form_post", true},
{"valid query", "query", true},
{"valid fragment", "fragment", true},
{"valid empty", "", true},
{"invalid fragment", "fragment", false},
{"invalid unknown", "unknown", false},
}
for _, tt := range tests {
@@ -54,7 +54,7 @@ func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) {
img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255})
// Load the font
fontBytes, err := resources.FS.ReadFile("fonts/PlayfairDisplay-Bold.ttf")
fontBytes, err := resources.FS.ReadFile("fonts/Gloock.ttf")
if err != nil {
return nil, fmt.Errorf("failed to read font file: %w", err)
}
@@ -84,7 +84,7 @@ func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) {
// Center the initials
x := (profilePictureSize - font.MeasureString(face, initials).Ceil()) / 2
y := (profilePictureSize-face.Metrics().Height.Ceil())/2 + face.Metrics().Ascent.Ceil() - 10
y := (profilePictureSize-face.Metrics().Height.Ceil())/2 + face.Metrics().Ascent.Ceil()
drawer.Dot = fixed.P(x, y)
// Draw the initials
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.