mirror of
https://github.com/muety/wakapi.git
synced 2025-12-05 22:20:24 -08:00
Merge remote-tracking branch 'origin/master'
This commit is contained in:
6
.github/assets/tuta_logo.svg
vendored
Normal file
6
.github/assets/tuta_logo.svg
vendored
Normal 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 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
11
main.go
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
39
mocks/api_key_service.go
Normal file
39
mocks/api_key_service.go
Normal 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
37
mocks/mail_service.go
Normal 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
125
mocks/user_repository.go
Normal 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)
|
||||
}
|
||||
@@ -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
13
models/api_key.go
Normal 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 != ""
|
||||
}
|
||||
@@ -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
71
repositories/api_key.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
103
services/api_key.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
103
services/user_test.go
Normal 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.
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Create heartbeats (get heartbeats test)
|
||||
type: http
|
||||
seq: 11
|
||||
seq: 12
|
||||
}
|
||||
|
||||
post {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Create heartbeats (non-matching)
|
||||
type: http
|
||||
seq: 9
|
||||
seq: 10
|
||||
}
|
||||
|
||||
post {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Get heartbeats
|
||||
type: http
|
||||
seq: 12
|
||||
seq: 14
|
||||
}
|
||||
|
||||
get {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
▸ When filename ends in <span
|
||||
▸ 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">
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user