mirror of
https://github.com/muety/wakapi.git
synced 2025-12-05 22:20:24 -08:00
feat: add support for multiple API keys in addition to the user key
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
.env
|
||||
config*.yml
|
||||
!config*.yml
|
||||
!config.default.yml
|
||||
*.db
|
||||
*.exe
|
||||
|
||||
49
Dockerfile
49
Dockerfile
@@ -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 /
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
vars {
|
||||
BASE_URL: http://localhost:3000
|
||||
BASE_URL: http://localhost:3001
|
||||
}
|
||||
vars:secret [
|
||||
API_KEY
|
||||
|
||||
68
compose.yml
68
compose.yml
@@ -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: {}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
14
main.go
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
14
models/api_key.go
Normal 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 != ""
|
||||
}
|
||||
@@ -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
81
repositories/api_key.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
86
services/api_key.go
Normal 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},
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
// }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
|
||||
Reference in New Issue
Block a user