feat: add support for multiple API keys in addition to the user key

This commit is contained in:
monomarh
2025-10-24 03:45:43 +02:00
parent ae57934b05
commit 85eb5e3173
29 changed files with 3721 additions and 3190 deletions

View File

@@ -1,5 +1,5 @@
.env
config*.yml
!config*.yml
!config.default.yml
*.db
*.exe

View File

@@ -1,8 +1,8 @@
FROM --platform=$BUILDPLATFORM golang:alpine AS build-env
WORKDIR /src
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \
chmod +x wait-for-it.sh
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O /bin/wait-for-it.sh && \
chmod +x /bin/wait-for-it.sh
COPY ./go.mod ./go.sum ./
RUN go mod download
@@ -10,17 +10,24 @@ COPY . .
ARG TARGETOS
ARG TARGETARCH
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 GOEXPERIMENT=greenteagc,jsonv2 go build -ldflags "-s -w" -v -o wakapi main.go
# RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 GOEXPERIMENT=greenteagc,jsonv2 go build -ldflags "-s -w" -v -o wakapi main.go
WORKDIR /staging
RUN mkdir ./data ./app && \
cp /src/wakapi app/ && \
cp /src/config.default.yml app/config.yml && \
sed -i 's/listen_ipv6: ::1/listen_ipv6: "-"/g' app/config.yml && \
cp /src/wait-for-it.sh app/ && \
cp /src/entrypoint.sh app/ && \
chown 1000:1000 ./data
# WORKDIR /staging
RUN set -ex; \
# mkdir ./data ./app && \
# mkdir ./data && \
# cp /src/wakapi app/ && \
# cp /src/config.default.yml app/config.yml && \
# sed -i 's/listen_ipv6: ::1/listen_ipv6: "-"/g' config.yml && \
# cp /src/wait-for-it.sh app/ && \
# cp /src/entrypoint.sh app/ && \
chown 1000:1000 ./data && \
apk add --no-cache bash ca-certificates tzdata && \
go install github.com/cespare/reflex@latest
RUN CGO_ENABLED=0 go install -ldflags "-s -w -extldflags '-static'" github.com/go-delve/delve/cmd/dlv@latest
ENTRYPOINT /src/entrypoint.sh
# Run Stage
# When running the application using `docker run`, you can pass environment variables
@@ -35,16 +42,16 @@ RUN addgroup -g 1000 app && \
apk add --no-cache bash ca-certificates tzdata
# See README.md and config.default.yml for all config options
ENV ENVIRONMENT=prod \
WAKAPI_DB_TYPE=sqlite3 \
WAKAPI_DB_USER='' \
WAKAPI_DB_PASSWORD='' \
WAKAPI_DB_HOST='' \
WAKAPI_DB_NAME=/data/wakapi.db \
WAKAPI_PASSWORD_SALT='' \
WAKAPI_LISTEN_IPV4='0.0.0.0' \
WAKAPI_INSECURE_COOKIES='true' \
WAKAPI_ALLOW_SIGNUP='true'
# ENV ENVIRONMENT=prod \
# WAKAPI_DB_TYPE=sqlite3 \
# WAKAPI_DB_USER='' \
# WAKAPI_DB_PASSWORD='' \
# WAKAPI_DB_HOST='' \
# WAKAPI_DB_NAME=/data/wakapi.db \
# WAKAPI_PASSWORD_SALT='' \
# WAKAPI_LISTEN_IPV4='0.0.0.0' \
# WAKAPI_INSECURE_COOKIES='true' \
# WAKAPI_ALLOW_SIGNUP='true'
COPY --from=build-env /staging /

View File

