Compare commits

...

20 Commits

Author SHA1 Message Date
Elias Schneider
e9b2d981b7 release: 0.41.0 2025-03-18 21:04:53 +01:00
Kyle Mendell
8f146188d5 feat(profile-picture): allow reset of profile picture (#355)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-18 19:59:31 +00:00
Viktor Szépe
a0f93bda49 chor: correct misspellings (#352) 2025-03-18 12:54:39 +01:00
Savely Krasovsky
0423d354f5 fix: own avatar not loading (#351) 2025-03-18 12:02:59 +01:00
Elias Schneider
9245851126 release: 0.40.1 2025-03-16 18:02:49 +01:00
Alexander Lehmann
39b7f6678c fix: emails are considered as medium spam by rspamd (#337) 2025-03-16 17:46:45 +01:00
Elias Schneider
e45d9e970d fix: caching for own profile picture 2025-03-16 17:45:30 +01:00
Elias Schneider
8ead0be8cd fix: API keys not working if sqlite is used 2025-03-16 14:28:44 +01:00
Elias Schneider
9f28503d6c fix: remove custom claim key restrictions 2025-03-16 14:11:33 +01:00
Elias Schneider
26e05947fe ci/cd: add separate worfklow for unit tests 2025-03-16 13:08:56 +01:00
Alessandro (Ale) Segala
348192b9d7 fix: Fixes and performance improvements in utils package (#331) 2025-03-14 19:21:24 -05:00
Kyle Mendell
b483e2e92f fix: email logo icon displaying too big (#336) 2025-03-14 13:38:27 -05:00
Elias Schneider
42f55e6e54 release: 0.40.0 2025-03-13 20:49:48 +01:00
Elias Schneider
a4bfd08a0f chore: automatically detect release type in release script 2025-03-13 20:49:33 +01:00
Alessandro (Ale) Segala
7b654c6bd1 feat: allow setting path where keys are stored (#327) 2025-03-13 17:01:15 +01:00
Elias Schneider
8c1c04db1d Merge branch 'main' of https://github.com/pocket-id/pocket-id 2025-03-13 14:18:54 +01:00
Elias Schneider
ec4b41a1d2 fix(docker): missing write permissions on scripts 2025-03-13 14:18:48 +01:00
dependabot[bot]
d27a121985 chore(deps): bump @babel/runtime from 7.26.7 to 7.26.10 in /frontend in the npm_and_yarn group across 1 directory (#328)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-13 14:15:49 +01:00
dependabot[bot]
d8952c0d62 chore(deps): bump golang.org/x/net from 0.34.0 to 0.36.0 in /backend in the go_modules group across 1 directory (#326)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-13 14:06:43 +01:00
Nebula
f65997e85b chore: add Dev Container (#313) 2025-03-11 17:24:41 -05:00
42 changed files with 753 additions and 213 deletions

View File

@@ -0,0 +1,32 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "pocket-id",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
"features": {
"ghcr.io/devcontainers/features/go:1": {},
"ghcr.io/devcontainers-extra/features/caddy:1": {}
},
"customizations": {
"vscode": {
"extensions": [
"golang.go",
"svelte.svelte-vscode"
]
}
},
// Use 'postCreateCommand' to run commands after the container is created.
// Install npm dependencies for the frontend.
"postCreateCommand": "npm install --prefix frontend"
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View File

@@ -49,7 +49,7 @@ body:
required: false
attributes:
label: "Log Output"
description: "Output of log files when the issue occured to help us diagnose the issue."
description: "Output of log files when the issue occurred to help us diagnose the issue."
- type: markdown
attributes:
value: |

12
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for more information:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# https://containers.dev/guide/dependabot
version: 2
updates:
- package-ecosystem: "devcontainers"
directory: "/"
schedule:
interval: weekly

34
.github/workflows/unit-tests.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Unit Tests
on:
push:
branches: [main]
paths:
- "backend/**"
pull_request:
branches: [main]
paths:
- "backend/**"
jobs:
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'backend/go.mod'
cache-dependency-path: 'backend/go.sum'
- name: Install dependencies
working-directory: backend
run: |
go get ./...
- name: Run backend unit tests
working-directory: backend
run: |
go test -v ./... | tee /tmp/TestResults.log
- uses: actions/upload-artifact@v4
if: always()
with:
name: backend-unit-tests
path: /tmp/TestResults.log
retention-days: 15

3
.gitignore vendored
View File

@@ -48,3 +48,6 @@ pocket-id-backend
npm-debug.log*
yarn-debug.log*
yarn-error.log*
#Debug
backend/cmd/__debug_*

View File

@@ -1 +1 @@
0.39.0
0.41.0

42
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Backend",
"type": "go",
"request": "launch",
"envFile": "${workspaceFolder}/backend/.env.example",
"env": {
"APP_ENV": "development"
},
"mode": "debug",
"program": "${workspaceFolder}/backend/cmd/main.go",
},
{
"name": "Frontend",
"type": "node",
"request": "launch",
"envFile": "${workspaceFolder}/frontend/.env.example",
"cwd": "${workspaceFolder}/frontend",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev"
]
}
],
"compounds": [
{
"name": "Development",
"configurations": [
"Backend",
"Frontend"
],
"presentation": {
"hidden": false,
"group": "",
"order": 1
}
}
],
}

37
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,37 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "Run Caddy",
"type": "shell",
"command": "caddy run --config reverse-proxy/Caddyfile",
"isBackground": true,
"problemMatcher": {
"owner": "custom",
"pattern": [
{
"regexp": ".",
"file": 1,
"location": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": ".*",
"endsPattern": "Caddyfile.*"
}
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"runOptions": {
"runOn": "folderOpen",
"instanceLimit": 1
}
}
]
}

View File

@@ -1,3 +1,39 @@
## [](https://github.com/pocket-id/pocket-id/compare/v0.40.1...v) (2025-03-18)
### Features
* **profile-picture:** allow reset of profile picture ([#355](https://github.com/pocket-id/pocket-id/issues/355)) ([8f14618](https://github.com/pocket-id/pocket-id/commit/8f146188d57b5c08a4c6204674c15379232280d8))
### Bug Fixes
* own avatar not loading ([#351](https://github.com/pocket-id/pocket-id/issues/351)) ([0423d35](https://github.com/pocket-id/pocket-id/commit/0423d354f533d2ff4fd431859af3eea7d4d7044f))
## [](https://github.com/pocket-id/pocket-id/compare/v0.40.0...v) (2025-03-16)
### Bug Fixes
* API keys not working if sqlite is used ([8ead0be](https://github.com/pocket-id/pocket-id/commit/8ead0be8cd0cfb542fe488b7251cfd5274975ae1))
* caching for own profile picture ([e45d9e9](https://github.com/pocket-id/pocket-id/commit/e45d9e970d327a5120ff9fb0c8d42df8af69bb38))
* email logo icon displaying too big ([#336](https://github.com/pocket-id/pocket-id/issues/336)) ([b483e2e](https://github.com/pocket-id/pocket-id/commit/b483e2e92fdb528e7de026350a727d6970227426))
* emails are considered as medium spam by rspamd ([#337](https://github.com/pocket-id/pocket-id/issues/337)) ([39b7f66](https://github.com/pocket-id/pocket-id/commit/39b7f6678c98cadcdc3abfbcb447d8eb0daa9eb0))
* Fixes and performance improvements in utils package ([#331](https://github.com/pocket-id/pocket-id/issues/331)) ([348192b](https://github.com/pocket-id/pocket-id/commit/348192b9d7e2698add97810f8fba53d13d0df018))
* remove custom claim key restrictions ([9f28503](https://github.com/pocket-id/pocket-id/commit/9f28503d6c73d3521d1309bee055704a0507e9b5))
## [](https://github.com/pocket-id/pocket-id/compare/v0.39.0...v) (2025-03-13)
### Features
* allow setting path where keys are stored ([#327](https://github.com/pocket-id/pocket-id/issues/327)) ([7b654c6](https://github.com/pocket-id/pocket-id/commit/7b654c6bd111ddcddd5e3450cbf326d9cf1777b6))
### Bug Fixes
* **docker:** missing write permissions on scripts ([ec4b41a](https://github.com/pocket-id/pocket-id/commit/ec4b41a1d26ea00bb4a95f654ac4cc745b2ce2e8))
## [](https://github.com/pocket-id/pocket-id/compare/v0.38.0...v) (2025-03-11)

View File

@@ -31,8 +31,15 @@ Before you submit the pull request for review please ensure that
- You run `npm run format` to format the code
## Setup project
Pocket ID consists of a frontend, backend and a reverse proxy. There are two ways to get the development environment setup:
Pocket ID consists of a frontend, backend and a reverse proxy.
## 1. Using DevContainers
1. Make sure you have [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension installed
2. Clone and open the repo in VS Code
3. VS Code will detect .devcontainer and will prompt you to open the folder in devcontainer
4. If the auto prompt does not work, hit `F1` and select `Dev Containers: Open Folder in Container.`, then select the pocket-id repo root folder and it'll open in container.
## 2. Manual
### Backend
@@ -63,6 +70,10 @@ Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
You're all set!
## Debugging
1. The VS Code is currently setup to auto launch caddy on opening the folder. (Defined in [tasks.json](.vscode/tasks.json))
2. Press `F5` to start a debug session. This will launch both frontend and backend and attach debuggers to those process. (Defined in [launch.json](.vscode/launch.json))
### Testing
We are using [Playwright](https://playwright.dev) for end-to-end testing.

View File

@@ -35,7 +35,7 @@ COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
COPY ./scripts ./scripts
RUN chmod +x ./scripts/*.sh
RUN chmod +x ./scripts/**/*.sh
EXPOSE 80
ENV APP_ENV=production

View File

@@ -20,7 +20,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
golang.org/x/crypto v0.32.0
golang.org/x/crypto v0.35.0
golang.org/x/image v0.24.0
golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11
@@ -69,9 +69,9 @@ require (
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.13.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/net v0.36.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

View File

@@ -217,8 +217,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -240,8 +240,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -263,8 +263,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=

View File

@@ -23,6 +23,7 @@ type EnvConfigSchema struct {
SqliteDBPath string `env:"SQLITE_DB_PATH"`
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"`
UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"`
Port string `env:"BACKEND_PORT"`
Host string `env:"HOST"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
@@ -37,6 +38,7 @@ var EnvConfig = &EnvConfigSchema{
SqliteDBPath: "data/pocket-id.db",
PostgresConnectionString: "",
UploadPath: "data/uploads",
KeysPath: "data/keys",
AppURL: "http://localhost",
Port: "8080",
Host: "0.0.0.0",
@@ -50,19 +52,21 @@ func init() {
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
log.Fatal(err)
}
// Validate the environment variables
if EnvConfig.DbProvider != DbProviderSqlite && EnvConfig.DbProvider != DbProviderPostgres {
switch EnvConfig.DbProvider {
case DbProviderSqlite:
if EnvConfig.SqliteDBPath == "" {
log.Fatal("Missing SQLITE_DB_PATH environment variable")
}
case DbProviderPostgres:
if EnvConfig.PostgresConnectionString == "" {
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable")
}
default:
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
}
if EnvConfig.DbProvider == DbProviderPostgres && EnvConfig.PostgresConnectionString == "" {
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable")
}
if EnvConfig.DbProvider == DbProviderSqlite && EnvConfig.SqliteDBPath == "" {
log.Fatal("Missing SQLITE_DB_PATH environment variable")
}
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
if err != nil {
log.Fatal("PUBLIC_APP_URL is not a valid URL")

View File

@@ -38,7 +38,7 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
group.GET("/users/me/profile-picture.png", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserProfilePictureHandler)
group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler)
group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler)
@@ -47,6 +47,9 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
}
type UserController struct {
@@ -249,24 +252,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
return
}
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
}
// getCurrentUserProfilePictureHandler godoc
// @Summary Get current user's profile picture
// @Description Retrieve the currently authenticated user's profile picture
// @Tags Users
// @Produce image/png
// @Success 200 {file} binary "PNG image"
// @Router /users/me/profile-picture.png [get]
func (uc *UserController) getCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
picture, size, err := uc.userService.GetProfilePicture(userID)
if err != nil {
c.Error(err)
return
}
c.Header("Cache-Control", "public, max-age=300")
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
}
@@ -497,3 +483,40 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
c.JSON(http.StatusOK, userDto)
}
// resetUserProfilePictureHandler godoc
// @Summary Reset user profile picture
// @Description Reset a specific user's profile picture to the default
// @Tags Users
// @Produce json
// @Param id path string true "User ID"
// @Success 204 "No Content"
// @Router /users/{id}/profile-picture [delete]
func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// resetCurrentUserProfilePictureHandler godoc
// @Summary Reset current user's profile picture
// @Description Reset the currently authenticated user's profile picture to the default
// @Tags Users
// @Produce json
// @Success 204 "No Content"
// @Router /users/me/profile-picture [delete]
func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -6,6 +6,6 @@ type CustomClaimDto struct {
}
type CustomClaimCreateDto struct {
Key string `json:"key" binding:"required,claimKey"`
Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"`
}

View File

@@ -16,22 +16,10 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
return matched
}
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain letters and numbers
regex := "^[A-Za-z0-9]*$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("username", validateUsername); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
}

View File

@@ -82,7 +82,7 @@ func (s *ApiKeyService) ValidateApiKey(apiKey string) (model.User, error) {
hashedKey := utils.CreateSha256Hash(apiKey)
if err := s.db.Preload("User").Where("key = ? AND expires_at > ?",
hashedKey, time.Now()).Preload("User").First(&key).Error; err != nil {
hashedKey, datatype.DateTime(time.Now())).Preload("User").First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, &common.InvalidAPIKeyError{}

View File

@@ -18,6 +18,8 @@ import (
"os"
ttemplate "text/template"
"time"
"github.com/google/uuid"
"strings"
)
type EmailService struct {
@@ -84,6 +86,29 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
c.AddHeaderRaw("Content-Type",
fmt.Sprintf("multipart/alternative;\n boundary=%s;\n charset=UTF-8", boundary),
)
c.AddHeader("MIME-Version", "1.0")
c.AddHeader("Date", time.Now().Format(time.RFC1123Z))
// to create a message-id, we need the FQDN of the sending server, but that may be a docker hostname or localhost
// so we use the domain of the from address instead (the same as Thunderbird does)
// if the address does not have an @ (which would be unusual), we use hostname
from_address := srv.appConfigService.DbConfig.SmtpFrom.Value
domain := ""
if strings.Contains(from_address, "@") {
domain = strings.Split(from_address, "@")[1]
} else {
hostname, err := os.Hostname()
if err != nil {
// can that happen? we just give up
return fmt.Errorf("failed to get own hostname: %w", err)
} else {
domain = hostname
}
}
c.AddHeader("Message-ID", "<" + uuid.New().String() + "@" + domain + ">")
c.Body(body)
// Connect to the SMTP server

View File

@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"log"
"math/big"
"os"
@@ -22,13 +23,12 @@ import (
)
const (
privateKeyPath = "data/keys/jwt_private_key.pem"
publicKeyPath = "data/keys/jwt_public_key.pem"
privateKeyFile = "jwt_private_key.pem"
)
type JwtService struct {
PublicKey *rsa.PublicKey
PrivateKey *rsa.PrivateKey
privateKey *rsa.PrivateKey
keyId string
appConfigService *AppConfigService
}
@@ -38,7 +38,7 @@ func NewJwtService(appConfigService *AppConfigService) *JwtService {
}
// Ensure keys are generated or loaded
if err := service.loadOrGenerateKeys(); err != nil {
if err := service.loadOrGenerateKey(common.EnvConfig.KeysPath); err != nil {
log.Fatalf("Failed to initialize jwt service: %v", err)
}
@@ -59,30 +59,39 @@ type JWK struct {
E string `json:"e"`
}
// loadOrGenerateKeys loads RSA keys from the given paths or generates them if they do not exist.
func (s *JwtService) loadOrGenerateKeys() error {
// loadOrGenerateKey loads RSA keys from the given paths or generates them if they do not exist.
func (s *JwtService) loadOrGenerateKey(keysPath string) error {
privateKeyPath := filepath.Join(keysPath, privateKeyFile)
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
if err := s.generateKeys(); err != nil {
return err
if err := s.generateKey(keysPath); err != nil {
return fmt.Errorf("can't generate key: %w", err)
}
}
privateKeyBytes, err := os.ReadFile(privateKeyPath)
if err != nil {
return errors.New("can't read jwt private key: " + err.Error())
return fmt.Errorf("can't read jwt private key: %w", err)
}
s.PrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
if err != nil {
return errors.New("can't parse jwt private key: " + err.Error())
return fmt.Errorf("can't parse jwt private key: %w", err)
}
publicKeyBytes, err := os.ReadFile(publicKeyPath)
err = s.SetKey(privateKey)
if err != nil {
return errors.New("can't read jwt public key: " + err.Error())
return fmt.Errorf("failed to set private key: %w", err)
}
s.PublicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
return nil
}
func (s *JwtService) SetKey(privateKey *rsa.PrivateKey) (err error) {
s.privateKey = privateKey
s.keyId, err = s.generateKeyID()
if err != nil {
return errors.New("can't parse jwt public key: " + err.Error())
return fmt.Errorf("can't generate key ID: %w", err)
}
return nil
@@ -100,20 +109,15 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
IsAdmin: user.IsAdmin,
}
kid, err := s.generateKeyID(s.PublicKey)
if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error())
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = kid
token.Header["kid"] = s.keyId
return token.SignedString(s.PrivateKey)
return token.SignedString(s.privateKey)
}
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
return s.PublicKey, nil
return &s.privateKey.PublicKey, nil
})
if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token")
@@ -146,15 +150,10 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
claims["nonce"] = nonce
}
kid, err := s.generateKeyID(s.PublicKey)
if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error())
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = kid
token.Header["kid"] = s.keyId
return token.SignedString(s.PrivateKey)
return token.SignedString(s.privateKey)
}
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
@@ -166,20 +165,15 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
Issuer: common.EnvConfig.AppURL,
}
kid, err := s.generateKeyID(s.PublicKey)
if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error())
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = kid
token.Header["kid"] = s.keyId
return token.SignedString(s.PrivateKey)
return token.SignedString(s.privateKey)
}
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
return s.PublicKey, nil
return &s.privateKey.PublicKey, nil
})
if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token")
@@ -195,7 +189,7 @@ func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.Registered
func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
return s.PublicKey, nil
return &s.privateKey.PublicKey, nil
}, jwt.WithIssuer(common.EnvConfig.AppURL))
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) {
@@ -212,32 +206,27 @@ func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, e
// GetJWK returns the JSON Web Key (JWK) for the public key.
func (s *JwtService) GetJWK() (JWK, error) {
if s.PublicKey == nil {
if s.privateKey == nil {
return JWK{}, errors.New("public key is not initialized")
}
kid, err := s.generateKeyID(s.PublicKey)
if err != nil {
return JWK{}, err
}
jwk := JWK{
Kid: kid,
Kid: s.keyId,
Kty: "RSA",
Use: "sig",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(s.PublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.PublicKey.E)).Bytes()),
N: base64.RawURLEncoding.EncodeToString(s.privateKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.privateKey.E)).Bytes()),
}
return jwk, nil
}
// GenerateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key.
func (s *JwtService) generateKeyID(publicKey *rsa.PublicKey) (string, error) {
pubASN1, err := x509.MarshalPKIXPublicKey(publicKey)
func (s *JwtService) generateKeyID() (string, error) {
pubASN1, err := x509.MarshalPKIXPublicKey(&s.privateKey.PublicKey)
if err != nil {
return "", errors.New("failed to marshal public key: " + err.Error())
return "", fmt.Errorf("failed to marshal public key: %w", err)
}
// Compute SHA-256 hash of the public key
@@ -252,29 +241,22 @@ func (s *JwtService) generateKeyID(publicKey *rsa.PublicKey) (string, error) {
return base64.RawURLEncoding.EncodeToString(shortHash), nil
}
// generateKeys generates a new RSA key pair and saves them to the specified paths.
func (s *JwtService) generateKeys() error {
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {
return errors.New("failed to create directories for keys: " + err.Error())
// generateKey generates a new RSA key and saves it to the specified path.
func (s *JwtService) generateKey(keysPath string) error {
if err := os.MkdirAll(keysPath, 0700); err != nil {
return fmt.Errorf("failed to create directories for keys: %w", err)
}
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return errors.New("failed to generate private key: " + err.Error())
return fmt.Errorf("failed to generate private key: %w", err)
}
s.PrivateKey = privateKey
privateKeyPath := filepath.Join(keysPath, privateKeyFile)
if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil {
return err
}
publicKey := &privateKey.PublicKey
s.PublicKey = publicKey
if err := s.savePEMKey(publicKeyPath, x509.MarshalPKCS1PublicKey(publicKey), "RSA PUBLIC KEY"); err != nil {
return err
}
return nil
}
@@ -282,7 +264,7 @@ func (s *JwtService) generateKeys() error {
func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) error {
keyFile, err := os.Create(path)
if err != nil {
return errors.New("failed to create key file: " + err.Error())
return fmt.Errorf("failed to create key file: %w", err)
}
defer keyFile.Close()
@@ -292,7 +274,7 @@ func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) er
})
if _, err := keyFile.Write(keyPEM); err != nil {
return errors.New("failed to write key file: " + err.Error())
return fmt.Errorf("failed to write key file: %w", err)
}
return nil

View File

@@ -336,8 +336,7 @@ wbeF6l05LexCkI7ShsOuSt+dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI=
block, _ := pem.Decode([]byte(privateKeyString))
privateKey, _ := x509.ParsePKCS1PrivateKey(block.Bytes)
s.jwtService.PrivateKey = privateKey
s.jwtService.PublicKey = &privateKey.PublicKey
s.jwtService.SetKey(privateKey)
}
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key

View File

@@ -365,3 +365,27 @@ func (s *UserService) checkDuplicatedFields(user model.User) error {
return nil
}
// ResetProfilePicture deletes a user's custom profile picture
func (s *UserService) ResetProfilePicture(userID string) error {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
return &common.InvalidUUIDError{}
}
// Build path to profile picture
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
// Check if file exists and delete it
if _, err := os.Stat(profilePicturePath); err == nil {
if err := os.Remove(profilePicturePath); err != nil {
return fmt.Errorf("failed to delete profile picture: %w", err)
}
} else if !os.IsNotExist(err) {
// If any error other than "file not exists"
return fmt.Errorf("failed to check if profile picture exists: %w", err)
}
// It's okay if the file doesn't exist - just means there's no custom picture to delete
return nil
}

View File

@@ -45,7 +45,11 @@ func genAddressHeader(name string, addresses []Address, maxLength int) string {
} else {
email = fmt.Sprintf("<%s>", addr.Email)
}
writeHeaderQ(hl, addr.Name)
if isPrintableASCII(addr.Name) {
writeHeaderAtom(hl, addr.Name)
} else {
writeHeaderQ(hl, addr.Name)
}
writeHeaderAtom(hl, " ")
writeHeaderAtom(hl, email)
}

View File

@@ -27,7 +27,7 @@ func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V])
return templateMap[template.Path]
}
type clonable[V pareseable[V]] interface {
type cloneable[V pareseable[V]] interface {
Clone() (V, error)
}
@@ -35,7 +35,7 @@ type pareseable[V any] interface {
ParseFS(fs.FS, ...string) (V, error)
}
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate clonable[V], suffix string) (V, error) {
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate cloneable[V], suffix string) (V, error) {
tmpl, err := rootTemplate.Clone()
if err != nil {
return *new(V), fmt.Errorf("clone root template: %w", err)

View File

@@ -5,14 +5,16 @@ import (
"mime/multipart"
"os"
"path/filepath"
"strings"
"github.com/pocket-id/pocket-id/backend/resources"
)
func GetFileExtension(filename string) string {
splitted := strings.Split(filename, ".")
return splitted[len(splitted)-1]
ext := filepath.Ext(filename)
if len(ext) > 0 && ext[0] == '.' {
return ext[1:]
}
return filename
}
func GetImageMimeType(ext string) string {

View File

@@ -0,0 +1,73 @@
package utils
import (
"testing"
)
func TestGetFileExtension(t *testing.T) {
tests := []struct {
name string
filename string
want string
}{
{
name: "Simple file with extension",
filename: "document.pdf",
want: "pdf",
},
{
name: "File with path",
filename: "/path/to/document.txt",
want: "txt",
},
{
name: "File with path (Windows style)",
filename: "C:\\path\\to\\document.jpg",
want: "jpg",
},
{
name: "Multiple extensions",
filename: "archive.tar.gz",
want: "gz",
},
{
name: "Hidden file with extension",
filename: ".config.json",
want: "json",
},
{
name: "Filename with dots",
filename: "version.1.2.3.txt",
want: "txt",
},
{
name: "File with uppercase extension",
filename: "image.JPG",
want: "JPG",
},
{
name: "File without extension",
filename: "README",
want: "README",
},
{
name: "Hidden file without extension",
filename: ".gitignore",
want: "gitignore",
},
{
name: "Empty filename",
filename: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetFileExtension(tt.filename)
if got != tt.want {
t.Errorf("GetFileExtension(%q) = %q, want %q", tt.filename, got, tt.want)
}
})
}
}

View File

@@ -90,7 +90,7 @@ func CreateDefaultProfilePicture(firstName, lastName string) (*bytes.Buffer, err
var buf bytes.Buffer
err = imaging.Encode(&buf, img, imaging.PNG)
if err != nil {
return nil, fmt.Errorf("failed to encode image: %v", err)
return nil, fmt.Errorf("failed to encode image: %w", err)
}
return &buf, nil

View File

@@ -2,8 +2,9 @@ package utils
import (
"crypto/rand"
"errors"
"fmt"
"math/big"
"io"
"net/url"
"regexp"
"strings"
@@ -13,23 +14,41 @@ import (
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
func GenerateRandomAlphanumericString(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
const charsetLength = int64(len(charset))
if length <= 0 {
return "", fmt.Errorf("length must be a positive integer")
return "", errors.New("length must be a positive integer")
}
result := make([]byte, length)
// The algorithm below is adapted from https://stackoverflow.com/a/35615565
const (
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
)
for i := range result {
num, err := rand.Int(rand.Reader, big.NewInt(charsetLength))
if err != nil {
return "", err
result := strings.Builder{}
result.Grow(length)
// Because we discard a bunch of bytes, we read more in the buffer to minimize the changes of performing additional IO
bufferSize := int(float64(length) * 1.3)
randomBytes := make([]byte, bufferSize)
for i, j := 0, 0; i < length; j++ {
// Fill the buffer if needed
if j%bufferSize == 0 {
_, err := io.ReadFull(rand.Reader, randomBytes)
if err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
}
// Discard bytes that are outside of the range
// This allows making sure that we maintain uniform distribution
idx := int(randomBytes[j%length] & letterIdxMask)
if idx < len(charset) {
result.WriteByte(charset[idx])
i++
}
result[i] = charset[num.Int64()]
}
return string(result), nil
return result.String(), nil
}
func GetHostnameFromURL(rawURL string) string {
@@ -45,30 +64,40 @@ func StringPointer(s string) *string {
return &s
}
func CapitalizeFirstLetter(s string) string {
if s == "" {
return s
func CapitalizeFirstLetter(str string) string {
if str == "" {
return ""
}
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
result := strings.Builder{}
result.Grow(len(str))
for i, r := range str {
if i == 0 {
result.WriteRune(unicode.ToUpper(r))
} else {
result.WriteRune(r)
}
}
return result.String()
}
func CamelCaseToSnakeCase(s string) string {
var result []rune
for i, r := range s {
func CamelCaseToSnakeCase(str string) string {
result := strings.Builder{}
result.Grow(int(float32(len(str)) * 1.1))
for i, r := range str {
if unicode.IsUpper(r) && i > 0 {
result = append(result, '_')
result.WriteByte('_')
}
result = append(result, unicode.ToLower(r))
result.WriteRune(unicode.ToLower(r))
}
return string(result)
return result.String()
}
var camelCaseToScreamingSnakeCaseRe = regexp.MustCompile(`([a-z0-9])([A-Z])`)
func CamelCaseToScreamingSnakeCase(s string) string {
// Insert underscores before uppercase letters (except the first one)
re := regexp.MustCompile(`([a-z0-9])([A-Z])`)
snake := re.ReplaceAllString(s, `${1}_${2}`)
snake := camelCaseToScreamingSnakeCaseRe.ReplaceAllString(s, `${1}_${2}`)
// Convert to uppercase
return strings.ToUpper(snake)

View File

@@ -0,0 +1,105 @@
package utils
import (
"regexp"
"testing"
)
func TestGenerateRandomAlphanumericString(t *testing.T) {
t.Run("valid length returns correct string", func(t *testing.T) {
const length = 10
str, err := GenerateRandomAlphanumericString(length)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if len(str) != length {
t.Errorf("Expected length %d, got %d", length, len(str))
}
matched, err := regexp.MatchString(`^[a-zA-Z0-9]+$`, str)
if err != nil {
t.Errorf("Regex match failed: %v", err)
}
if !matched {
t.Errorf("String contains non-alphanumeric characters: %s", str)
}
})
t.Run("zero length returns error", func(t *testing.T) {
_, err := GenerateRandomAlphanumericString(0)
if err == nil {
t.Error("Expected error for zero length, got nil")
}
})
t.Run("negative length returns error", func(t *testing.T) {
_, err := GenerateRandomAlphanumericString(-1)
if err == nil {
t.Error("Expected error for negative length, got nil")
}
})
t.Run("generates different strings", func(t *testing.T) {
str1, _ := GenerateRandomAlphanumericString(10)
str2, _ := GenerateRandomAlphanumericString(10)
if str1 == str2 {
t.Error("Generated strings should be different")
}
})
}
func TestCapitalizeFirstLetter(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"empty string", "", ""},
{"lowercase first letter", "hello", "Hello"},
{"already capitalized", "Hello", "Hello"},
{"single lowercase letter", "h", "H"},
{"single uppercase letter", "H", "H"},
{"starts with number", "123abc", "123abc"},
{"unicode character", "étoile", "Étoile"},
{"special character", "_test", "_test"},
{"multi-word", "hello world", "Hello world"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CapitalizeFirstLetter(tt.input)
if result != tt.expected {
t.Errorf("CapitalizeFirstLetter(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestCamelCaseToSnakeCase(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"empty string", "", ""},
{"simple camelCase", "camelCase", "camel_case"},
{"PascalCase", "PascalCase", "pascal_case"},
{"multipleWordsInCamelCase", "multipleWordsInCamelCase", "multiple_words_in_camel_case"},
{"consecutive uppercase", "HTTPRequest", "h_t_t_p_request"},
{"single lowercase word", "word", "word"},
{"single uppercase word", "WORD", "w_o_r_d"},
{"with numbers", "camel123Case", "camel123_case"},
{"with numbers in middle", "model2Name", "model2_name"},
{"mixed case", "iPhone6sPlus", "i_phone6s_plus"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CamelCaseToSnakeCase(tt.input)
if result != tt.expected {
t.Errorf("CamelCaseToSnakeCase(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

View File

@@ -1,7 +1,7 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
<h1>{{ .AppName }}</h1>
</div>
<div class="warning">Warning</div>

View File

@@ -1,7 +1,7 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
<h1>{{ .AppName }}</h1>
</div>
</div>

View File

@@ -1,12 +1,12 @@
{
"name": "pocket-id-frontend",
"version": "0.38.0",
"version": "0.39.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pocket-id-frontend",
"version": "0.38.0",
"version": "0.39.0",
"dependencies": {
"@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0",
@@ -77,9 +77,10 @@
"optional": true
},
"node_modules/@babel/runtime": {
"version": "7.26.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz",
"integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==",
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
"license": "MIT",
"optional": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "0.39.0",
"version": "0.41.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -16,7 +16,6 @@
let filteredSuggestions: string[] = $state(suggestions.slice(0, suggestionLimit));
let selectedIndex = $state(-1);
let keyError: string | undefined = $state();
let isInputFocused = $state(false);
@@ -26,13 +25,6 @@
}
function handleOnInput() {
if (value.length > 0 && !/^[A-Za-z0-9]*$/.test(value)) {
keyError = 'Only alphanumeric characters are allowed';
return;
} else {
keyError = undefined;
}
filteredSuggestions = suggestions
.filter((s) => s.includes(value.toLowerCase()))
.slice(0, suggestionLimit);
@@ -83,9 +75,6 @@
onfocus={() => (isInputFocused = true)}
onblur={() => (isInputFocused = false)}
/>
{#if keyError}
<p class="mt-1 text-sm text-red-500">{keyError}</p>
{/if}
<Popover.Root
open={isOpen}
disableFocusTrap

View File

@@ -1,20 +1,23 @@
<script lang="ts">
import FileInput from '$lib/components/form/file-input.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import { LucideLoader, LucideUpload } from 'lucide-svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { LucideLoader, LucideRefreshCw, LucideUpload } from 'lucide-svelte';
import { openConfirmDialog } from '../confirm-dialog';
let {
userId,
isLdapUser = false,
callback
resetCallback,
updateCallback
}: {
userId: string;
isLdapUser?: boolean;
callback: (image: File) => Promise<void>;
resetCallback: () => Promise<void>;
updateCallback: (image: File) => Promise<void>;
} = $props();
let isLoading = $state(false);
let imageDataURL = $state(`/api/users/${userId}/profile-picture.png`);
async function onImageChange(e: Event) {
@@ -29,11 +32,27 @@
};
reader.readAsDataURL(file);
await callback(file).catch(() => {
imageDataURL = `/api/users/${userId}/profile-picture.png`;
await updateCallback(file).catch(() => {
imageDataURL = `/api/users/${userId}/profile-picture.png}`;
});
isLoading = false;
}
function onReset() {
openConfirmDialog({
title: 'Reset profile picture?',
message:
'This will remove the uploaded image, and reset the profile picture to default. Do you want to continue?',
confirm: {
label: 'Reset',
action: async () => {
isLoading = true;
await resetCallback().catch();
isLoading = false;
}
}
});
}
</script>
<div class="flex gap-5">
@@ -50,34 +69,48 @@
</p>
<p class="text-muted-foreground mt-1 text-sm">The image should be in PNG or JPEG format.</p>
{/if}
<Button
variant="outline"
size="sm"
class="mt-5"
on:click={onReset}
disabled={isLoading || isLdapUser}
>
<LucideRefreshCw class="mr-2 h-4 w-4" />
Reset to default
</Button>
</div>
{#if isLdapUser}
<Avatar.Root class="h-24 w-24">
<Avatar.Image class="object-cover" src={imageDataURL} />
</Avatar.Root>
{:else}
<FileInput
id="profile-picture-input"
variant="secondary"
accept="image/png, image/jpeg"
onchange={onImageChange}
>
<div class="group relative h-28 w-28 rounded-full">
<Avatar.Root class="h-full w-full transition-opacity duration-200">
<Avatar.Image
class="object-cover group-hover:opacity-10 {isLoading ? 'opacity-10' : ''}"
src={imageDataURL}
/>
</Avatar.Root>
<div class="absolute inset-0 flex items-center justify-center">
{#if isLoading}
<LucideLoader class="h-5 w-5 animate-spin" />
{:else}
<LucideUpload class="h-5 w-5 opacity-0 transition-opacity group-hover:opacity-100" />
{/if}
<div class="flex flex-col items-center gap-2">
<FileInput
id="profile-picture-input"
variant="secondary"
accept="image/png, image/jpeg"
onchange={onImageChange}
>
<div class="group relative h-28 w-28 rounded-full">
<Avatar.Root class="h-full w-full transition-opacity duration-200">
<Avatar.Image
class="object-cover group-hover:opacity-10 {isLoading ? 'opacity-10' : ''}"
src={imageDataURL}
/>
</Avatar.Root>
<div class="absolute inset-0 flex items-center justify-center">
{#if isLoading}
<LucideLoader class="h-5 w-5 animate-spin" />
{:else}
<LucideUpload
class="h-5 w-5 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/if}
</div>
</div>
</div>
</FileInput>
</FileInput>
</div>
{/if}
</div>
</div>

View File

@@ -16,7 +16,7 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger
><Avatar.Root class="h-9 w-9">
<Avatar.Image src="/api/users/me/profile-picture.png" />
<Avatar.Image src="/api/users/{$userStore?.id}/profile-picture.png" />
</Avatar.Root></DropdownMenu.Trigger
>
<DropdownMenu.Content class="min-w-40" align="start">

View File

@@ -59,6 +59,14 @@ export default class UserService extends APIService {
await this.api.put('/users/me/profile-picture', formData);
}
async resetCurrentUserProfilePicture() {
await this.api.delete(`/users/me/profile-picture`);
}
async resetProfilePicture(userId: string) {
await this.api.delete(`/users/${userId}/profile-picture`);
}
async createOneTimeAccessToken(expiresAt: Date, userId: string) {
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
userId,

View File

@@ -31,7 +31,7 @@
{#if !appConfig}
<Error
message="A critical error occured. Please contact your administrator."
message="A critical error occurred. Please contact your administrator."
showButton={false}
/>
{:else}

View File

@@ -21,7 +21,7 @@
await userService
.requestOneTimeAccessEmail(email, data.redirect)
.then(() => (success = true))
.catch((e) => (error = e.response?.data.error || 'An unknown error occured'));
.catch((e) => (error = e.response?.data.error || 'An unknown error occurred'));
isLoading = false;
}

View File

@@ -26,6 +26,15 @@
const userService = new UserService();
const webauthnService = new WebAuthnService();
async function resetProfilePicture() {
await userService
.resetCurrentUserProfilePicture()
.then(() =>
toast.success('Profile picture has been reset. It may take a few minutes to update.')
)
.catch(axiosErrorToast);
}
async function updateAccount(user: UserCreate) {
let success = true;
await userService
@@ -42,7 +51,9 @@
async function updateProfilePicture(image: File) {
await userService
.updateCurrentUsersProfilePicture(image)
.then(() => toast.success('Profile picture updated successfully'))
.then(() =>
toast.success('Profile picture updated successfully. It may take a few minutes to update.')
)
.catch(axiosErrorToast);
}
@@ -99,9 +110,10 @@
<Card.Root>
<Card.Content class="pt-6">
<ProfilePictureSettings
userId="me"
userId={account.id}
isLdapUser={!!account.ldapId}
callback={updateProfilePicture}
updateCallback={updateProfilePicture}
resetCallback={resetProfilePicture}
/>
</Card.Content>
</Card.Root>

View File

@@ -58,7 +58,14 @@
async function updateProfilePicture(image: File) {
await userService
.updateProfilePicture(user.id, image)
.then(() => toast.success('Profile picture updated successfully'))
.then(() => toast.success('Profile picture updated successfully. It may take a few minutes to update.'))
.catch(axiosErrorToast);
}
async function resetProfilePicture() {
await userService
.resetProfilePicture(user.id)
.then(() => toast.success('Profile picture has been reset. It may take a few minutes to update.'))
.catch(axiosErrorToast);
}
</script>
@@ -89,7 +96,8 @@
<ProfilePictureSettings
userId={user.id}
isLdapUser={!!user.ldapId}
callback={updateProfilePicture}
updateCallback={updateProfilePicture}
resetCallback={resetProfilePicture}
/>
</Card.Content>
</Card.Root>

View File

@@ -13,7 +13,11 @@ increment_version() {
local part=$2
IFS='.' read -r -a parts <<<"$version"
if [ "$part" == "minor" ]; then
if [ "$part" == "major" ]; then
parts[0]=$((parts[0] + 1))
parts[1]=0
parts[2]=0
elif [ "$part" == "minor" ]; then
parts[1]=$((parts[1] + 1))
parts[2]=0
elif [ "$part" == "patch" ]; then
@@ -22,16 +26,36 @@ increment_version() {
echo "${parts[0]}.${parts[1]}.${parts[2]}"
}
RELEASE_TYPE=$1
# Determine the release type
if [ "$1" == "major" ]; then
RELEASE_TYPE="major"
else
# Get the latest tag
LATEST_TAG=$(git describe --tags --abbrev=0)
if [ "$RELEASE_TYPE" == "minor" ]; then
# Check for "feat" or "fix" in the commit messages since the latest tag
if git log "$LATEST_TAG"..HEAD --oneline | grep -q "feat"; then
RELEASE_TYPE="minor"
elif git log "$LATEST_TAG"..HEAD --oneline | grep -q "fix"; then
RELEASE_TYPE="patch"
else
echo "No 'fix' or 'feat' commits found since the latest release. No new release will be created."
exit 0
fi
fi
# Increment the version based on the release type
if [ "$RELEASE_TYPE" == "major" ]; then
echo "Performing major release..."
NEW_VERSION=$(increment_version $VERSION major)
elif [ "$RELEASE_TYPE" == "minor" ]; then
echo "Performing minor release..."
NEW_VERSION=$(increment_version $VERSION minor)
elif [ "$RELEASE_TYPE" == "patch" ]; then
echo "Performing patch release..."
NEW_VERSION=$(increment_version $VERSION patch)
else
echo "Invalid release type. Please enter either 'minor' or 'patch'."
echo "Invalid release type. Please enter either 'major', 'minor', or 'patch'."
exit 1
fi