diff --git a/backend/go.mod b/backend/go.mod index 28b6a327..6f993d63 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -164,7 +164,7 @@ require ( golang.org/x/sys v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect - google.golang.org/grpc v1.79.1 // indirect + google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.68.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 4486468e..0fe05340 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -470,8 +470,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1: google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/internal/bootstrap/app_images_bootstrap.go b/backend/internal/bootstrap/app_images_bootstrap.go index fb8d6fe5..0d026d08 100644 --- a/backend/internal/bootstrap/app_images_bootstrap.go +++ b/backend/internal/bootstrap/app_images_bootstrap.go @@ -12,6 +12,7 @@ import ( "log/slog" "os" "path" + "strings" "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -76,6 +77,18 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) dstNameToExt[nameWithoutExt] = ext } + initedPath := path.Join("application-images", ".inited") + if _, _, err := fileStorage.Open(ctx, initedPath); err == nil { + return dstNameToExt, nil + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to read .inited: %w", err) + } else { + err := fileStorage.Save(ctx, initedPath, strings.NewReader("")) + if err != nil { + return nil, fmt.Errorf("failed to store .inited: %w", err) + } + } + // Copy images from the images directory to the application-images directory if they don't already exist for _, sourceFile := range sourceFiles { if sourceFile.IsDir() { diff --git a/backend/internal/bootstrap/db_bootstrap.go b/backend/internal/bootstrap/db_bootstrap.go index 4fb7e87a..291d3961 100644 --- a/backend/internal/bootstrap/db_bootstrap.go +++ b/backend/internal/bootstrap/db_bootstrap.go @@ -34,7 +34,8 @@ func NewDatabase() (db *gorm.DB, err error) { } // Run migrations - if err := utils.MigrateDatabase(sqlDb); err != nil { + err = utils.MigrateDatabase(sqlDb) + if err != nil { return nil, fmt.Errorf("failed to run migrations: %w", err) } @@ -42,7 +43,10 @@ func NewDatabase() (db *gorm.DB, err error) { } func ConnectDatabase() (db *gorm.DB, err error) { - var dialector gorm.Dialector + var ( + dialector gorm.Dialector + sqliteNetworkFilesystem bool + ) // Choose the correct database provider var onConnFn func(conn *sql.DB) @@ -63,6 +67,14 @@ func ConnectDatabase() (db *gorm.DB, err error) { if err := ensureSqliteDatabaseDir(dbPath); err != nil { return nil, err } + + sqliteNetworkFilesystem, err = utils.IsNetworkedFileSystem(filepath.Dir(dbPath)) + if err != nil { + // Log the error only + slog.Warn("Failed to detect filesystem type for the SQLite database directory", slog.String("path", filepath.Dir(dbPath)), slog.Any("error", err)) + } else if sqliteNetworkFilesystem { + slog.Warn("⚠️⚠️⚠️ SQLite databases should not be stored on a networked file system like NFS, SMB, or FUSE, as there's a risk of crashes and even database corruption", slog.String("path", filepath.Dir(dbPath))) + } } // Before we connect, also make sure that there's a temporary folder for SQLite to write its data diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go index e177c7ee..a4e401ee 100644 --- a/backend/internal/controller/well_known_controller.go +++ b/backend/internal/controller/well_known_controller.go @@ -91,6 +91,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) { "id_token_signing_alg_values_supported": []string{alg.String()}, "authorization_response_iss_parameter_supported": true, "code_challenge_methods_supported": []string{"plain", "S256"}, + "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"}, } return json.Marshal(config) } diff --git a/backend/internal/dto/signup_dto.go b/backend/internal/dto/signup_dto.go index f2ab2c55..a31d3e93 100644 --- a/backend/internal/dto/signup_dto.go +++ b/backend/internal/dto/signup_dto.go @@ -1,7 +1,7 @@ package dto type SignUpDto struct { - Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` + Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"` Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"` LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go index 000b5381..e337528f 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -23,7 +23,7 @@ type UserDto struct { } type UserCreateDto struct { - Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` + Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"` Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` EmailVerified bool `json:"emailVerified"` FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"` diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go index 135706fa..a40973e0 100644 --- a/backend/internal/dto/validations.go +++ b/backend/internal/dto/validations.go @@ -13,7 +13,8 @@ import ( // [a-zA-Z0-9] : The username must start with an alphanumeric character // [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols // [a-zA-Z0-9]$ : The username must end with an alphanumeric character -var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$") +// (...)? : This allows single-character usernames (just one alphanumeric character) +var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$") var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$") diff --git a/backend/internal/dto/validations_test.go b/backend/internal/dto/validations_test.go index f6449068..b91b3292 100644 --- a/backend/internal/dto/validations_test.go +++ b/backend/internal/dto/validations_test.go @@ -20,6 +20,7 @@ func TestValidateUsername(t *testing.T) { {"starts with symbol", ".username", false}, {"ends with non-alphanumeric", "username-", false}, {"contains space", "user name", false}, + {"valid single char", "a", true}, {"empty", "", false}, {"only special chars", "-._@", false}, {"valid long", "a1234567890_b.c-d@e", true}, diff --git a/backend/internal/service/geolite_service.go b/backend/internal/service/geolite_service.go index 66a2a5f0..9378af20 100644 --- a/backend/internal/service/geolite_service.go +++ b/backend/internal/service/geolite_service.go @@ -14,6 +14,7 @@ import ( "net/netip" "os" "path/filepath" + "strings" "sync" "time" @@ -112,7 +113,11 @@ func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error { } slog.Info("Updating GeoLite2 City database") - downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey) + + downloadUrl := common.EnvConfig.GeoLiteDBUrl + if strings.Contains(downloadUrl, "%s") { + downloadUrl = fmt.Sprintf(downloadUrl, common.EnvConfig.MaxMindLicenseKey) + } ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute) defer cancel() diff --git a/backend/internal/utils/networked_filesystem_linux.go b/backend/internal/utils/networked_filesystem_linux.go new file mode 100644 index 00000000..31afd2fa --- /dev/null +++ b/backend/internal/utils/networked_filesystem_linux.go @@ -0,0 +1,35 @@ +//go:build linux + +package utils + +import ( + "fmt" + "syscall" +) + +// Filesystem magic values from Linux's include/uapi/linux/magic.h, used by statfs(2). +const ( + nfsSuperMagic = 0x6969 + smbSuperMagic = 0x517b + cifsSuperMagic = 0xff534d42 + fuseSuperMagic = 0x65735546 +) + +// IsNetworkedFileSystem reports whether path is on a filesystem that is known to be unsafe for SQLite, specifically NFS, SMB/CIFS, or FUSE mounts. +func IsNetworkedFileSystem(path string) (bool, error) { + var statfs syscall.Statfs_t + err := syscall.Statfs(path, &statfs) + if err != nil { + return false, fmt.Errorf("error executing statfs syscall: %w", err) + } + + // Statfs_t.Type is arch-dependent (for example, int32 on some systems and int64 on others). + // Normalize through uint32 first so signed values still preserve the Linux bit pattern for magic numbers such as CIFS (0xff534d42), then compare in a wide unsigned form. + //nolint:gosec + switch uint64(uint32(statfs.Type)) { + case nfsSuperMagic, smbSuperMagic, cifsSuperMagic, fuseSuperMagic: + return true, nil + default: + return false, nil + } +} diff --git a/backend/internal/utils/networked_filesystem_nonlinux.go b/backend/internal/utils/networked_filesystem_nonlinux.go new file mode 100644 index 00000000..386f5539 --- /dev/null +++ b/backend/internal/utils/networked_filesystem_nonlinux.go @@ -0,0 +1,8 @@ +//go:build !linux + +package utils + +// IsNetworkedFileSystem returns false on non-Linux systems because this detection is only used for Linux-specific statfs(2) filesystem magic values. +func IsNetworkedFileSystem(string) (bool, error) { + return false, nil +} diff --git a/frontend/messages/no.json b/frontend/messages/no.json index 5ddff904..ecb72aa6 100644 --- a/frontend/messages/no.json +++ b/frontend/messages/no.json @@ -102,7 +102,7 @@ "see_your_recent_account_activities": "See your account activities within the configured retention period.", "time": "Time", "event": "Event", - "approximate_location": "Approximate Location", + "approximate_location": "Omtrentlig plassering", "ip_address": "IP adresse", "device": "Enhet", "client": "Klient", diff --git a/frontend/src/lib/utils/zod-util.ts b/frontend/src/lib/utils/zod-util.ts index 7bccb5ef..fe3aa45b 100644 --- a/frontend/src/lib/utils/zod-util.ts +++ b/frontend/src/lib/utils/zod-util.ts @@ -31,7 +31,7 @@ export const callbackUrlSchema = z export const usernameSchema = z .string() - .min(2) + .min(1) .max(30) .regex(/^[a-zA-Z0-9]/, m.username_must_start_with()) .regex(/[a-zA-Z0-9]$/, m.username_must_end_with()) diff --git a/tests/specs/account-settings.spec.ts b/tests/specs/account-settings.spec.ts index 33e12495..b5da2213 100644 --- a/tests/specs/account-settings.spec.ts +++ b/tests/specs/account-settings.spec.ts @@ -66,7 +66,7 @@ test('Change Locale', async ({ page }) => { // Check if the validation messages are translated because they are provided by Zod await page.getByRole('textbox', { name: 'Gebruikersnaam' }).fill(''); await page.getByRole('button', { name: 'Opslaan' }).click(); - await expect(page.getByText('Te kort: verwacht dat string >=2 tekens heeft')).toBeVisible(); + await expect(page.getByText('Te kort: verwacht dat string >=1 tekens heeft')).toBeVisible(); // Clear all cookies and sign in again to check if the language is still set to Dutch await page.context().clearCookies(); @@ -76,7 +76,7 @@ test('Change Locale', async ({ page }) => { await page.getByRole('textbox', { name: 'Gebruikersnaam' }).fill(''); await page.getByRole('button', { name: 'Opslaan' }).click(); - await expect(page.getByText('Te kort: verwacht dat string >=2 tekens heeft')).toBeVisible(); + await expect(page.getByText('Te kort: verwacht dat string >=1 tekens heeft')).toBeVisible(); }); test('Add passkey to an account', async ({ page }) => { diff --git a/tests/specs/cli.spec.ts b/tests/specs/cli.spec.ts index e6116575..8b1733f8 100644 --- a/tests/specs/cli.spec.ts +++ b/tests/specs/cli.spec.ts @@ -109,7 +109,7 @@ function compareExports(dir1: string, dir2: string): void { const hashes2 = hashAllFiles(dir2); const files1 = Object.keys(hashes1).sort(); - const files2 = Object.keys(hashes2).sort(); + const files2 = Object.keys(hashes2).sort().filter(p => !p.includes('.inited')); expect(files2).toEqual(files1); for (const file of files1) {