mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-24 20:09:03 +02:00
Merge remote-tracking branch 'origin/main' into feat/custom-fields
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user