Merge remote-tracking branch 'origin/master'

This commit is contained in:
Ferdinand Mütsch
2025-11-26 18:21:45 +01:00
43 changed files with 3905 additions and 2822 deletions

6
.github/assets/tuta_logo.svg vendored Normal file
View File

@@ -0,0 +1,6 @@
<svg width="892" height="316" viewBox="0 0 892 316" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M761.169 183.664C761.169 155.945 782.541 122.871 818.904 122.871H845.698L836.129 153.11C824.965 189.334 807.102 211.698 785.411 211.698C769.781 211.698 761.169 200.989 761.169 183.664ZM624.965 168.545C611.568 211.068 631.982 240.363 678.234 240.363C684.933 240.363 693.864 239.733 696.416 239.103C697.692 238.788 698.33 238.158 698.968 236.583L706.942 210.123C707.261 208.549 706.942 207.289 704.71 207.604C697.692 208.234 691.312 208.864 685.571 208.864C662.285 208.864 652.716 196.579 659.734 174.529L676.001 122.871H723.21C724.486 122.871 725.443 122.241 726.081 120.666L734.375 93.8915C734.694 92.3165 734.056 91.3715 732.142 91.3715H685.89L691.95 71.8421C692.269 70.5821 691.95 69.6371 690.994 68.6921L666.432 46.6427C665.156 45.3827 663.561 45.6977 662.923 47.5877L624.965 168.545ZM462.286 169.49C448.569 212.643 470.579 240.993 505.029 240.993C524.805 240.993 540.435 231.543 551.6 216.108L551.281 235.953C551.281 237.843 552.238 238.473 553.833 238.473H575.842C577.437 238.473 578.075 237.843 578.713 236.268L623.051 94.2065C623.689 92.3165 622.732 91.3715 621.137 91.3715H589.558C587.963 91.3715 587.006 92.0015 586.687 93.5765L567.868 153.425C556.065 190.594 537.565 211.698 518.107 211.698C498.649 211.698 490.356 196.579 497.692 173.269L522.573 94.2065C523.211 92.3165 522.254 91.3715 520.659 91.3715H489.08C487.485 91.3715 486.847 92.0015 486.209 93.5765L462.286 169.49ZM354.79 168.545C341.393 211.068 361.807 240.363 408.059 240.363C414.758 240.363 423.689 239.733 426.241 239.103C427.517 238.788 428.155 238.158 428.793 236.583L436.767 210.123C437.086 208.549 436.767 207.289 434.534 207.604C427.517 208.234 421.137 208.864 415.396 208.864C392.11 208.864 382.541 196.579 389.558 174.529L405.826 122.871H453.035C454.311 122.871 455.587 122.241 455.906 120.666L464.199 93.8915C464.518 92.3165 463.88 91.3715 461.967 91.3715H415.396L421.456 71.8421C421.775 70.5821 421.456 69.6371 420.499 68.6921L395.938 46.6427C394.662 45.3827 393.067 45.6977 392.429 47.5877L354.79 168.545ZM773.928 240.993C792.429 240.993 807.74 232.803 819.223 216.108V235.953C819.223 237.528 820.18 238.473 821.775 238.473H843.784C845.379 238.473 846.017 237.843 846.655 236.268L891.312 93.8915C891.631 92.3165 890.993 91.0566 889.398 91.0566H819.861C751.919 91.0566 725.443 149.33 725.443 187.129C725.443 220.203 746.177 240.993 773.928 240.993Z" fill="#410002"/>
<path d="M25.284 3.80373L79.8294 58.2973C81.1053 59.5573 82.3812 59.8723 84.2951 59.8723H314.279C315.874 59.8723 316.831 57.9823 315.236 56.4074L261.329 2.54376C260.053 1.28379 258.777 0.653809 256.225 0.653809H26.5599C24.3271 0.653809 24.0081 2.54376 25.284 3.80373Z" fill="#850122"/>
<path d="M17.3097 313.125C16.9907 314.385 17.6287 315.645 19.2236 315.645H246.018C248.251 315.645 249.208 314.7 249.846 312.81L318.426 94.206C319.064 92.0011 318.107 91.3711 316.193 91.3711H88.761C86.8472 91.3711 86.2092 92.0011 85.5713 93.576L17.3097 313.125Z" fill="#850122"/>
<path d="M0.0848389 250.126C0.0848389 252.646 3.27463 252.646 3.91259 250.126L55.9062 82.8657C56.5441 80.9757 56.5441 79.7158 54.9492 78.1408L3.27463 27.4271C1.99871 26.1671 0.0848389 26.7971 0.0848389 28.3721V250.126Z" fill="#850122"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -575,9 +575,12 @@ Coding in open source is my passion, and I would love to do it on a full-time ba
I highly appreciate the efforts of **[@alanhamlett](https://github.com/alanhamlett)** and the WakaTime team and am thankful for their software being open source.
Moreover, thanks to **[server.camp](https://server.camp)** for sponsoring server infrastructure for Wakapi.dev.
Moreover, thanks to **[server.camp](https://server.camp)** for donating server infrastructure for Wakapi.dev and [Tuta](https://tuta.com) to support us with their open-source sponsorship program.
<img src=".github/assets/servercamp_logo.png" width="220px" />
<div>
<img src=".github/assets/servercamp_logo.png" width="200px" />
<img src=".github/assets/tuta_logo.svg" width="200px"/>
</div>
## 📓 License

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

11
main.go
View File

@@ -8,6 +8,7 @@ import (
"log/slog"
"net"
"net/http"
_ "net/http/pprof"
"os"
"strconv"
"time"
@@ -38,8 +39,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
@@ -69,6 +68,7 @@ var (
diagnosticsRepository repositories.IDiagnosticsRepository
metricsRepository *repositories.MetricsRepository
durationRepository *repositories.DurationRepository
apiKeyRepository repositories.IApiKeyRepository
)
var (
@@ -88,6 +88,7 @@ var (
diagnosticsService services.IDiagnosticsService
housekeepingService services.IHousekeepingService
miscService services.IMiscService
apiKeyService services.IApiKeyService
)
// TODO: Refactor entire project to be structured after business domains
@@ -173,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)
@@ -234,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,14 +34,16 @@ type AuthenticateMiddleware struct {
optionalForMethods []string
redirectTarget string // optional
redirectErrorMessage string // optional
requireFullAccessKey bool // true only for heartbeat routes
}
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
return &AuthenticateMiddleware{
config: conf.Get(),
userSrvc: userService,
optionalForPaths: []string{},
optionalForMethods: []string{},
config: conf.Get(),
userSrvc: userService,
optionalForPaths: []string{},
optionalForMethods: []string{},
requireFullAccessKey: false,
}
}
@@ -65,6 +67,11 @@ func (m *AuthenticateMiddleware) WithRedirectErrorMessage(message string) *Authe
return m
}
func (m *AuthenticateMiddleware) WithFullAccessOnly(requireFullAccess bool) *AuthenticateMiddleware {
m.requireFullAccessKey = requireFullAccess
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)
@@ -138,7 +145,7 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKeyHeader(r *http.Request) (*mod
var user *models.User
userKey := strings.TrimSpace(key)
user, err = m.userSrvc.GetUserByKey(userKey)
user, err = m.userSrvc.GetUserByKey(userKey, m.requireFullAccessKey)
if err != nil {
return nil, err
}
@@ -152,7 +159,7 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKeyQuery(r *http.Request) (*mode
if userKey == "" {
return nil, errEmptyKey
}
user, err := m.userSrvc.GetUserByKey(userKey)
user, err := m.userSrvc.GetUserByKey(userKey, m.requireFullAccessKey)
if err != nil {
return nil, err
}

View File

@@ -11,20 +11,22 @@ 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) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
testUser := &models.User{ApiKey: testApiKey}
// In the case of the API Key from User Model - it's always full access
testApiKeyRequireFullAccess := false
mockRequest := &http.Request{
Header: http.Header{
@@ -33,7 +35,7 @@ func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Success(t *testing.T) {
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
userServiceMock.On("GetUserByKey", testApiKey, testApiKeyRequireFullAccess).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock)
@@ -57,6 +59,29 @@ func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Invalid(t *testing.T) {
userServiceMock := new(mocks.UserServiceMock)
sut := NewAuthenticateMiddleware(userServiceMock)
sut.WithFullAccessOnly(false)
result, err := sut.tryGetUserByApiKeyHeader(mockRequest)
assert.Error(t, err)
assert.Nil(t, result)
}
func TestAuthenticateMiddleware_tryGetUserByApiKeyHeaderWithReadOnlyKey_Invalid(t *testing.T) {
testApiKey := "read-only-additional-key"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
mockRequest := &http.Request{
Header: http.Header{
"Authorization": []string{fmt.Sprintf("Basic %s", testToken)},
},
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey, true).Return(nil, errors.New("forbidden: requires full access"))
sut := NewAuthenticateMiddleware(userServiceMock)
sut.WithFullAccessOnly(true)
result, err := sut.tryGetUserByApiKeyHeader(mockRequest)
@@ -67,6 +92,8 @@ func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Invalid(t *testing.T) {
func TestAuthenticateMiddleware_tryGetUserByApiKeyQuery_Success(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testUser := &models.User{ApiKey: testApiKey}
// In the case of the API Key from User Model - it's always full access
testApiKeyRequireFullAccess := true
params := url.Values{}
params.Add("api_key", testApiKey)
@@ -77,9 +104,10 @@ func TestAuthenticateMiddleware_tryGetUserByApiKeyQuery_Success(t *testing.T) {
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
userServiceMock.On("GetUserByKey", testApiKey, testApiKeyRequireFullAccess).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock)
sut.WithFullAccessOnly(true)
result, err := sut.tryGetUserByApiKeyQuery(mockRequest)

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
}
}

39
mocks/api_key_service.go Normal file
View File

@@ -0,0 +1,39 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type MockApiKeyService struct {
mock.Mock
}
func (m *MockApiKeyService) GetByApiKey(apiKey string, requireFullAccessKey bool) (*models.ApiKey, error) {
args := m.Called(apiKey, requireFullAccessKey)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.ApiKey), args.Error(1)
}
func (m *MockApiKeyService) GetByUser(userID string) ([]*models.ApiKey, error) {
args := m.Called(userID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.ApiKey), args.Error(1)
}
func (m *MockApiKeyService) Create(apiKey *models.ApiKey) (*models.ApiKey, error) {
args := m.Called(apiKey)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.ApiKey), args.Error(1)
}
func (m *MockApiKeyService) Delete(apiKey *models.ApiKey) error {
args := m.Called(apiKey)
return args.Error(0)
}

37
mocks/mail_service.go Normal file
View File

@@ -0,0 +1,37 @@
package mocks
import (
"time"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type MailServiceMock struct {
mock.Mock
}
func (m *MailServiceMock) SendPasswordReset(user *models.User, resetLink string) error {
args := m.Called(user, resetLink)
return args.Error(0)
}
func (m *MailServiceMock) SendWakatimeFailureNotification(user *models.User, numFailures int) error {
args := m.Called(user, numFailures)
return args.Error(0)
}
func (m *MailServiceMock) SendImportNotification(user *models.User, duration time.Duration, numHeartbeats int) error {
args := m.Called(user, duration, numHeartbeats)
return args.Error(0)
}
func (m *MailServiceMock) SendReport(user *models.User, report *models.Report) error {
args := m.Called(user, report)
return args.Error(0)
}
func (m *MailServiceMock) SendSubscriptionNotification(user *models.User, hasExpired bool) error {
args := m.Called(user, hasExpired)
return args.Error(0)
}

125
mocks/user_repository.go Normal file
View File

@@ -0,0 +1,125 @@
package mocks
import (
"time"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
"gorm.io/gorm"
)
type UserRepositoryMock struct {
BaseRepositoryMock
mock.Mock
}
func (m *UserRepositoryMock) FindOne(user models.User) (*models.User, error) {
args := m.Called(user)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetByIds(userIds []string) ([]*models.User, error) {
args := m.Called(userIds)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetAll() ([]*models.User, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetMany(ids []string) ([]*models.User, error) {
args := m.Called(ids)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetAllByReports(reportsEnabled bool) ([]*models.User, error) {
args := m.Called(reportsEnabled)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetAllByLeaderboard(leaderboardEnabled bool) ([]*models.User, error) {
args := m.Called(leaderboardEnabled)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetByLoggedInBefore(t time.Time) ([]*models.User, error) {
args := m.Called(t)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
args := m.Called(t)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetByLastActiveAfter(t time.Time) ([]*models.User, error) {
args := m.Called(t)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) Count() (int64, error) {
args := m.Called()
return args.Get(0).(int64), args.Error(1)
}
func (m *UserRepositoryMock) InsertOrGet(user *models.User) (*models.User, bool, error) {
args := m.Called(user)
if args.Get(0) == nil {
return nil, args.Bool(1), args.Error(2)
}
return args.Get(0).(*models.User), args.Bool(1), args.Error(2)
}
func (m *UserRepositoryMock) Update(user *models.User) (*models.User, error) {
args := m.Called(user)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserRepositoryMock) UpdateField(user *models.User, key string, value interface{}) (*models.User, error) {
args := m.Called(user, key, value)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserRepositoryMock) Delete(user *models.User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *UserRepositoryMock) DeleteTx(user *models.User, tx *gorm.DB) error {
args := m.Called(user, tx)
return args.Error(0)
}

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 {
@@ -17,8 +18,11 @@ func (m *UserServiceMock) GetUserById(s string) (*models.User, error) {
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetUserByKey(s string) (*models.User, error) {
args := m.Called(s)
func (m *UserServiceMock) GetUserByKey(s string, r bool) (*models.User, error) {
args := m.Called(s, r)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}

13
models/api_key.go Normal file
View File

@@ -0,0 +1,13 @@
package models
type ApiKey struct {
ApiKey string `json:"api_key" 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"`
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,12 @@ type SettingsVMCombinedLabel struct {
Values []string
}
type SettingsApiKeys struct {
Name string
Value string
ReadOnly bool
}
func (s *SettingsViewModel) SubscriptionsEnabled() bool {
return s.SubscriptionPrice != ""
}

71
repositories/api_key.go Normal file
View File

@@ -0,0 +1,71 @@
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) GetByApiKey(apiKey string, requireFullAccessKey bool) (*models.ApiKey, error) {
key := &models.ApiKey{}
query := r.db.Preload("User").Where("api_key = ?", apiKey)
if requireFullAccessKey {
query = query.Where("read_only = ?", false)
}
if err := query.First(key).Error; err != nil {
return nil, 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(apiKey string) error {
return r.db.
Where("api_key = ?", apiKey).
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,12 @@ 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)
GetByUser(string) ([]*models.ApiKey, error)
GetByApiKey(string, bool) (*models.ApiKey, error)
Insert(*models.ApiKey) (*models.ApiKey, error)
Delete(string) 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).WithFullAccessOnly(true).Handler,
customMiddleware.NewWakatimeRelayMiddleware().Handler,
)
// see https://github.com/muety/wakapi/issues/203

View File

@@ -46,9 +46,9 @@ func TestUsersHandler_Get(t *testing.T) {
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserById", "AdminUser").Return(adminUser, nil)
userServiceMock.On("GetUserByKey", "admin-user-api-key").Return(adminUser, nil)
userServiceMock.On("GetUserByKey", "admin-user-api-key", false).Return(adminUser, nil)
userServiceMock.On("GetUserById", "BasicUser").Return(basicUser, nil)
userServiceMock.On("GetUserByKey", "basic-user-api-key").Return(basicUser, nil)
userServiceMock.On("GetUserByKey", "basic-user-api-key", false).Return(basicUser, nil)
heartbeatServiceMock := new(mocks.HeartbeatServiceMock)
heartbeatServiceMock.On("GetLatestByUser", adminUser).Return(&models.Heartbeat{

View File

@@ -77,7 +77,7 @@ func TestHomeHandler_Get_LoggedIn(t *testing.T) {
router.Use(middlewares.NewSharedDataMiddleware())
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", user1.ApiKey).Return(&user1, nil)
userServiceMock.On("GetUserByKey", user1.ApiKey, false).Return(&user1, nil)
userServiceMock.On("GetUserById", user1.ID).Return(&user1, nil)
keyValueServiceMock := new(mocks.KeyValueServiceMock)

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,33 @@ 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{
{
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{
Name: apiKey.Label,
Value: apiKey.ApiKey,
ReadOnly: apiKey.ReadOnly,
})
}
vm := &view.SettingsViewModel{
SharedLoggedInViewModel: view.SharedLoggedInViewModel{
SharedViewModel: view.NewSharedViewModel(h.config, nil),
@@ -985,6 +1072,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

103
services/api_key.go Normal file
View File

@@ -0,0 +1,103 @@
package services
import (
"errors"
"strings"
"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 {
srv := &ApiKeyService{
config: config.Get(),
eventBus: config.EventBus(),
repository: apiKeyRepository,
cache: cache.New(24*time.Hour, 24*time.Hour),
}
onApiKeyCreate := srv.eventBus.Subscribe(0, config.EventApiKeyCreate)
go func(sub *hub.Subscription) {
for m := range sub.Receiver {
srv.invalidateUserCache(m.Fields[config.FieldUserId].(string))
}
}(&onApiKeyCreate)
onApiKeyDelete := srv.eventBus.Subscribe(0, config.EventApiKeyDelete)
go func(sub *hub.Subscription) {
for m := range sub.Receiver {
srv.invalidateUserCache(m.Fields[config.FieldUserId].(string))
}
}(&onApiKeyDelete)
return srv
}
func (srv *ApiKeyService) GetByApiKey(apiKey string, requireFullAccessKey bool) (*models.ApiKey, error) {
return srv.repository.GetByApiKey(apiKey, requireFullAccessKey)
}
func (srv *ApiKeyService) GetByUser(userId string) ([]*models.ApiKey, error) {
if userApiKeys, found := srv.cache.Get(userId); found {
return userApiKeys.([]*models.ApiKey), nil
}
userApiKeys, err := srv.repository.GetByUser(userId)
if err != nil {
return nil, err
}
srv.cache.Set(userId, userApiKeys, cache.DefaultExpiration)
return userApiKeys, nil
}
func (srv *ApiKeyService) Create(apiKey *models.ApiKey) (*models.ApiKey, error) {
result, err := srv.repository.Insert(apiKey)
if err != nil {
return nil, err
}
srv.cache.Delete(result.UserID)
srv.notifyUpdate(apiKey, false)
return result, nil
}
func (srv *ApiKeyService) Delete(apiKey *models.ApiKey) error {
if apiKey.UserID == "" {
return errors.New("no user id specified")
}
err := srv.repository.Delete(apiKey.ApiKey)
srv.cache.Delete(apiKey.UserID)
srv.notifyUpdate(apiKey, true)
return err
}
func (srv *ApiKeyService) notifyUpdate(apiKey *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: apiKey, config.FieldUserId: apiKey.UserID},
})
}
func (srv *ApiKeyService) invalidateUserCache(userId string) {
for key := range srv.cache.Items() {
if strings.Contains(key, userId) {
srv.cache.Delete(key)
}
}
}

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 {
@@ -147,7 +148,7 @@ type ILeaderboardService interface {
type IUserService interface {
GetUserById(string) (*models.User, error)
GetUserByKey(string) (*models.User, error)
GetUserByKey(string, bool) (*models.User, error)
GetUserByEmail(string) (*models.User, error)
GetUserByResetToken(string) (*models.User, error)
GetUserByUnsubscribeToken(string) (*models.User, error)
@@ -173,3 +174,10 @@ type IUserService interface {
FlushCache()
FlushUserCache(string)
}
type IApiKeyService interface {
GetByApiKey(string, bool) (*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),
@@ -96,7 +99,7 @@ func (srv *UserService) GetUserById(userId string) (*models.User, error) {
return u, nil
}
func (srv *UserService) GetUserByKey(key string) (*models.User, error) {
func (srv *UserService) GetUserByKey(key string, requireFullAccessKey bool) (*models.User, error) {
if key == "" {
return nil, errors.New("key must not be empty")
}
@@ -106,12 +109,18 @@ func (srv *UserService) GetUserByKey(key string) (*models.User, error) {
}
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, requireFullAccessKey)
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) {

103
services/user_test.go Normal file
View File

@@ -0,0 +1,103 @@
package services
import (
"errors"
"testing"
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/patrickmn/go-cache"
"github.com/stretchr/testify/suite"
)
const (
TestUserID = "muety"
TestAPIKey = "full-access-key-from-user-model"
)
type UserServiceTestSuite struct {
suite.Suite
TestUser *models.User
KeyValueService *mocks.KeyValueServiceMock
MailService *mocks.MailServiceMock
ApiKeyService *mocks.MockApiKeyService
UserRepo *mocks.UserRepositoryMock
}
func (suite *UserServiceTestSuite) SetupSuite() {
suite.TestUser = &models.User{ID: TestUserID, ApiKey: TestAPIKey}
}
func (suite *UserServiceTestSuite) BeforeTest(suiteName, testName string) {
suite.KeyValueService = new(mocks.KeyValueServiceMock)
suite.MailService = new(mocks.MailServiceMock)
suite.ApiKeyService = new(mocks.MockApiKeyService)
suite.UserRepo = new(mocks.UserRepositoryMock)
}
func TestUserServiceTestSuite(t *testing.T) {
suite.Run(t, new(UserServiceTestSuite))
}
func (suite *UserServiceTestSuite) TestUserService_GetByEmptyKey_Failed() {
sut := NewUserService(suite.KeyValueService, suite.MailService, suite.ApiKeyService, suite.UserRepo)
result, err := sut.GetUserByKey("", false)
suite.Nil(result)
suite.NotNil(err)
suite.Equal(err, errors.New("key must not be empty"))
}
func (suite *UserServiceTestSuite) TestUserService_GetByKeyFromCache_Success() {
userCached := &models.User{ID: TestUserID, ApiKey: "cached-key"}
userCache := cache.New(cache.NoExpiration, cache.NoExpiration)
userCache.SetDefault(TestAPIKey, userCached)
sut := &UserService{cache: userCache}
result, err := sut.GetUserByKey(TestAPIKey, false)
suite.Nil(err)
suite.NotNil(result)
suite.Equal(1, userCache.ItemCount())
suite.Equal(userCached, result)
suite.Equal(userCached.ApiKey, result.ApiKey)
}
func (suite *UserServiceTestSuite) TestUserService_GetByKeyFromUserModel_Success() {
sut := NewUserService(suite.KeyValueService, suite.MailService, suite.ApiKeyService, suite.UserRepo)
suite.UserRepo.On("FindOne", models.User{ApiKey: TestAPIKey}).Return(suite.TestUser, nil)
result, err := sut.GetUserByKey(TestAPIKey, false)
suite.Nil(err)
suite.NotNil(result)
suite.Equal(suite.TestUser, result)
suite.Equal(suite.TestUser.ApiKey, result.ApiKey)
}
func (suite *UserServiceTestSuite) TestUserService_GetByKeyFromAdditionalApiKeys_Success() {
sut := NewUserService(suite.KeyValueService, suite.MailService, suite.ApiKeyService, suite.UserRepo)
suite.UserRepo.On("FindOne", models.User{ApiKey: TestAPIKey}).Return(nil, errors.New("not found"))
suite.ApiKeyService.On("GetByApiKey", TestAPIKey, true).Return(&models.ApiKey{User: suite.TestUser}, nil)
result, err := sut.GetUserByKey(TestAPIKey, true)
suite.Nil(err)
suite.NotNil(result)
suite.Equal(suite.TestUser, result)
suite.Equal(suite.TestUser.ApiKey, result.ApiKey)
}
func (suite *UserServiceTestSuite) TestUserService_GetByKeyFromAdditionalApiKeys_Failed() {
sut := NewUserService(suite.KeyValueService, suite.MailService, suite.ApiKeyService, suite.UserRepo)
suite.UserRepo.On("FindOne", models.User{ApiKey: TestAPIKey}).Return(nil, errors.New("not found"))
suite.ApiKeyService.On("GetByApiKey", TestAPIKey, true).Return(nil, errors.New("not found"))
result, err := sut.GetUserByKey(TestAPIKey, true)
suite.Nil(result)
suite.NotNil(err)
suite.Equal(err, errors.New("not found"))
}

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -24,4 +24,9 @@ INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_
VALUES ('writeuser', 'f7aa255c-8647-4d0b-b90f-621c58fd580f', '', 'Europe/Berlin',
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:56',
'2021-05-28 14:35:05.118+02:00', 7, 0, 0, 1, 0, 0, 0, 1, '', '', 0);
INSERT INTO "api_keys" ("api_key", "user_id", "label", "read_only")
VALUES
('1c91f670-2309-45fb-9d7e-738c766e85a6', 'writeuser', 'Full Access Key', false),
('774f7e16-b9a3-433e-ac68-7e28a82a50ca', 'writeuser', 'Read Only Key', true);
COMMIT;

View File

@@ -26,6 +26,8 @@ DROP TABLE IF EXISTS "summary_items";
CREATE TABLE `summary_items` (`id` integer PRIMARY KEY AUTOINCREMENT,`summary_id` integer,`type` integer,`key` text,`total` integer,CONSTRAINT `fk_summaries_projects` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "users";
CREATE TABLE `users` (`id` text,`api_key` text DEFAULT NULL,`email` text,`location` text,`password` text,`created_at` timestamp,`last_logged_in_at` timestamp,`share_data_max_days` integer,`share_editors` numeric DEFAULT false,`share_languages` numeric DEFAULT false,`share_projects` numeric DEFAULT false,`share_oss` numeric DEFAULT false,`share_machines` numeric DEFAULT false,`share_labels` numeric DEFAULT false,`share_activity_chart` numeric DEFAULT false,`is_admin` numeric DEFAULT false,`has_data` numeric DEFAULT false,`wakatime_api_key` text,`wakatime_api_url` text,`reset_token` text,`reports_weekly` numeric DEFAULT false,`public_leaderboard` numeric DEFAULT false,`subscribed_until` timestamp,`subscription_renewal` timestamp,`stripe_customer_id` text,`invited_by` text,`exclude_unknown_projects` numeric,`heartbeats_timeout_sec` integer DEFAULT 600,PRIMARY KEY (`id`),CONSTRAINT `uni_users_api_key` UNIQUE (`api_key`));
DROP TABLE IF EXISTS "api_keys";
CREATE TABLE `api_keys` (`api_key` text,`user_id` text NOT NULL,`label` varchar(64) NOT NULL,`read_only` boolean DEFAULT false,PRIMARY KEY (`api_key`));
DROP INDEX IF EXISTS "idx_alias_type_key";
CREATE INDEX `idx_alias_type_key` ON `aliases`(`type`,`key`);
DROP INDEX IF EXISTS "idx_alias_user";
@@ -68,4 +70,6 @@ DROP INDEX IF EXISTS "idx_user_email";
CREATE INDEX `idx_user_email` ON `users`(`email`);
DROP INDEX IF EXISTS "idx_user_project";
CREATE INDEX `idx_user_project` ON `heartbeats`(`user_id`,`project`);
DROP INDEX IF EXISTS "idx_api_key_user";
CREATE INDEX `idx_api_key_user` ON `api_keys`(`user_id`);
COMMIT;

View File

@@ -0,0 +1,23 @@
meta {
name: Authenticate (header, full access key)
type: http
seq: 7
}
get {
url: {{BASE_URL}}/api/summary?interval=today
body: none
auth: bearer
}
params:query {
interval: today
}
auth:bearer {
token: {{ADDITIONAL_FULL_ACCESS_TOKEN}}
}
assert {
res.status: eq 200
}

View File

@@ -0,0 +1,23 @@
meta {
name: Authenticate (header, readonly key)
type: http
seq: 8
}
get {
url: {{BASE_URL}}/api/summary?interval=today
body: none
auth: bearer
}
params:query {
interval: today
}
auth:bearer {
token: {{ADDITIONAL_READ_ONLY_TOKEN}}
}
assert {
res.status: eq 200
}

View File

@@ -0,0 +1,40 @@
meta {
name: Create heartbeats (alt 8, single, additional full access key)
type: http
seq: 9
}
post {
url: {{BASE_URL}}/api/users/current/heartbeats
body: json
auth: bearer
}
auth:bearer {
token: {{ADDITIONAL_FULL_ACCESS_TOKEN}}
}
body:json {
{
"entity": "/home/user1/dev/project1/main.go",
"project": "wakapi",
"language": "Go",
"is_write": true,
"type": "file",
"category": null,
"branch": null,
"time": {{ts1}}
}
}
assert {
res.status: eq 201
}
tests {
test("Response body is correct", function () {
expect(res.body.responses.length).to.eql(1);
expect(res.body.responses[0].length).to.eql(2);
expect(res.body.responses[0][1]).to.eql(201);
});
}

View File

@@ -1,7 +1,7 @@
meta {
name: Create heartbeats (get heartbeats test)
type: http
seq: 11
seq: 12
}
post {

View File

@@ -1,7 +1,7 @@
meta {
name: Create heartbeats (non-matching)
type: http
seq: 9
seq: 10
}
post {

View File

@@ -1,7 +1,7 @@
meta {
name: Create heartbeats (unauthorized)
type: http
seq: 10
seq: 11
}
post {
@@ -12,7 +12,7 @@ post {
body:json {
[{
"entity": "/home/user1/dev/proejct1/main.go",
"entity": "/home/user1/dev/project1/main.go",
"project": "wakapi",
"language": "Go",
"is_write": true,

View File

@@ -0,0 +1,32 @@
meta {
name: Create heartbeats (unauthorized, readonly key)
type: http
seq: 13
}
post {
url: {{BASE_URL}}/api/heartbeat
body: json
auth: bearer
}
auth:bearer {
token: {{ADDITIONAL_READ_ONLY_TOKEN}}
}
body:json {
[{
"entity": "/home/user1/dev/project1/main.go",
"project": "wakapi",
"language": "Go",
"is_write": true,
"type": "file",
"category": null,
"branch": null,
"time": {{tsNowMinus1Min}}
}]
}
assert {
res.status: eq 401
}

View File

@@ -0,0 +1,33 @@
meta {
name: Get heartbeats (full access key)
type: http
seq: 15
}
get {
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date={{yesterdayDate}}
body: none
auth: bearer
}
params:query {
date: {{yesterdayDate}}
}
auth:bearer {
token: {{ADDITIONAL_FULL_ACCESS_TOKEN}}
}
assert {
res.status: eq 200
}
tests {
test("Response body is correct", function () {
const date = new Date(bru.getVar('yesterdayDateIso'))
expect(res.body.timezone).to.eql(bru.getCollectionVar('TZ'));
expect(new Date(res.body.start)).to.eql(date);
expect(new Date(res.body.end)).to.eql(new Date(date.getTime() + 3600 * 1000 * 24 - 1000));
expect(res.body.data.length).to.eql(2);
});
}

View File

@@ -0,0 +1,33 @@
meta {
name: Get heartbeats (readonly key)
type: http
seq: 16
}
get {
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date={{yesterdayDate}}
body: none
auth: bearer
}
params:query {
date: {{yesterdayDate}}
}
auth:bearer {
token: {{ADDITIONAL_READ_ONLY_TOKEN}}
}
assert {
res.status: eq 200
}
tests {
test("Response body is correct", function () {
const date = new Date(bru.getVar('yesterdayDateIso'))
expect(res.body.timezone).to.eql(bru.getCollectionVar('TZ'));
expect(new Date(res.body.start)).to.eql(date);
expect(new Date(res.body.end)).to.eql(new Date(date.getTime() + 3600 * 1000 * 24 - 1000));
expect(res.body.data.length).to.eql(2);
});
}

View File

@@ -1,7 +1,7 @@
meta {
name: Get heartbeats
type: http
seq: 12
seq: 14
}
get {

View File

@@ -11,6 +11,8 @@ vars:pre-request {
BASE_URL: http://localhost:3000
READUSER_API_KEY: 33e7f538-0dce-4eba-8ffe-53db6814ed42
WRITEUSER_API_KEY: f7aa255c-8647-4d0b-b90f-621c58fd580f
ADDITIONAL_FULL_ACCESS_KEY: 1c91f670-2309-45fb-9d7e-738c766e85a6
ADDITIONAL_READ_ONLY_KEY: 774f7e16-b9a3-433e-ac68-7e28a82a50ca
TZ: Europe/Berlin
TZ_OFFSET: +02:00
READUSER_PASSWORD: testpw
@@ -19,10 +21,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 +33,36 @@ 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')
const additionalFullAccessApiKey = bru.getCollectionVar('ADDITIONAL_FULL_ACCESS_KEY')
const additionalReadOnlyApiKey = bru.getCollectionVar('ADDITIONAL_READ_ONLY_KEY')
if (!readApiKey || !writeApiKey) {
throw new Error('no api key given')
}
bru.setVar('READUSER_TOKEN', base64encode(readApiKey))
bru.setVar('WRITEUSER_TOKEN', base64encode(writeApiKey))
bru.setVar('ADDITIONAL_FULL_ACCESS_TOKEN', base64encode(additionalFullAccessApiKey))
bru.setVar('ADDITIONAL_READ_ONLY_TOKEN', base64encode(additionalReadOnlyApiKey))
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>
@@ -359,7 +362,7 @@
<div class="ml-3 inline">
<button @click="showProjectAddButton({{ $i }})" class="top-1 relative" v-show="!labels[{{ $i }}]">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 text-secondary">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
</button>
<form action="" method="post" class="inline-flex gap-x-1" v-show="labels[{{ $i }}]">
@@ -367,7 +370,7 @@
<input type="hidden" name="value" value="{{ $label.Key }}">
<select name="key" class="block text-sm select-default !w-auto">
{{ range $k, $project := $.Projects }}
<option value="{{ $project }}">{{ $project }}</option>
<option value="{{ $project }}">{{ $project }}</option>
{{ end }}
</select>
<button type="submit" class="btn-primary btn-small">
@@ -392,7 +395,7 @@
<input class="input-default block" name="value" placeholder="Label" required>
<select name="key" class="block w-full p-2.5 select-default grow" multiple required>
{{ range $i, $p := .Projects }}
<option value="{{ $p }}" class="bg-transparent checked:text-green-500">{{ $p }}</option>
<option value="{{ $p }}" class="bg-transparent checked:text-green-500">{{ $p }}</option>
{{ end }}
</select>
<button type="submit" class="btn-primary">
@@ -426,7 +429,7 @@
{{ range $i, $mapping := .LanguageMappings }}
<div class="flex items-center mb-2">
<div class="text-foreground border-1 w-full inline-block my-1 py-1 text-align text-sm">
&#9656;&nbsp; When filename ends in <span
&#9656;&nbsp; When filename ends in <span
class="text-accent chip mr-1">{{ $mapping.Extension }}</span>
then change the <span class="font-semibold">language</span> to <span
class="text-accent chip mr-1">{{ $mapping.Language }}</span>
@@ -886,7 +889,8 @@
{{ if ne .User.Email "" }}
<button type="submit" class="btn-primary mt-4">Subscribe ({{ .SubscriptionPrice }} / mo)</button>
{{ else }}
<button type="submit" class="btn-disabled cursor-pointer mt-4" disabled title="">Subscribe ({{ .SubscriptionPrice }} / mo)</button><br>
<button type="submit" class="btn-disabled cursor-pointer mt-4" disabled title="">Subscribe ({{ .SubscriptionPrice }} / mo)</button>
<br>
<span class="text-xs text-muted">You have to provide an e-mail address to purchase a subscription.</span>
{{ end }}
</form>
@@ -899,6 +903,89 @@
</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 primary API key</span>
<span class="block text-sm text-muted">
Please note that resetting your API key requires you to update your <code>.wakatime.cfg</code> 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 justify-between items-center" id="form-generate-api-key">
<div class="w-1/2 mb-8">
<span class="font-semibold text-foreground text-lg">Add API keys</span>
<span class="block text-sm text-muted">
Besides the primary (aka. <i>main</i>) API key, which always exists, you can create additional API keys for different applications to access Wakapi. You can either grant read-only access or read-write access, which additionally allows to ingest heartbeats.
</span>
</div>
<form action="" method="post" class="flex justify-end shrink-0">
<input type="hidden" name="action" value="add_api_key">
<div class="flex gap-2 items-center justify-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>
<select autocomplete="off" id="api-readonly" name="api_readonly" class="select-default grow">
<option value="false" class="cursor-pointer">Read / write</option>
<option value="true" class="cursor-pointer">Read only</option>
</select>
<button type="submit" class="btn-primary">Add</button>
</div>
</form>
</div>
<div class="w-full lg:w-3/4">
<span class="flex font-semibold text-foreground text-lg mb-2">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 +1002,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">

View File

@@ -43,7 +43,7 @@
<p class="">After having installed one or more IDE plugins, you will have to adapt the WakaTime config file in your home directory.</p>
<p class="my-1">
<span class="font-semibold">On Linux / macOS: <span class="font-mono text-sm font-normal ml-1">~/.wakatime.cfg</span></span><br>
<span class="font-semibold">On Windows: <span class="font-mono text-sm font-normal ml-1">%USERPROFILE\.wakatime.cfg</span></span>
<span class="font-semibold">On Windows: <span class="font-mono text-sm font-normal ml-1">%USERPROFILE%\.wakatime.cfg</span></span>
</p>
<p>Open the file with your favorite editor and adapt the API URL and -key like this:</p>
<div class="bg-card text-left rounded-md py-4 px-8 text-xs font-mono shadow-md">