diff --git a/.env.example b/.env.example index b91146b..2a08eb1 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,17 @@ RUN_MIGRATIONS=false # Set to "false" to disable in read-only or maintenance mode ENABLE_WORKERS=true +# ============================================ +# Web Push Notifications (VAPID) +# ============================================ +# Generate VAPID keys using: npx web-push generate-vapid-keys +# Or use any online VAPID key generator +# These are required for push notifications to work when browser is closed + +VAPID_PUBLIC_KEY=your-vapid-public-key-here +VAPID_PRIVATE_KEY=your-vapid-private-key-here +VAPID_SUBJECT=mailto:admin@yourdomain.com + # ============================================ # Production Deployment Example # ============================================ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..da07a23 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [ main, beta ] + pull_request: + branches: [ main, beta ] + +jobs: + backend-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Test backend + env: + JWT_SECRET: "at-least-32-characters-long-secret-for-testing" + run: | + cd backend + go test ./... + + frontend-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Cache Node modules + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('frontend/package.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: | + cd frontend + npm install + + - name: Test frontend + run: | + cd frontend + npm run test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f642900..1e7ee66 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,6 +30,9 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Get short SHA id: vars run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT @@ -52,3 +55,5 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 53ea24f..5989e6a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ /frontend/node_modules/ /frontend/dist/ /frontend/.vite/ - # Environment .env docker-compose.yml diff --git a/DEPLOY.md b/DEPLOY.md index fee703c..29644a7 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -3,7 +3,7 @@ ### Pull example .env file ```sh -wget https://raw.githubusercontent.com/Panonim/kept/refs/heads/beta/.env.example -o .env +wget https://raw.githubusercontent.com/Panonim/kept/refs/heads/main/.env.example -o .env ``` ## Deploying with Docker Compose @@ -20,9 +20,9 @@ services: - JWT_REFRESH_SECRET=your_refresh_secret - ALLOWED_ORIGINS=https://yourdomain.com - DISABLE_REGISTRATION=false - - RUN_MIGRATIONS=true + - RUN_MIGRATIONS=false volumes: - - data:/root/data + - data:/data ports: - "80:80" # Frontend - "3000:3000" # Backend @@ -43,6 +43,27 @@ volumes: --- +## Generating VAPID keys + +For web-push notifications (required for background/persisted push), generate VAPID keys and set them as environment variables. + +- Generate keys locally (Node.js required): + +```sh +npx web-push generate-vapid-keys --json +``` + +Copy the resulting `publicKey` and `privateKey` into your environment. + +- Example environment variables (Docker Compose `environment` block or systemd exports): + +``` +VAPID_PUBLIC_KEY=your-vapid-public-key-here +VAPID_PRIVATE_KEY=your-vapid-private-key-here +VAPID_SUBJECT=mailto:admin@yourdomain.com +``` + +- For Docker users: add these to the `kept` service `environment` in `docker-compose.yml`, or provide via Docker secrets for improved security. ## Bare Metal Deployment (No Docker) @@ -104,4 +125,4 @@ Restart=on-failure [Install] WantedBy=multi-user.target -``` \ No newline at end of file +``` diff --git a/Dockerfile b/Dockerfile index 0c06b28..36e592a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ RUN npm run build ######################### # Backend build stage ######################### -FROM golang:1.21-alpine AS backend-builder +FROM golang:1.24-alpine AS backend-builder WORKDIR /src/backend # Install build deps for SQLite diff --git a/backend/Dockerfile b/backend/Dockerfile index e0cc397..cb9218c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,6 +1,6 @@ # Backend Dockerfile # syntax=docker/dockerfile:1.4 -FROM golang:1.21 AS builder +FROM golang:1.24 AS builder WORKDIR /app diff --git a/backend/go.mod b/backend/go.mod index 8f85c55..4d47ec9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,24 +1,25 @@ module kept -go 1.21 +go 1.24.0 require ( - github.com/gofiber/fiber/v2 v2.52.0 - github.com/golang-jwt/jwt/v5 v5.2.0 - github.com/mattn/go-sqlite3 v1.14.19 - golang.org/x/crypto v0.18.0 + github.com/SherClockHolmes/webpush-go v1.4.0 + github.com/gofiber/fiber/v2 v2.52.10 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/mattn/go-sqlite3 v1.14.33 + golang.org/x/crypto v0.47.0 ) require ( - github.com/andybalholm/brotli v1.0.5 // indirect - github.com/google/uuid v1.5.0 // indirect - github.com/klauspost/compress v1.17.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.51.0 // indirect - github.com/valyala/tcplisten v1.0.0 // indirect - golang.org/x/sys v0.16.0 // indirect + github.com/valyala/fasthttp v1.69.0 // indirect + golang.org/x/sys v0.40.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index e7b52bf..b7ebfbb 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,33 +1,100 @@ -github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= -github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= -github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= -github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= -github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= -github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY= +github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= -github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= -github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= -github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= -github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= -golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= +github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +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/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= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/backend/internal/api/recurring_reminders.go b/backend/internal/api/recurring_reminders.go index f4bb104..3c5be7a 100644 --- a/backend/internal/api/recurring_reminders.go +++ b/backend/internal/api/recurring_reminders.go @@ -2,6 +2,7 @@ package api import ( "database/sql" + "fmt" "kept/internal/models" "log" "time" @@ -32,7 +33,7 @@ func ProcessRecurringReminders(db *sql.DB) error { } if shouldRemind(p) { - if err := sendReminder(db, p); err != nil { + if err := sendRecurringReminder(db, p); err != nil { log.Printf("Failed to send reminder for promise %d: %v", p.ID, err) } } @@ -40,15 +41,64 @@ func ProcessRecurringReminders(db *sql.DB) error { return nil } +// ProcessScheduledReminders checks for one-time reminders that are due +// and sends push notifications for them. +func ProcessScheduledReminders(db *sql.DB) error { + query := ` + SELECT r.id, r.promise_id, r.user_id, r.remind_at, p.recipient, p.description + FROM reminders r + JOIN promises p ON r.promise_id = p.id + WHERE r.is_sent = FALSE AND r.remind_at <= CURRENT_TIMESTAMP + ` + + rows, err := db.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var reminderID, promiseID, userID int + var remindAt time.Time + var recipient, description string + + err := rows.Scan(&reminderID, &promiseID, &userID, &remindAt, &recipient, &description) + if err != nil { + log.Printf("Error scanning reminder: %v", err) + continue + } + + payload := PushPayload{ + Title: fmt.Sprintf("Reminder about your promise to: %s", recipient), + Body: description, + Icon: "/Static/logos/Kept Mascot Colored.svg", + Badge: "/Static/logos/Kept Mascot Colored.svg", + Tag: fmt.Sprintf("kept-reminder-%d", reminderID), + Data: map[string]interface{}{"promise_id": promiseID, "reminder_id": reminderID}, + } + + if err := SendPushToUser(db, userID, payload); err != nil { + log.Printf("Failed to send scheduled reminder %d: %v", reminderID, err) + continue + } + + // Mark reminder as sent + _, err = db.Exec("UPDATE reminders SET is_sent = TRUE WHERE id = ?", reminderID) + if err != nil { + log.Printf("Failed to mark reminder %d as sent: %v", reminderID, err) + } else { + log.Printf("Sent scheduled reminder %d for promise %d to user %d", reminderID, promiseID, userID) + } + } + return nil +} + func shouldRemind(p models.Promise) bool { lastReminded := p.CreatedAt if p.LastRemindedAt != nil { lastReminded = *p.LastRemindedAt } - // Don't remind if last reminder was very recent (to avoid duplicate runs within seconds) - // But since we update last_reminded_at immediately, this should be fine. - var duration time.Duration switch p.ReminderFrequency { case "daily": @@ -64,14 +114,26 @@ func shouldRemind(p models.Promise) bool { return time.Since(lastReminded) >= duration } -func sendReminder(db *sql.DB, p models.Promise) error { - // 1. Send Notification Logic - // In a real implementation with webpush-go, we would fetch subscriptions for p.UserID and send. - // For now, we log it. - log.Printf("Use webpush-go to SEND PUSH NOTIFICATION for promise %d to user %d: Remember your promise to %s: %s", - p.ID, p.UserID, p.Recipient, p.Description) +func sendRecurringReminder(db *sql.DB, p models.Promise) error { + payload := PushPayload{ + Title: fmt.Sprintf("Reminder about your promise to: %s", p.Recipient), + Body: p.Description, + Icon: "/Static/logos/Kept Mascot Colored.svg", + Badge: "/Static/logos/Kept Mascot Colored.svg", + Tag: fmt.Sprintf("kept-recurring-%d", p.ID), + Data: map[string]interface{}{"promise_id": p.ID}, + } + + if err := SendPushToUser(db, p.UserID, payload); err != nil { + return err + } - // 2. Update last_reminded_at + // Update last_reminded_at _, err := db.Exec("UPDATE promises SET last_reminded_at = CURRENT_TIMESTAMP WHERE id = ?", p.ID) - return err + if err != nil { + return fmt.Errorf("failed to update last_reminded_at: %w", err) + } + + log.Printf("Sent recurring reminder for promise %d to user %d", p.ID, p.UserID) + return nil } diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 0b3c940..026e919 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -30,6 +30,9 @@ func SetupRoutes(app *fiber.App, db *sql.DB) { auth.Post("/refresh", RefreshTokenHandler(db)) auth.Post("/logout", LogoutHandler(db)) + // VAPID public key endpoint (public - must be before protected routes for proper routing) + api.Get("/push/vapid-public-key", VapidPublicKeyHandler()) + // Protected routes protected := api.Group("/", AuthMiddleware()) @@ -55,8 +58,8 @@ func SetupRoutes(app *fiber.App, db *sql.DB) { push := protected.Group("/push") push.Post("/subscribe", SubscribePushHandler(db)) push.Delete("/unsubscribe", UnsubscribePushHandler(db)) - // Test endpoint for sending an example push-style notification payload - push.Post("/test", TestPushHandler(db)) + // Test endpoint for sending an actual push notification + push.Post("/test", SendTestPushHandler(db)) // Health check app.Get("/health", func(c *fiber.Ctx) error { diff --git a/backend/internal/api/webpush.go b/backend/internal/api/webpush.go new file mode 100644 index 0000000..216034a --- /dev/null +++ b/backend/internal/api/webpush.go @@ -0,0 +1,202 @@ +package api + +import ( + "database/sql" + "encoding/json" + "fmt" + "io" + "log" + "os" + "time" + + webpush "github.com/SherClockHolmes/webpush-go" + "github.com/gofiber/fiber/v2" +) + +// PushPayload represents the notification payload sent to clients +type PushPayload struct { + Title string `json:"title"` + Body string `json:"body"` + Icon string `json:"icon,omitempty"` + Badge string `json:"badge,omitempty"` + Tag string `json:"tag,omitempty"` + Data map[string]interface{} `json:"data,omitempty"` +} + +// GetVapidOptions returns configured VAPID options from environment +func GetVapidOptions() *webpush.Options { + return &webpush.Options{ + Subscriber: os.Getenv("VAPID_SUBJECT"), + VAPIDPublicKey: os.Getenv("VAPID_PUBLIC_KEY"), + VAPIDPrivateKey: os.Getenv("VAPID_PRIVATE_KEY"), + TTL: 30, + } +} + +// IsWebPushConfigured checks if VAPID keys are configured +func IsWebPushConfigured() bool { + publicKey := os.Getenv("VAPID_PUBLIC_KEY") + privateKey := os.Getenv("VAPID_PRIVATE_KEY") + subject := os.Getenv("VAPID_SUBJECT") + + return publicKey != "" && privateKey != "" && subject != "" +} + +// SendPushToUser sends a push notification to all subscriptions for a user +func SendPushToUser(db *sql.DB, userID int, payload PushPayload) error { + if !IsWebPushConfigured() { + log.Println("Web push not configured - skipping notification") + return nil + } + + // Get all push subscriptions for the user + rows, err := db.Query( + "SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?", + userID, + ) + if err != nil { + return fmt.Errorf("failed to fetch subscriptions: %w", err) + } + defer rows.Close() + + payloadJSON, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + log.Printf("Payload to send: %s", string(payloadJSON)) + + options := GetVapidOptions() + successCount := 0 + failCount := 0 + subscriptionCount := 0 + + for rows.Next() { + subscriptionCount++ + var endpoint, p256dh, auth string + if err := rows.Scan(&endpoint, &p256dh, &auth); err != nil { + log.Printf("Error scanning subscription: %v", err) + failCount++ + continue + } + + log.Printf("Sending push to endpoint: %s", endpoint[:50]+"...") + + subscription := &webpush.Subscription{ + Endpoint: endpoint, + Keys: webpush.Keys{ + P256dh: p256dh, + Auth: auth, + }, + } + + resp, err := webpush.SendNotification(payloadJSON, subscription, options) + if err != nil { + log.Printf("Failed to send push to %s: %v", endpoint, err) + failCount++ + + // If subscription is expired/invalid (410 Gone or 404), remove it + if resp != nil && (resp.StatusCode == 410 || resp.StatusCode == 404) { + _, _ = db.Exec("DELETE FROM push_subscriptions WHERE endpoint = ?", endpoint) + log.Printf("Removed expired subscription: %s", endpoint) + } + continue + } + + if resp != nil { + log.Printf("Push response status: %d", resp.StatusCode) + // Log response body for debugging (especially for errors like 403) + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + log.Printf("Push service error response: %s", string(body)) + } + resp.Body.Close() + + // If 403 Forbidden, the VAPID keys don't match - delete the subscription + // so the client will re-subscribe with current keys + if resp.StatusCode == 403 { + _, _ = db.Exec("DELETE FROM push_subscriptions WHERE endpoint = ?", endpoint) + log.Printf("Deleted mismatched subscription (403 Forbidden): %s", endpoint) + failCount++ + continue + } + } + + successCount++ + log.Printf("Push sent successfully to user %d", userID) + } + + log.Printf("Push notification summary for user %d: subscriptions=%d, success=%d, failed=%d", userID, subscriptionCount, successCount, failCount) + + if subscriptionCount == 0 { + return fmt.Errorf("no push subscriptions found for user %d", userID) + } + + if failCount > 0 && successCount == 0 { + return fmt.Errorf("failed to send any push notifications (attempted %d)", failCount) + } + + return nil +} + +// VapidPublicKeyHandler returns the VAPID public key for client subscription +func VapidPublicKeyHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + publicKey := os.Getenv("VAPID_PUBLIC_KEY") + if publicKey == "" { + return fiber.NewError(fiber.StatusServiceUnavailable, "Push notifications not configured") + } + return c.JSON(fiber.Map{ + "publicKey": publicKey, + }) + } +} + +// SendTestPushHandler sends an actual push notification for testing +func SendTestPushHandler(db *sql.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + userID := c.Locals("userID").(int) + + if !IsWebPushConfigured() { + return fiber.NewError(fiber.StatusServiceUnavailable, "Push notifications not configured. Set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT environment variables.") + } + + // Get most recent promise for context + row := db.QueryRow("SELECT id, recipient, description FROM promises WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1", userID) + var promiseID int + var recipient, description string + err := row.Scan(&promiseID, &recipient, &description) + + var payload PushPayload + if err == sql.ErrNoRows { + payload = PushPayload{ + Title: "Kept — Test Notification", + Body: "This is a test notification", + Icon: "/Static/logos/Kept%20Mascot%20Colored.svg", + Badge: "/Static/logos/Kept%20Mascot%20Colored.svg", + Tag: fmt.Sprintf("kept-test-%d", time.Now().Unix()), + } + } else if err != nil { + return err + } else { + payload = PushPayload{ + Title: "Kept — Test Notification", + Body: "This is a test notification", + Icon: "/Static/logos/Kept%20Mascot%20Colored.svg", + Badge: "/Static/logos/Kept%20Mascot%20Colored.svg", + Tag: fmt.Sprintf("kept-test-%d", time.Now().Unix()), + Data: map[string]interface{}{"promise_id": promiseID}, + } + } + + if err := SendPushToUser(db, userID, payload); err != nil { + log.Printf("Test push failed for user %d: %v", userID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to send test notification: "+err.Error()) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "Test notification sent", + }) + } +} diff --git a/backend/main.go b/backend/main.go index 3a3d9ef..d8ab6ca 100644 --- a/backend/main.go +++ b/backend/main.go @@ -59,6 +59,9 @@ func main() { if err := api.ProcessRecurringReminders(db); err != nil { log.Printf("Recurring reminder worker error: %v", err) } + if err := api.ProcessScheduledReminders(db); err != nil { + log.Printf("Scheduled reminder worker error: %v", err) + } } }() } else { diff --git a/frontend/index.html b/frontend/index.html index cf661dc..790c97a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -50,6 +50,7 @@