@@ -1,5 +1,5 @@
vars {
BASE_URL: http://localhost:3000
BASE_URL: http://localhost:3001
}
vars:secret [
API_KEY

View File

@@ -1,10 +1,16 @@
services:
wakapi:
build: .
build:
context: .
target: build-env
init: true
ports:
- 3000:3000
- 4000:4000
- 4001:4001
restart: unless-stopped
volumes:
- .:/src/
environment:
# See README.md and config.default.yml for all config options
WAKAPI_DB_TYPE: "postgres"
@@ -12,49 +18,51 @@ services:
WAKAPI_DB_USER: "wakapi"
WAKAPI_DB_HOST: "db"
WAKAPI_DB_PORT: "5432"
WAKAPI_DB_PASSWORD_FILE: "/run/secrets/db_password" # alternatively, set WAKAPI_DB_PASSWORD directly without the use of secrets
WAKAPI_PASSWORD_SALT_FILE: "/run/secrets/password_salt" # alternatively, set WAKAPI_PASSWORD_SALT directly without the use of secrets
WAKAPI_MAIL_SMTP_PASS_FILE: "/run/secrets/smtp_pass" # alternatively, set WAKAPI_MAIL_SMTP_PASS directly without the use of secrets
secrets:
- source: password_salt
target: password_salt
uid: '1000'
gid: '1000'
mode: '0400'
WAKAPI_DB_PASSWORD: "wakapi" # alternatively, set WAKAPI_DB_PASSWORD directly without the use of secrets
WAKAPI_PASSWORD_SALT: "wakapi" # alternatively, set WAKAPI_PASSWORD_SALT directly without the use of secrets
WAKAPI_MAIL_SMTP_PASS: "wakapi" # alternatively, set WAKAPI_MAIL_SMTP_PASS directly without the use of secrets
# secrets:
# - source: password_salt
# target: password_salt
# uid: '1000'
# gid: '1000'
# mode: '0400'
- source: smtp_pass
target: smtp_pass
uid: '1000'
gid: '1000'
mode: '0400'
# - source: smtp_pass
# target: smtp_pass
# uid: '1000'
# gid: '1000'
# mode: '0400'
- source: db_password
target: db_password
uid: '1000'
gid: '1000'
mode: '0400'
# - source: db_password
# target: db_password
# uid: '1000'
# gid: '1000'
# mode: '0400'
db:
image: postgres:17
restart: unless-stopped
ports:
- 5432:5432
environment:
POSTGRES_USER: "wakapi"
POSTGRES_PASSWORD_FILE: "/run/secrets/db_password" # alternatively, set POSTGRES_PASSWORD directly without the use of secrets
POSTGRES_PASSWORD: "wakapi" # alternatively, set POSTGRES_PASSWORD directly without the use of secrets
POSTGRES_DB: "wakapi"
volumes:
- wakapi-db-data:/var/lib/postgresql/data
secrets:
- db_password
# secrets:
# - db_password
# secrets can be defined either from a local file or from an environment variable defined on the client host (the one that runs `docker compose` command)
# see https://docs.docker.com/compose/how-tos/use-secrets/ for details
secrets:
password_salt:
environment: WAKAPI_PASSWORD_SALT
smtp_pass:
environment: WAKAPI_MAIL_SMTP_PASS
db_password:
environment: WAKAPI_DB_PASSWORD
# secrets:
# password_salt:
# environment: WAKAPI_PASSWORD_SALT
# smtp_pass:
# environment: WAKAPI_MAIL_SMTP_PASS
# db_password:
# environment: WAKAPI_DB_PASSWORD
volumes:
wakapi-db-data: {}

View File

@@ -3,6 +3,7 @@ package config
import (
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"os"
@@ -12,15 +13,13 @@ import (
"time"
"github.com/duke-git/lancet/v2/slice"
"log/slog"
"github.com/gofrs/uuid/v5"
"github.com/gorilla/securecookie"
"github.com/jinzhu/configor"
"github.com/robfig/cron/v3"
"github.com/muety/wakapi/data"
"github.com/muety/wakapi/utils"
"github.com/robfig/cron/v3"
)
const (
@@ -544,6 +543,7 @@ func Load(configFlag string, version string) *Config {
config.InstanceId = uuid.Must(uuid.NewV4()).String()
config.App.Colors = readColors()
config.Db.Dialect = resolveDbDialect(config.Db.Type)
slog.Info("loaded configuration", "environment", config.Env, "version", config.Version, "db_dialect", config.Db.Type)
if config.Db.Type == "cockroach" {
slog.Warn("cockroach is not officially supported, it is strongly recommended to migrate to postgres instead")
}

View File

@@ -18,6 +18,8 @@ const (
EventProjectLabelDelete = "project_label.delete"
EventWakatimeFailure = "wakatime.failure"
EventLanguageMappingsChanged = "language_mappings.changed"
EventApiKeyCreate = "api_key.create"
EventApiKeyDelete = "api_key.delete"
FieldPayload = "payload"
FieldUser = "user"
FieldUserId = "user.id"

File diff suppressed because it is too large Load Diff

View File

@@ -32,5 +32,5 @@ if [ "$WAKAPI_DB_TYPE" == "sqlite3" ] || [ "$WAKAPI_DB_TYPE" == "" ]; then
exec ./wakapi
else
echo "Waiting for database to come up"
exec ./wait-for-it.sh "$WAKAPI_DB_HOST:$WAKAPI_DB_PORT" -s -t 60 -- ./wakapi
exec /bin/wait-for-it.sh "$WAKAPI_DB_HOST:$WAKAPI_DB_PORT" -s -t 60 -- reflex -c reflex.conf
fi

14
main.go
View File

@@ -8,6 +8,7 @@ import (
"log/slog"
"net"
"net/http"
_ "net/http/pprof"
"os"
"strconv"
"time"
@@ -37,8 +38,6 @@ import (
"github.com/muety/wakapi/services/mail"
"github.com/muety/wakapi/static/docs"
fsutils "github.com/muety/wakapi/utils/fs"
_ "net/http/pprof"
)
// Embed version.txt
@@ -68,6 +67,7 @@ var (
diagnosticsRepository repositories.IDiagnosticsRepository
metricsRepository *repositories.MetricsRepository
durationRepository *repositories.DurationRepository
apiKeyRepository repositories.IApiKeyRepository
)
var (
@@ -87,6 +87,7 @@ var (
diagnosticsService services.IDiagnosticsService
housekeepingService services.IHousekeepingService
miscService services.IMiscService
apiKeyService services.IApiKeyService
)
// TODO: Refactor entire project to be structured after business domains
@@ -108,7 +109,7 @@ var (
// @securitydefinitions.apikey ApiKeyAuth
// @in header
// @name Authorization
// @name Authorizatio
func main() {
var versionFlag = flag.Bool("version", false, "print version")
@@ -120,6 +121,7 @@ func main() {
os.Exit(0)
}
config = conf.Load(*configFlag, version)
slog.Info("loaded configuration", "configFile", *configFlag)
// Configure Swagger docs
docs.SwaggerInfo.BasePath = config.Server.BasePath + "/api"
@@ -172,12 +174,14 @@ func main() {
diagnosticsRepository = repositories.NewDiagnosticsRepository(db)
metricsRepository = repositories.NewMetricsRepository(db)
durationRepository = repositories.NewDurationRepository(db)
apiKeyRepository = repositories.NewApiKeyRepository(db)
// Services
mailService = mail.NewMailService()
aliasService = services.NewAliasService(aliasRepository)
keyValueService = services.NewKeyValueService(keyValueRepository)
userService = services.NewUserService(keyValueService, mailService, userRepository)
apiKeyService = services.NewApiKeyService(apiKeyRepository)
userService = services.NewUserService(keyValueService, mailService, apiKeyService, userRepository)
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
@@ -233,7 +237,7 @@ func main() {
// MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService, heartbeatService, durationService, aliasService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, durationService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, durationService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService, apiKeyService)
subscriptionHandler := routes.NewSubscriptionHandler(userService, mailService, keyValueService)
projectsHandler := routes.NewProjectsHandler(userService, heartbeatService)
homeHandler := routes.NewHomeHandler(userService, keyValueService)

View File

@@ -9,11 +9,11 @@ import (
"github.com/duke-git/lancet/v2/slice"
"github.com/gofrs/uuid/v5"
"github.com/muety/wakapi/helpers"
routeutils "github.com/muety/wakapi/routes/utils"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
)
@@ -34,6 +34,7 @@ type AuthenticateMiddleware struct {
optionalForMethods []string
redirectTarget string // optional
redirectErrorMessage string // optional
onlyRWApiKeys bool
}
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
@@ -42,6 +43,7 @@ func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateM
userSrvc: userService,
optionalForPaths: []string{},
optionalForMethods: []string{},
onlyRWApiKeys: false,
}
}
@@ -65,6 +67,11 @@ func (m *AuthenticateMiddleware) WithRedirectErrorMessage(message string) *Authe
return m
}
func (m *AuthenticateMiddleware) WithOnlyRWApiKeys(onlyRW bool) *AuthenticateMiddleware {
m.onlyRWApiKeys = onlyRW
return m
}
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.ServeHTTP(w, r, h.ServeHTTP)

View File

@@ -11,14 +11,14 @@ import (
"testing"
"time"
"github.com/muety/wakapi/config"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/oauth2-proxy/mockoidc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
routeutils "github.com/muety/wakapi/routes/utils"
)
func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Success(t *testing.T) {

View File

@@ -1,10 +1,10 @@
package middlewares
import (
"net/http"
"github.com/muety/wakapi/models"
routeutils "github.com/muety/wakapi/routes/utils"
"net/http"
)
func SetPrincipal(r *http.Request, user *models.User) {

View File

@@ -1,12 +1,14 @@
package migrations
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
"sort"
"strings"
"gorm.io/gorm"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
)
type GormMigrationFunc func(db *gorm.DB) error
@@ -62,6 +64,9 @@ func GetMigrationFunc(cfg *config.Config) GormMigrationFunc {
if err := db.AutoMigrate(&models.Duration{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.ApiKey{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
return nil
}
}

View File

@@ -1,8 +1,9 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
"github.com/muety/wakapi/models"
)
type UserServiceMock struct {
@@ -45,6 +46,14 @@ func (m *UserServiceMock) GetUserByOidc(s1, s2 string) (*models.User, error) {
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetUserByRWKey(s string) (*models.User, error) {
args := m.Called(s)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetAll() ([]*models.User, error) {
args := m.Called()
return args.Get(0).([]*models.User), args.Error(1)

14
models/api_key.go Normal file
View File

@@ -0,0 +1,14 @@
package models
type ApiKey struct {
ID uint `json:"id" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"-" gorm:"not null; index:idx_api_key_user"`
ApiKey string `json:"api_key" gorm:"unique"`
ReadOnly bool `json:"readonly" gorm:"default:false"`
Label string `json:"label" gorm:"type:varchar(64)"`
}
func (k *ApiKey) IsValid() bool {
return k.ApiKey != "" && k.Label != ""
}

View File

@@ -1,8 +1,9 @@
package view
import (
"github.com/muety/wakapi/models"
"time"
"github.com/muety/wakapi/models"
)
type SettingsViewModel struct {
@@ -17,6 +18,7 @@ type SettingsViewModel struct {
SupportContact string
InviteLink string
ReadmeCardCustomTitle string
ApiKeys []*SettingsApiKeys
}
type SettingsVMCombinedAlias struct {
@@ -30,6 +32,13 @@ type SettingsVMCombinedLabel struct {
Values []string
}
type SettingsApiKeys struct {
ID uint
Name string
Value string
ReadOnly bool
}
func (s *SettingsViewModel) SubscriptionsEnabled() bool {
return s.SubscriptionPrice != ""
}

81
repositories/api_key.go Normal file
View File

@@ -0,0 +1,81 @@
package repositories
import (
"errors"
"gorm.io/gorm"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
)
type ApiKeyRepository struct {
BaseRepository
config *config.Config
}
func NewApiKeyRepository(db *gorm.DB) *ApiKeyRepository {
return &ApiKeyRepository{BaseRepository: NewBaseRepository(db), config: config.Get()}
}
func (r *ApiKeyRepository) GetAll() ([]*models.ApiKey, error) {
var keys []*models.ApiKey
if err := r.db.Find(&keys).Error; err != nil {
return nil, err
}
return keys, nil
}
func (r *ApiKeyRepository) GetById(id uint) (*models.ApiKey, error) {
key := &models.ApiKey{}
if err := r.db.Where(&models.ApiKey{ID: id}).First(key).Error; err != nil {
return key, err
}
return key, nil
}
func (r *ApiKeyRepository) GetByApiKey(apiKey string) (*models.ApiKey, error) {
key := &models.ApiKey{}
if err := r.db.Where(&models.ApiKey{ApiKey: apiKey}).First(key).Error; err != nil {
return key, err
}
return key, nil
}
func (r *ApiKeyRepository) GetByRWApiKey(apiKey string) (*models.ApiKey, error) {
key := &models.ApiKey{}
if err := r.db.Where(&models.ApiKey{ApiKey: apiKey, ReadOnly: false}).First(key).Error; err != nil {
return key, err
}
return key, nil
}
func (r *ApiKeyRepository) GetByUser(userId string) ([]*models.ApiKey, error) {
if userId == "" {
return []*models.ApiKey{}, nil
}
var keys []*models.ApiKey
if err := r.db.
Where(&models.ApiKey{UserID: userId}).
Find(&keys).Error; err != nil {
return keys, err
}
return keys, nil
}
func (r *ApiKeyRepository) Insert(key *models.ApiKey) (*models.ApiKey, error) {
if !key.IsValid() {
return nil, errors.New("invalid API key")
}
result := r.db.Create(key)
if err := result.Error; err != nil {
return nil, err
}
return key, nil
}
func (r *ApiKeyRepository) Delete(id uint) error {
return r.db.
Where("id = ?", id).
Delete(models.ApiKey{}).Error
}

View File

@@ -3,8 +3,9 @@ package repositories
import (
"time"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"github.com/muety/wakapi/models"
)
type IBaseRepository interface {
@@ -142,3 +143,14 @@ type ILeaderboardRepository interface {
GetAllAggregatedByInterval(*models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
}
type IApiKeyRepository interface {
IBaseRepository
GetAll() ([]*models.ApiKey, error)
GetById(uint) (*models.ApiKey, error)
GetByUser(string) ([]*models.ApiKey, error)
GetByApiKey(string) (*models.ApiKey, error)
GetByRWApiKey(string) (*models.ApiKey, error)
Insert(*models.ApiKey) (*models.ApiKey, error)
Delete(uint) error
}

View File

@@ -1,22 +1,21 @@
package api
import (
"github.com/duke-git/lancet/v2/condition"
"github.com/go-chi/chi/v5"
"github.com/muety/wakapi/helpers"
"github.com/rs/cors"
"net/http"
"github.com/duke-git/lancet/v2/condition"
"github.com/go-chi/chi/v5"
"github.com/rs/cors"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/middlewares"
customMiddleware "github.com/muety/wakapi/middlewares/custom"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"github.com/muety/wakapi/models"
)
type HeartbeatApiHandler struct {
@@ -38,7 +37,7 @@ func NewHeartbeatApiHandler(userService services.IUserService, heartbeatService
func (h *HeartbeatApiHandler) RegisterRoutes(router chi.Router) {
router.Group(func(r chi.Router) {
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithOptionalForMethods(http.MethodOptions).Handler,
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithOptionalForMethods(http.MethodOptions).WithOnlyRWApiKeys(true).Handler,
customMiddleware.NewWakatimeRelayMiddleware().Handler,
)
// see https://github.com/muety/wakapi/issues/203

View File

@@ -3,6 +3,7 @@ package routes
import (
"encoding/base64"
"fmt"
"log/slog"
"net/http"
"net/url"
"sort"
@@ -11,15 +12,13 @@ import (
"time"
"github.com/duke-git/lancet/v2/condition"
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"github.com/go-chi/chi/v5"
"github.com/gofrs/uuid/v5"
"github.com/muety/wakapi/helpers"
"log/slog"
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
@@ -43,6 +42,7 @@ type SettingsHandler struct {
projectLabelSrvc services.IProjectLabelService
keyValueSrvc services.IKeyValueService
mailSrvc services.IMailService
apiKeySrvc services.IApiKeyService
httpClient *http.Client
aggregationLocks map[string]bool
}
@@ -71,6 +71,7 @@ func NewSettingsHandler(
projectLabelService services.IProjectLabelService,
keyValueService services.IKeyValueService,
mailService services.IMailService,
apiKeyService services.IApiKeyService,
) *SettingsHandler {
return &SettingsHandler{
config: conf.Get(),
@@ -84,6 +85,7 @@ func NewSettingsHandler(
durationSrvc: durationService,
keyValueSrvc: keyValueService,
mailSrvc: mailService,
apiKeySrvc: apiKeyService,
httpClient: &http.Client{Timeout: 10 * time.Second},
aggregationLocks: make(map[string]bool),
}
@@ -106,7 +108,10 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w, nil))
err := templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w, nil))
if err != nil {
panic(err)
}
}
func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
@@ -116,7 +121,10 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w, nil).WithError("missing form values"))
err = templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w, nil).WithError("missing form values"))
if err != nil {
panic(err)
}
return
}
@@ -193,6 +201,10 @@ func (h *SettingsHandler) dispatchAction(action string) action {
return h.actionUpdateExcludeUnknownProjects
case "update_heartbeats_timeout":
return h.actionUpdateHeartbeatsTimeout
case "add_api_key":
return h.actionAddApiKey
case "delete_api_key":
return h.actionDeleteApiKey
}
return nil
}
@@ -870,6 +882,54 @@ func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
return nil
}
func (h *SettingsHandler) actionAddApiKey(w http.ResponseWriter, r *http.Request) actionResult {
if h.config.IsDev() {
loadTemplates()
}
apiKey := uuid.Must(uuid.NewV4()).String()
if _, err := h.apiKeySrvc.Create(&models.ApiKey{
User: middlewares.GetPrincipal(r),
Label: r.PostFormValue("api-name"),
ApiKey: apiKey,
ReadOnly: r.PostFormValue("api-readonly") == "true",
}); err != nil {
return actionResult{http.StatusInternalServerError, "", conf.ErrInternalServerError, nil}
}
msg := fmt.Sprintf("you added new api key: %s", apiKey)
return actionResult{http.StatusOK, msg, "", nil}
}
func (h *SettingsHandler) actionDeleteApiKey(w http.ResponseWriter, r *http.Request) actionResult {
if h.config.IsDev() {
loadTemplates()
}
user := middlewares.GetPrincipal(r)
apiKeyValue := r.PostFormValue("api_key_value")
if apiKeyValue == user.ApiKey {
return actionResult{http.StatusBadRequest, "", "Main api key can only be regenerated, not deleted", nil}
}
apiKeys, err := h.apiKeySrvc.GetByUser(user.ID)
if err != nil {
return actionResult{http.StatusInternalServerError, "", "could not delete API key", nil}
}
for _, k := range apiKeys {
if k.ApiKey == apiKeyValue {
if err := h.apiKeySrvc.Delete(k); err != nil {
return actionResult{http.StatusInternalServerError, "", "could not delete API key", nil}
}
return actionResult{http.StatusOK, "API key deleted successfully", "", nil}
}
}
return actionResult{http.StatusNotFound, "", "API key not found", nil}
}
func (h *SettingsHandler) buildViewModel(r *http.Request, w http.ResponseWriter, args *map[string]interface{}) *view.SettingsViewModel {
user := middlewares.GetPrincipal(r)
@@ -971,6 +1031,35 @@ func (h *SettingsHandler) buildViewModel(r *http.Request, w http.ResponseWriter,
inviteCode := getVal[string](args, valueInviteCode, "")
inviteLink := condition.Ternary[bool, string](inviteCode == "", "", fmt.Sprintf("%s/signup?invite=%s", h.config.Server.GetPublicUrl(), inviteCode))
// API keys
combinedApiKeys := []*view.SettingsApiKeys{
{
ID: 0,
Name: "Main API Key",
Value: user.ApiKey,
ReadOnly: false,
},
}
apiKeys, err := h.apiKeySrvc.GetByUser(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while fetching user's api keys", "user", user.ID, "error", err)
return &view.SettingsViewModel{
SharedLoggedInViewModel: view.SharedLoggedInViewModel{
SharedViewModel: view.NewSharedViewModel(h.config, &view.Messages{Error: criticalError}),
User: user,
},
}
}
for _, apiKey := range apiKeys {
combinedApiKeys = append(combinedApiKeys, &view.SettingsApiKeys{
ID: apiKey.ID,
Name: apiKey.Label,
Value: apiKey.ApiKey,
ReadOnly: apiKey.ReadOnly,
})
}
vm := &view.SettingsViewModel{
SharedLoggedInViewModel: view.SharedLoggedInViewModel{
SharedViewModel: view.NewSharedViewModel(h.config, nil),
@@ -985,6 +1074,7 @@ func (h *SettingsHandler) buildViewModel(r *http.Request, w http.ResponseWriter,
SupportContact: h.config.App.SupportContact,
DataRetentionMonths: h.config.App.DataRetentionMonths,
InviteLink: inviteLink,
ApiKeys: combinedApiKeys,
}
// readme card params

86
services/api_key.go Normal file
View File

@@ -0,0 +1,86 @@
package services
import (
"errors"
"time"
"github.com/leandro-lugaresi/hub"
"github.com/patrickmn/go-cache"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
)
type ApiKeyService struct {
config *config.Config
cache *cache.Cache
eventBus *hub.Hub
repository repositories.IApiKeyRepository
}
func NewApiKeyService(apiKeyRepository repositories.IApiKeyRepository) *ApiKeyService {
return &ApiKeyService{
config: config.Get(),
eventBus: config.EventBus(),
repository: apiKeyRepository,
cache: cache.New(24*time.Hour, 24*time.Hour),
}
}
func (srv *ApiKeyService) GetById(id uint) (*models.ApiKey, error) {
return srv.repository.GetById(id)
}
func (srv *ApiKeyService) GetByApiKey(apiKey string) (*models.ApiKey, error) {
return srv.repository.GetByApiKey(apiKey)
}
func (srv *ApiKeyService) GetByRWApiKey(apiKey string) (*models.ApiKey, error) {
return srv.repository.GetByRWApiKey(apiKey)
}
func (srv *ApiKeyService) GetByUser(userId string) ([]*models.ApiKey, error) {
if labels, found := srv.cache.Get(userId); found {
return labels.([]*models.ApiKey), nil
}
labels, err := srv.repository.GetByUser(userId)
if err != nil {
return nil, err
}
srv.cache.Set(userId, labels, cache.DefaultExpiration)
return labels, nil
}
func (srv *ApiKeyService) Create(label *models.ApiKey) (*models.ApiKey, error) {
result, err := srv.repository.Insert(label)
if err != nil {
return nil, err
}
srv.cache.Delete(result.UserID)
srv.notifyUpdate(label, false)
return result, nil
}
func (srv *ApiKeyService) Delete(label *models.ApiKey) error {
if label.UserID == "" {
return errors.New("no user id specified")
}
err := srv.repository.Delete(label.ID)
srv.cache.Delete(label.UserID)
srv.notifyUpdate(label, true)
return err
}
func (srv *ApiKeyService) notifyUpdate(label *models.ApiKey, isDelete bool) {
name := config.EventApiKeyCreate
if isDelete {
name = config.EventApiKeyDelete
}
srv.eventBus.Publish(hub.Message{
Name: name,
Fields: map[string]interface{}{config.FieldPayload: label, config.FieldUserId: label.UserID},
})
}

View File

@@ -2,13 +2,15 @@ package services
import (
"errors"
"time"
"github.com/duke-git/lancet/v2/slice"
"github.com/leandro-lugaresi/hub"
"github.com/patrickmn/go-cache"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache"
"time"
)
type ProjectLabelService struct {

View File

@@ -4,10 +4,11 @@ import (
"time"
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"gorm.io/gorm"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/types"
"github.com/muety/wakapi/utils"
"gorm.io/gorm"
)
type IAggregationService interface {
@@ -148,6 +149,7 @@ type ILeaderboardService interface {
type IUserService interface {
GetUserById(string) (*models.User, error)
GetUserByKey(string) (*models.User, error)
GetUserByRWKey(string) (*models.User, error)
GetUserByEmail(string) (*models.User, error)
GetUserByResetToken(string) (*models.User, error)
GetUserByUnsubscribeToken(string) (*models.User, error)
@@ -173,3 +175,12 @@ type IUserService interface {
FlushCache()
FlushUserCache(string)
}
type IApiKeyService interface {
GetById(uint) (*models.ApiKey, error)
GetByApiKey(string) (*models.ApiKey, error)
GetByRWApiKey(string) (*models.ApiKey, error)
GetByUser(string) ([]*models.ApiKey, error)
Create(*models.ApiKey) (*models.ApiKey, error)
Delete(*models.ApiKey) error
}

View File

@@ -12,12 +12,13 @@ import (
"github.com/duke-git/lancet/v2/datetime"
"github.com/gofrs/uuid/v5"
"github.com/leandro-lugaresi/hub"
"github.com/patrickmn/go-cache"
"gorm.io/gorm"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
"gorm.io/gorm"
)
type UserService struct {
@@ -26,17 +27,19 @@ type UserService struct {
eventBus *hub.Hub
keyValueService IKeyValueService
mailService IMailService
apiKeyService IApiKeyService
repository repositories.IUserRepository
currentOnlineUsers *cache.Cache
countersInitialized atomic.Bool
}
func NewUserService(keyValueService IKeyValueService, mailService IMailService, userRepo repositories.IUserRepository) *UserService {
func NewUserService(keyValueService IKeyValueService, mailService IMailService, apiKeyService IApiKeyService, userRepo repositories.IUserRepository) *UserService {
srv := &UserService{
config: config.Get(),
eventBus: config.EventBus(),
cache: cache.New(1*time.Hour, 2*time.Hour),
keyValueService: keyValueService,
apiKeyService: apiKeyService,
mailService: mailService,
repository: userRepo,
currentOnlineUsers: cache.New(models.DefaultHeartbeatsTimeout, 1*time.Minute),
@@ -101,17 +104,48 @@ func (srv *UserService) GetUserByKey(key string) (*models.User, error) {
return nil, errors.New("key must not be empty")
}
// TODO: does it make sense to use cache by api key if it's not being used?
if u, ok := srv.cache.Get(key); ok {
return u.(*models.User), nil
}
u, err := srv.repository.FindOne(models.User{ApiKey: key})
if err != nil {
return nil, err
if err == nil {
srv.cache.SetDefault(u.ID, u)
return u, nil
}
srv.cache.SetDefault(u.ID, u)
return u, nil
apiKey, err := srv.apiKeyService.GetByApiKey(key)
if err == nil {
srv.cache.SetDefault(apiKey.User.ID, apiKey.User)
return apiKey.User, nil
}
return nil, err
}
func (srv *UserService) GetUserByRWKey(key string) (*models.User, error) {
if key == "" {
return nil, errors.New("key must not be empty")
}
if u, ok := srv.cache.Get(key); ok {
return u.(*models.User), nil
}
u, err := srv.repository.FindOne(models.User{ApiKey: key})
if err == nil {
srv.cache.SetDefault(u.ID, u)
return u, nil
}
apiKey, err := srv.apiKeyService.GetByApiKey(key)
if err == nil {
srv.cache.SetDefault(apiKey.User.ID, apiKey.User)
return apiKey.User, nil
}
return nil, err
}
func (srv *UserService) GetUserByEmail(email string) (*models.User, error) {

View File

@@ -1,13 +1,13 @@
env: production
env: dev
server:
listen_ipv4: 127.0.0.1
listen_ipv6:
tls_cert_path:
tls_key_path:
port: 3000
port: 3001
base_path: /
public_url: http://localhost:3000
public_url: http://localhost:3001
app:
aggregation_time: '02:15'

View File

@@ -35,7 +35,7 @@ if [ "${MIGRATION-0}" -eq 1 ]; then
fi
cleanup() {
if [ -n "$pid" ] && ps -p "$pid" > /dev/null; then
if [ -n "$pid" ] && ps "$pid" > /dev/null; then
kill -TERM "$pid"
fi
if [ "${docker_down-0}" -eq 1 ]; then
@@ -101,14 +101,14 @@ start_wakapi_background() {
path=$1
config=$2
"$path" -config "$config" &
"$path" -config "$config"
pid=$!
wait_for_wakapi
}
kill_wakapi() {
echo "Shutting down Wakapi ..."
kill -TERM $pid
kill -TERM $pid || true
}
# Run original wakapi
@@ -126,9 +126,8 @@ kill_wakapi
# Only sqlite has data
if [ "$DB_TYPE" == "sqlite" ]; then
echo "Creating database and schema ..."
sqlite3 wakapi_testing.db < schema.sql
echo "Importing seed data ..."
sqlite3 wakapi_testing.db < data.sql
sqlite3 testing/wakapi_testing.db < testing/schema.sql
sqlite3 testing/wakapi_testing.db < testing/data.sql
start_wakapi_background "../wakapi" "$config"
echo "Running test collection ..."
@@ -138,4 +137,14 @@ if [ "$DB_TYPE" == "sqlite" ]; then
fi
kill_wakapi
fi
fi
apk add curl nodejs npm sqlite
npm install -g @usebruno/cli
sqlite3 testing/wakapi_testing.db < testing/schema.sql
sqlite3 testing/wakapi_testing.db < testing/data.sql
rm testing/wakapi_testing.db && sqlite3 testing/wakapi_testing.db < testing/schema.sql && sqlite3 testing/wakapi_testing.db < testing/data.sql
WAKAPI_PASSWORD_SALT="" WAKAPI_DB_TYPE=sqlite WAKAPI_DB_NAME=testing/wakapi_testing.db dlv debug --listen=:4001 --headless=true --log=true --accept-multiclient --api-version=2 --continue /src/main.go -- -config testing/config.sqlite.yml

View File

@@ -1,24 +1,24 @@
meta {
name: Sign up user
type: http
seq: 1
}
// meta {
// name: Sign up user
// type: http
// seq: 1
// }
post {
url: {{BASE_URL}}/signup
body: formUrlEncoded
auth: none
}
// post {
// url: {{BASE_URL}}/signup
// body: formUrlEncoded
// auth: none
// }
body:form-urlencoded {
location: {{TZ}}
username: testuser
email: testuser@wakapi.dev
password: testpassword
password_repeat: testpassword
}
// body:form-urlencoded {
// location: {{TZ}}
// username: testuser1
// email: testuser1@wakapi.dev
// password: testpassword
// password_repeat: testpassword
// }
assert {
res.status: eq 200
res.body: contains Account created successfully
}
// assert {
// res.status: eq 200
// res.body: contains Account created successfully
// }

View File

@@ -8,7 +8,7 @@ auth {
}
vars:pre-request {
BASE_URL: http://localhost:3000
BASE_URL: http://localhost:3001
READUSER_API_KEY: 33e7f538-0dce-4eba-8ffe-53db6814ed42
WRITEUSER_API_KEY: f7aa255c-8647-4d0b-b90f-621c58fd580f
TZ: Europe/Berlin
@@ -19,10 +19,10 @@ vars:pre-request {
script:pre-request {
const moment = require('moment')
// pretend we're in Berlin, as this is also the time zone configured for the user
const userZone = 'Europe/Berlin'
// https://stackoverflow.com/a/63199512
// return UTC offset of timezone in minutes
function getTimezoneOffset(timeZone, date = new Date()) {
@@ -31,32 +31,32 @@ script:pre-request {
const offset = Date.parse(`${dateString} UTC`) - Date.parse(`${dateString} ${tz}`);
return offset / 1000 / 60;
}
const utcOffset = getTimezoneOffset(userZone)
bru.setVar('utcOffset', utcOffset)
const now = moment().utcOffset(utcOffset)
const startOfDay = now.clone().startOf('day')
const endOfDay = now.clone().endOf('day')
const endOfTomorrow = now.clone().add(1, 'd').endOf('day')
const startOfYesterday = now.clone().add(-1, 'day').startOf('day')
const endOfYesterday = now.clone().add(-1, 'day').endOf('day')
// Auth stuff
const readApiKey = bru.getCollectionVar('READUSER_API_KEY')
const writeApiKey = bru.getCollectionVar('WRITEUSER_API_KEY')
if (!readApiKey || !writeApiKey) {
throw new Error('no api key given')
}
bru.setVar('READUSER_TOKEN', base64encode(readApiKey))
bru.setVar('WRITEUSER_TOKEN', base64encode(writeApiKey))
function base64encode(str) {
return Buffer.from(str, 'utf-8').toString('base64')
}
// Heartbeat stuff
bru.setVar('tsNow', now.clone().format('x') / 1000)
bru.setVar('tsNowMinus1Min', now.clone().add(-1, 'm').format('x') / 1000)

View File

@@ -54,6 +54,9 @@
<a href="settings#subscription" @click="updateTab">Subscription</a>
</li>
{{ end }}
<li class="font-semibold text-2xl" v-bind:class="{ 'text-foreground': isActive('api_keys'), 'hover:text-secondary': !isActive('api_keys') }">
<a href="settings#api_keys" @click="updateTab">API Keys</a>
</li>
<li class="font-semibold text-2xl" v-bind:class="{ 'text-foreground': isActive('danger_zone'), 'hover:text-secondary': !isActive('danger_zone') }">
<a href="settings#danger_zone" @click="updateTab">Danger Zone</a>
</li>
@@ -899,6 +902,97 @@
</div>
{{ end }}
<div v-cloak id="api_keys" class="tab flex flex-col space-y-4" v-if="isActive('api_keys')">
<div class="w-full lg:w-3/4">
<form action="" method="post" class="flex mb-8">
<input type="hidden" name="action" value="reset_apikey">
<div class="w-1/2 mr-4">
<span class="font-semibold text-foreground text-lg">Reset Main API Key</span>
<span class="block text-sm text-muted">
Please note that resetting your API key requires you to update your .wakatime.cfg files on all of your computers to make the WakaTime client send heartbeats again.
</span>
</div>
<div class="w-1/2 ml-4 flex items-center justify-end">
<button type="submit" class="btn-danger ml-1">Reset API key</button>
</div>
</form>
</div>
<div class="flex w-full lg:w-3/4" id="form-generate-api-key">
<div class="w-1/2 mr-4">
<span class="font-semibold text-foreground text-lg">Generate API Key</span>
<span class="block text-sm text-muted">
Add a new API key to access the Wakapi API. You can have multiple API keys active at the same time.
</span>
</div>
<form action="" method="post" class="flex w-full ml-4 justify-end">
<input type="hidden" name="action" value="add_api_key">
<div class="flex flex-col gap-2 items-end">
<input class="appearance-none bg-card text-foreground outline-none rounded py-2 px-4 focus:bg-focused"
type="text" id="api-name" name="api-name" placeholder="Key Name" minlength="1"
maxlength="64" required>
<div class="flex gap-2 items-center justify-end mt-4 float-left">
<label class="font-semibold text-foreground" for="api-readonly">Read Only</label>
<div>
<select autocomplete="off" id="api-readonly" name="api-readonly" class="select-default grow">
<option value="false" class="cursor-pointer">No</option>
<option value="true" class="cursor-pointer">Yes</option>
</select>
</div>
</div>
<div class="flex items-center justify-end">
<button type="submit" class="w-1/2 btn-primary justify-end float-right mt-4">Generate</button>
</div>
</div>
</form>
</div>
<div class="w-full lg:w-3/4">
<span class="flex font-semibold text-foreground text-lg mb-4">API Keys</span>
<table class="w-full">
<thead>
<tr>
<th class="text-left py-2 text-muted w-1/4">Name</th>
<th class="text-left py-2 text-muted w-1/2">Key</th>
<th class="text-center py-2 text-muted w-1/6">Type</th>
<th class="text-center py-2 text-muted w-1/6">Actions</th>
</tr>
</thead>
<tbody>
{{ range $i, $ApiKey := .ApiKeys }}
<tr>
<td class="py-2 text-foreground">{{ $ApiKey.Name }}</td>
<td class="py-2 text-muted font-mono text-sm">{{ $ApiKey.Value }}</td>
<td class="py-2 text-center">
{{ if $ApiKey.ReadOnly }}
<span class=" rounded-full text-xs">
Read Only
</span>
{{ else }}
<span class="rounded-full text-xs text-accent">
Full Access
</span>
{{ end }}
</td>
<td class="py-2 text-center">
<form action="" method="post" class="inline">
<input type="hidden" name="action" value="delete_api_key">
<input type="hidden" name="api_key_value" value="{{ $ApiKey.Value }}">
<button type="submit" class="py-2 px-4 rounded bg-card hover:bg-focused text-danger text-sm" title="Delete API Key"></button>
</form>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
<div v-cloak id="danger_zone" class="tab flex flex-col space-y-4" v-if="isActive('danger_zone')">
<div class="w-full lg:w-3/4">
<form action="" method="post" class="flex mb-8" id="form-regenerate-summaries">
@@ -915,20 +1009,6 @@
</div>
</form>
<form action="" method="post" class="flex mb-8">
<input type="hidden" name="action" value="reset_apikey">
<div class="w-1/2 mr-4 inline-block">
<span class="font-semibold text-foreground">Reset API Key</span>
<span class="block text-sm text-muted">
Please note that resetting your API key requires you to update your .wakatime.cfg files on all of your computers to make the WakaTime client send heartbeats again.
</span>
</div>
<div class="w-1/2 ml-4 flex items-center">
<button type="submit" class="btn-danger ml-1">Reset API key</button>
</div>
</form>
<form action="" method="post" class="flex mb-8" id="form-clear-data">
<input type="hidden" name="action" value="clear_data">