mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
23d00d574b | |||
d4b15e7959 | |||
42808fa38a | |||
52269c780f | |||
302eb33b1b | |||
784adec3c1 | |||
d2cdd35fff | |||
33d65fb33a | |||
6d762f5fd6 | |||
222024dabb | |||
660a09475e |
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
* text=auto
|
||||||
|
*.db -text
|
||||||
|
*.png -text
|
||||||
|
*.br -text
|
||||||
|
*.ico -text
|
||||||
|
*.woff2 -text
|
@ -56,4 +56,6 @@ COPY --from=build-env /app .
|
|||||||
|
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
ENTRYPOINT /app/entrypoint.sh
|
ENTRYPOINT /app/entrypoint.sh
|
||||||
|
@ -66,10 +66,12 @@ $ curl -L https://wakapi.dev/get | bash
|
|||||||
# Create a persistent volume
|
# Create a persistent volume
|
||||||
$ docker volume create wakapi-data
|
$ docker volume create wakapi-data
|
||||||
|
|
||||||
|
$ SALT="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)"
|
||||||
|
|
||||||
# Run the container
|
# Run the container
|
||||||
$ docker run -d \
|
$ docker run -d \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-e "WAKAPI_PASSWORD_SALT=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)" \
|
-e "WAKAPI_PASSWORD_SALT=$SALT" \
|
||||||
-v wakapi-data:/data \
|
-v wakapi-data:/data \
|
||||||
--name wakapi \
|
--name wakapi \
|
||||||
ghcr.io/muety/wakapi:latest
|
ghcr.io/muety/wakapi:latest
|
||||||
|
@ -23,7 +23,8 @@ app:
|
|||||||
|
|
||||||
# url template for user avatar images (to be used with services like gravatar or dicebear)
|
# url template for user avatar images (to be used with services like gravatar or dicebear)
|
||||||
# available variable placeholders are: username, username_hash, email, email_hash
|
# available variable placeholders are: username, username_hash, email, email_hash
|
||||||
avatar_url_template: https://avatars.dicebear.com/api/pixel-art-neutral/{username_hash}.svg
|
# defaults to wakapi's internal avatar rendering powered by https://codeberg.org/Codeberg/avatars
|
||||||
|
avatar_url_template: api/avatar/{username_hash}.svg
|
||||||
|
|
||||||
db:
|
db:
|
||||||
host: # leave blank when using sqlite3
|
host: # leave blank when using sqlite3
|
||||||
|
@ -69,7 +69,7 @@ type appConfig struct {
|
|||||||
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
|
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
|
||||||
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
|
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
|
||||||
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
|
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
|
||||||
AvatarURLTemplate string `yaml:"avatar_url_template" default:"https://avatars.dicebear.com/api/pixel-art-neutral/{username_hash}.svg"`
|
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg"`
|
||||||
CustomLanguages map[string]string `yaml:"custom_languages"`
|
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||||
Colors map[string]map[string]string `yaml:"-"`
|
Colors map[string]map[string]string `yaml:"-"`
|
||||||
}
|
}
|
||||||
@ -269,12 +269,12 @@ func IsDev(env string) bool {
|
|||||||
func readColors() map[string]map[string]string {
|
func readColors() map[string]map[string]string {
|
||||||
// Read language colors
|
// Read language colors
|
||||||
// Source:
|
// Source:
|
||||||
// – https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
|
// - https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
|
||||||
// – https://wakatime.com/colors/operating_systems
|
// - https://wakatime.com/colors/operating_systems
|
||||||
// - https://wakatime.com/colors/editors
|
// - https://wakatime.com/colors/editors
|
||||||
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
|
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
|
||||||
// – $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
|
// - $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
|
||||||
// – $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
|
// - $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
|
||||||
|
|
||||||
raw := data.ColorsFile
|
raw := data.ColorsFile
|
||||||
if IsDev(env) {
|
if IsDev(env) {
|
||||||
|
@ -140,7 +140,7 @@ func initSentry(config sentryConfig, debug bool) {
|
|||||||
return event
|
return event
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logbuch.Fatal("failed to initialized sentry – %v", err)
|
logbuch.Fatal("failed to initialized sentry - %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
2
go.mod
2
go.mod
@ -3,6 +3,7 @@ module github.com/muety/wakapi
|
|||||||
go 1.16
|
go 1.16
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69
|
||||||
github.com/BurntSushi/toml v0.4.1 // indirect
|
github.com/BurntSushi/toml v0.4.1 // indirect
|
||||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
|
||||||
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
|
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
|
||||||
@ -16,6 +17,7 @@ require (
|
|||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/gorilla/schema v1.2.0
|
github.com/gorilla/schema v1.2.0
|
||||||
github.com/gorilla/securecookie v1.1.1
|
github.com/gorilla/securecookie v1.1.1
|
||||||
|
github.com/hashicorp/golang-lru v0.5.4
|
||||||
github.com/jackc/pgx/v4 v4.14.1 // indirect
|
github.com/jackc/pgx/v4 v4.14.1 // indirect
|
||||||
github.com/jinzhu/configor v1.2.1
|
github.com/jinzhu/configor v1.2.1
|
||||||
github.com/jinzhu/now v1.1.4 // indirect
|
github.com/jinzhu/now v1.1.4 // indirect
|
||||||
|
4
go.sum
4
go.sum
@ -1,3 +1,5 @@
|
|||||||
|
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69 h1:/XvI42KX57UTpeIOIt7IfM+pmEFTL8FGtiIUGcGDOIU=
|
||||||
|
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
|
||||||
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
|
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
|
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
|
||||||
@ -107,6 +109,8 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC
|
|||||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
|
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
|
||||||
|
2
main.go
2
main.go
@ -180,6 +180,7 @@ func main() {
|
|||||||
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
|
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
|
||||||
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
|
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
|
||||||
diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService)
|
diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService)
|
||||||
|
avatarHandler := api.NewAvatarHandler()
|
||||||
|
|
||||||
// Compat Handlers
|
// Compat Handlers
|
||||||
wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService)
|
wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService)
|
||||||
@ -237,6 +238,7 @@ func main() {
|
|||||||
heartbeatApiHandler.RegisterRoutes(apiRouter)
|
heartbeatApiHandler.RegisterRoutes(apiRouter)
|
||||||
metricsHandler.RegisterRoutes(apiRouter)
|
metricsHandler.RegisterRoutes(apiRouter)
|
||||||
diagnosticsHandler.RegisterRoutes(apiRouter)
|
diagnosticsHandler.RegisterRoutes(apiRouter)
|
||||||
|
avatarHandler.RegisterRoutes(apiRouter)
|
||||||
wakatimeV1StatusBarHandler.RegisterRoutes(apiRouter)
|
wakatimeV1StatusBarHandler.RegisterRoutes(apiRouter)
|
||||||
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
|
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
|
||||||
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
|
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
|
||||||
|
@ -105,7 +105,7 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||||||
func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header, forUser *models.User) {
|
func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header, forUser *models.User) {
|
||||||
request, err := http.NewRequest(method, url, body)
|
request, err := http.NewRequest(method, url, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logbuch.Warn("error constructing relayed request – %v", err)
|
logbuch.Warn("error constructing relayed request - %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,7 +117,7 @@ func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, heade
|
|||||||
|
|
||||||
response, err := m.httpClient.Do(request)
|
response, err := m.httpClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logbuch.Warn("error executing relayed request – %v", err)
|
logbuch.Warn("error executing relayed request - %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,7 +46,7 @@ func RunPreMigrations(db *gorm.DB, cfg *config.Config) {
|
|||||||
for _, m := range preMigrations {
|
for _, m := range preMigrations {
|
||||||
logbuch.Info("potentially running migration '%s'", m.name)
|
logbuch.Info("potentially running migration '%s'", m.name)
|
||||||
if err := m.f(db, cfg); err != nil {
|
if err := m.f(db, cfg); err != nil {
|
||||||
logbuch.Fatal("migration '%s' failed – %v", m.name, err)
|
logbuch.Fatal("migration '%s' failed - %v", m.name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -57,7 +57,7 @@ func RunPostMigrations(db *gorm.DB, cfg *config.Config) {
|
|||||||
for _, m := range postMigrations {
|
for _, m := range postMigrations {
|
||||||
logbuch.Info("potentially running migration '%s'", m.name)
|
logbuch.Info("potentially running migration '%s'", m.name)
|
||||||
if err := m.f(db, cfg); err != nil {
|
if err := m.f(db, cfg); err != nil {
|
||||||
logbuch.Fatal("migration '%s' failed – %v", m.name, err)
|
logbuch.Fatal("migration '%s' failed - %v", m.name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -40,7 +40,7 @@ func NewDurationFromHeartbeat(h *Heartbeat) *Duration {
|
|||||||
func (d *Duration) Hashed() *Duration {
|
func (d *Duration) Hashed() *Duration {
|
||||||
hash, err := hashstructure.Hash(d, hashstructure.FormatV2, nil)
|
hash, err := hashstructure.Hash(d, hashstructure.FormatV2, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logbuch.Error("CRITICAL ERROR: failed to hash struct – %v", err)
|
logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
|
||||||
}
|
}
|
||||||
d.GroupHash = fmt.Sprintf("%x", hash)
|
d.GroupHash = fmt.Sprintf("%x", hash)
|
||||||
return d
|
return d
|
||||||
|
@ -103,7 +103,7 @@ func (f *Filters) IsEmpty() bool {
|
|||||||
func (f *Filters) Hash() string {
|
func (f *Filters) Hash() string {
|
||||||
hash, err := hashstructure.Hash(f, hashstructure.FormatV2, nil)
|
hash, err := hashstructure.Hash(f, hashstructure.FormatV2, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logbuch.Error("CRITICAL ERROR: failed to hash struct – %v", err)
|
logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
|
return fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
|
||||||
}
|
}
|
||||||
|
@ -94,7 +94,7 @@ func (h *Heartbeat) String() string {
|
|||||||
func (h *Heartbeat) Hashed() *Heartbeat {
|
func (h *Heartbeat) Hashed() *Heartbeat {
|
||||||
hash, err := hashstructure.Hash(h, hashstructure.FormatV2, nil)
|
hash, err := hashstructure.Hash(h, hashstructure.FormatV2, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logbuch.Error("CRITICAL ERROR: failed to hash struct – %v", err)
|
logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
|
||||||
}
|
}
|
||||||
h.Hash = fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
|
h.Hash = fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
|
||||||
return h
|
return h
|
||||||
|
45
routes/api/avatar.go
Normal file
45
routes/api/avatar.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"codeberg.org/Codeberg/avatars"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
lru "github.com/hashicorp/golang-lru"
|
||||||
|
conf "github.com/muety/wakapi/config"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AvatarHandler struct {
|
||||||
|
config *conf.Config
|
||||||
|
cache *lru.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAvatarHandler() *AvatarHandler {
|
||||||
|
cache, err := lru.New(1 * 1000 * 64) // assuming an avatar is 1 kb, allocate up to 64 mb of memory for avatars cache
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AvatarHandler{
|
||||||
|
config: conf.Get(),
|
||||||
|
cache: cache,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AvatarHandler) RegisterRoutes(router *mux.Router) {
|
||||||
|
r := router.PathPrefix("/avatar/{hash}.svg").Subrouter()
|
||||||
|
r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AvatarHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hash := mux.Vars(r)["hash"]
|
||||||
|
|
||||||
|
if !h.cache.Contains(hash) {
|
||||||
|
h.cache.Add(hash, avatars.MakeMaleAvatar(hash))
|
||||||
|
}
|
||||||
|
data, _ := h.cache.Get(hash)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "image/svg+xml")
|
||||||
|
w.Header().Set("Cache-Control", "max-age=2592000")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(data.(string)))
|
||||||
|
}
|
@ -98,7 +98,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
|
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte(conf.ErrInternalServerError))
|
w.Write([]byte(conf.ErrInternalServerError))
|
||||||
conf.Log().Request(r).Error("failed to batch-insert heartbeats – %v", err)
|
conf.Log().Request(r).Error("failed to batch-insert heartbeats - %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +107,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
|||||||
if _, err := h.userSrvc.Update(user); err != nil {
|
if _, err := h.userSrvc.Update(user); err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte(conf.ErrInternalServerError))
|
w.Write([]byte(conf.ErrInternalServerError))
|
||||||
conf.Log().Request(r).Error("failed to update user – %v", err)
|
conf.Log().Request(r).Error("failed to update user - %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -260,7 +260,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
|
|||||||
|
|
||||||
activeUsers, err := h.userSrvc.GetActive(false)
|
activeUsers, err := h.userSrvc.GetActive(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logbuch.Error("failed to retrieve active users for metric – %v", err)
|
logbuch.Error("failed to retrieve active users for metric - %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -284,7 +284,7 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
|
|||||||
go func(user *models.User) {
|
go func(user *models.User) {
|
||||||
link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken)
|
link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken)
|
||||||
if err := h.mailSrvc.SendPasswordReset(user, link); err != nil {
|
if err := h.mailSrvc.SendPasswordReset(user, link); err != nil {
|
||||||
conf.Log().Request(r).Error("failed to send password reset mail to %s – %v", user.ID, err)
|
conf.Log().Request(r).Error("failed to send password reset mail to %s - %v", user.ID, err)
|
||||||
} else {
|
} else {
|
||||||
logbuch.Info("sent password reset mail to %s", user.ID)
|
logbuch.Info("sent password reset mail to %s", user.ID)
|
||||||
}
|
}
|
||||||
@ -299,12 +299,11 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
|
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
|
||||||
numUsers, _ := h.userSrvc.Count()
|
numUsers, _ := h.userSrvc.Count()
|
||||||
allowSignup := h.config.Security.AllowSignup
|
|
||||||
|
|
||||||
return &view.LoginViewModel{
|
return &view.LoginViewModel{
|
||||||
Success: r.URL.Query().Get("success"),
|
Success: r.URL.Query().Get("success"),
|
||||||
Error: r.URL.Query().Get("error"),
|
Error: r.URL.Query().Get("error"),
|
||||||
TotalUsers: int(numUsers),
|
TotalUsers: int(numUsers),
|
||||||
AllowSignup: allowSignup,
|
AllowSignup: h.config.IsDev() || h.config.Security.AllowSignup,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -492,7 +492,7 @@ func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Re
|
|||||||
|
|
||||||
insert := func(batch []*models.Heartbeat) {
|
insert := func(batch []*models.Heartbeat) {
|
||||||
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
|
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
|
||||||
logbuch.Warn("failed to insert imported heartbeat, already existing? – %v", err)
|
logbuch.Warn("failed to insert imported heartbeat, already existing? - %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -518,13 +518,13 @@ func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Re
|
|||||||
if !user.HasData {
|
if !user.HasData {
|
||||||
user.HasData = true
|
user.HasData = true
|
||||||
if _, err := h.userSrvc.Update(user); err != nil {
|
if _, err := h.userSrvc.Update(user); err != nil {
|
||||||
conf.Log().Request(r).Error("failed to set 'has_data' flag for user %s – %v", user.ID, err)
|
conf.Log().Request(r).Error("failed to set 'has_data' flag for user %s - %v", user.ID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Email != "" {
|
if user.Email != "" {
|
||||||
if err := h.mailSrvc.SendImportNotification(user, time.Now().Sub(start), int(countAfter-countBefore)); err != nil {
|
if err := h.mailSrvc.SendImportNotification(user, time.Now().Sub(start), int(countAfter-countBefore)); err != nil {
|
||||||
conf.Log().Request(r).Error("failed to send import notification mail to %s – %v", user.ID, err)
|
conf.Log().Request(r).Error("failed to send import notification mail to %s - %v", user.ID, err)
|
||||||
} else {
|
} else {
|
||||||
logbuch.Info("sent import notification mail to %s", user.ID)
|
logbuch.Info("sent import notification mail to %s", user.ID)
|
||||||
}
|
}
|
||||||
@ -546,11 +546,11 @@ func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *ht
|
|||||||
|
|
||||||
go func(user *models.User) {
|
go func(user *models.User) {
|
||||||
if err := h.regenerateSummaries(user); err != nil {
|
if err := h.regenerateSummaries(user); err != nil {
|
||||||
conf.Log().Request(r).Error("failed to regenerate summaries for user '%s' – %v", user.ID, err)
|
conf.Log().Request(r).Error("failed to regenerate summaries for user '%s' - %v", user.ID, err)
|
||||||
}
|
}
|
||||||
}(middlewares.GetPrincipal(r))
|
}(middlewares.GetPrincipal(r))
|
||||||
|
|
||||||
return http.StatusAccepted, "summaries are being regenerated – this may take a up to a couple of minutes, please come back later", ""
|
return http.StatusAccepted, "summaries are being regenerated - this may take a up to a couple of minutes, please come back later", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
@ -563,7 +563,7 @@ func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Reques
|
|||||||
logbuch.Info("deleting user '%s' shortly", user.ID)
|
logbuch.Info("deleting user '%s' shortly", user.ID)
|
||||||
time.Sleep(5 * time.Minute)
|
time.Sleep(5 * time.Minute)
|
||||||
if err := h.userSrvc.Delete(user); err != nil {
|
if err := h.userSrvc.Delete(user); err != nil {
|
||||||
conf.Log().Request(r).Error("failed to delete user '%s' – %v", user.ID, err)
|
conf.Log().Request(r).Error("failed to delete user '%s' - %v", user.ID, err)
|
||||||
} else {
|
} else {
|
||||||
logbuch.Info("successfully deleted user '%s'", user.ID)
|
logbuch.Info("successfully deleted user '%s'", user.ID)
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ LANGUAGES = {
|
|||||||
'PHP': 'php',
|
'PHP': 'php',
|
||||||
'Blade': 'blade.php'
|
'Blade': 'blade.php'
|
||||||
}
|
}
|
||||||
|
BRANCHES = ['master', 'feature-1', 'feature-2']
|
||||||
|
|
||||||
|
|
||||||
class Heartbeat:
|
class Heartbeat:
|
||||||
@ -65,6 +66,7 @@ def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[H
|
|||||||
p: str = random.choice(projects)
|
p: str = random.choice(projects)
|
||||||
l: str = random.choice(languages)
|
l: str = random.choice(languages)
|
||||||
f: str = randomword(random.randint(2, 8))
|
f: str = randomword(random.randint(2, 8))
|
||||||
|
b: str = random.choice(BRANCHES)
|
||||||
delta: timedelta = timedelta(
|
delta: timedelta = timedelta(
|
||||||
hours=random.randint(0, n_past_hours - 1),
|
hours=random.randint(0, n_past_hours - 1),
|
||||||
minutes=random.randint(0, 59),
|
minutes=random.randint(0, 59),
|
||||||
@ -77,6 +79,7 @@ def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[H
|
|||||||
entity=f'/home/me/dev/{p}/{f}.{LANGUAGES[l]}',
|
entity=f'/home/me/dev/{p}/{f}.{LANGUAGES[l]}',
|
||||||
project=p,
|
project=p,
|
||||||
language=l,
|
language=l,
|
||||||
|
branch=b,
|
||||||
time=(now - delta).timestamp()
|
time=(now - delta).timestamp()
|
||||||
))
|
))
|
||||||
|
|
||||||
|
@ -84,7 +84,7 @@ func (srv *AggregationService) Run(userIds map[string]bool) error {
|
|||||||
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
|
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
|
||||||
for job := range jobs {
|
for job := range jobs {
|
||||||
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil {
|
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil {
|
||||||
config.Log().Error("failed to generate summary (%v, %v, %s) – %v", job.From, job.To, job.UserID, err)
|
config.Log().Error("failed to generate summary (%v, %v, %s) - %v", job.From, job.To, job.UserID, err)
|
||||||
} else {
|
} else {
|
||||||
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
|
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
|
||||||
summaries <- summary
|
summaries <- summary
|
||||||
@ -95,7 +95,7 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
|
|||||||
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
|
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
|
||||||
for summary := range summaries {
|
for summary := range summaries {
|
||||||
if err := srv.summaryService.Insert(summary); err != nil {
|
if err := srv.summaryService.Insert(summary); err != nil {
|
||||||
config.Log().Error("failed to save summary (%v, %v, %s) – %v", summary.UserID, summary.FromTime, summary.ToTime, err)
|
config.Log().Error("failed to save summary (%v, %v, %s) - %v", summary.UserID, summary.FromTime, summary.ToTime, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -45,7 +45,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
|||||||
|
|
||||||
startDate, endDate, err := w.fetchRange(baseUrl)
|
startDate, endDate, err := w.fetchRange(baseUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
config.Log().Error("failed to fetch date range while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
config.Log().Error("failed to fetch date range while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,13 +58,13 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
|||||||
|
|
||||||
userAgents, err := w.fetchUserAgents(baseUrl)
|
userAgents, err := w.fetchUserAgents(baseUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
machinesNames, err := w.fetchMachineNames(baseUrl)
|
machinesNames, err := w.fetchMachineNames(baseUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,7 +76,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
|||||||
|
|
||||||
for _, d := range days {
|
for _, d := range days {
|
||||||
if err := sem.Acquire(ctx, 1); err != nil {
|
if err := sem.Acquire(ctx, 1); err != nil {
|
||||||
logbuch.Error("failed to acquire semaphore – %v", err)
|
logbuch.Error("failed to acquire semaphore - %v", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,7 +87,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
|||||||
d := day.Format(config.SimpleDateFormat)
|
d := day.Format(config.SimpleDateFormat)
|
||||||
heartbeats, err := w.fetchHeartbeats(d, baseUrl)
|
heartbeats, err := w.fetchHeartbeats(d, baseUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' – &v", d, user.ID, err)
|
config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' - &v", d, user.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, h := range heartbeats {
|
for _, h := range heartbeats {
|
||||||
|
@ -89,7 +89,7 @@ func (srv *ReportService) SyncSchedule(u *models.User) bool {
|
|||||||
At(t).
|
At(t).
|
||||||
Tag(u.ID).
|
Tag(u.ID).
|
||||||
Do(srv.Run, u, 7*24*time.Hour); err != nil {
|
Do(srv.Run, u, 7*24*time.Hour); err != nil {
|
||||||
config.Log().Error("failed to schedule report job for user '%s' – %v", u.ID, err)
|
config.Log().Error("failed to schedule report job for user '%s' - %v", u.ID, err)
|
||||||
} else {
|
} else {
|
||||||
logbuch.Info("next report for user %s is scheduled for %v", u.ID, job.NextRun())
|
logbuch.Info("next report for user %s is scheduled for %v", u.ID, job.NextRun())
|
||||||
}
|
}
|
||||||
@ -114,7 +114,7 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
|
|||||||
|
|
||||||
summary, err := srv.summaryService.Aliased(start, end, user, srv.summaryService.Retrieve, nil, false)
|
summary, err := srv.summaryService.Aliased(start, end, user, srv.summaryService.Retrieve, nil, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
config.Log().Error("failed to generate report for '%s' – %v", user.ID, err)
|
config.Log().Error("failed to generate report for '%s' - %v", user.ID, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,7 +126,7 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := srv.mailService.SendReport(user, report); err != nil {
|
if err := srv.mailService.SendReport(user, report); err != nil {
|
||||||
config.Log().Error("failed to send report for '%s' – %v", user.ID, err)
|
config.Log().Error("failed to send report for '%s' - %v", user.ID, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -132,9 +132,9 @@ function draw(subselection) {
|
|||||||
onClick: (event, data) => {
|
onClick: (event, data) => {
|
||||||
const idx = data[0].index
|
const idx = data[0].index
|
||||||
const name = wakapiData.projects[idx].key
|
const name = wakapiData.projects[idx].key
|
||||||
const query = new URLSearchParams(window.location.search)
|
const url = new URL(window.location.href)
|
||||||
query.set('project', name)
|
url.searchParams.set('query', name)
|
||||||
window.location.replace(`${window.location.pathname.slice(1)}?${query.toString()}`)
|
window.location.href = url.href
|
||||||
},
|
},
|
||||||
onHover: (event, elem) => {
|
onHover: (event, elem) => {
|
||||||
event.native.target.style.cursor = elem[0] ? 'pointer' : 'default'
|
event.native.target.style.cursor = elem[0] ? 'pointer' : 'default'
|
||||||
@ -331,13 +331,13 @@ function draw(subselection) {
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
let branchChart = branchesCanvas && !branchesCanvas.classList.contains('hidden') && shouldUpdate(0)
|
let branchChart = branchesCanvas && !branchesCanvas.classList.contains('hidden') && shouldUpdate(6)
|
||||||
? new Chart(branchesCanvas.getContext('2d'), {
|
? new Chart(branchesCanvas.getContext('2d'), {
|
||||||
type: "bar",
|
type: "bar",
|
||||||
data: {
|
data: {
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: wakapiData.branches
|
data: wakapiData.branches
|
||||||
.slice(0, Math.min(showTopN[0], wakapiData.branches.length))
|
.slice(0, Math.min(showTopN[6], wakapiData.branches.length))
|
||||||
.map(p => parseInt(p.total)),
|
.map(p => parseInt(p.total)),
|
||||||
backgroundColor: wakapiData.branches.map((p, i) => {
|
backgroundColor: wakapiData.branches.map((p, i) => {
|
||||||
const c = hexToRgb(getColor(p.key, i % baseColors.length))
|
const c = hexToRgb(getColor(p.key, i % baseColors.length))
|
||||||
@ -349,7 +349,7 @@ function draw(subselection) {
|
|||||||
}),
|
}),
|
||||||
}],
|
}],
|
||||||
labels: wakapiData.branches
|
labels: wakapiData.branches
|
||||||
.slice(0, Math.min(showTopN[0], wakapiData.branches.length))
|
.slice(0, Math.min(showTopN[6], wakapiData.branches.length))
|
||||||
.map(p => p.key)
|
.map(p => p.key)
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
@ -30,7 +30,7 @@ done
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "Running test collection ..."
|
echo "Running test collection ..."
|
||||||
newman run "Wakapi API Tests.postman_collection.json"
|
newman run "wakapi_api_tests.postman_collection.json"
|
||||||
exit_code=$?
|
exit_code=$?
|
||||||
|
|
||||||
echo "Shutting down Wakapi ..."
|
echo "Shutting down Wakapi ..."
|
||||||
@ -39,4 +39,4 @@ kill -TERM $pid
|
|||||||
echo "Deleting database ..."
|
echo "Deleting database ..."
|
||||||
rm wakapi_testing.db
|
rm wakapi_testing.db
|
||||||
|
|
||||||
exit $exit_code
|
exit $exit_code
|
||||||
|
@ -1,23 +1,28 @@
|
|||||||
package fs
|
package fs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/patrickmn/go-cache"
|
lru "github.com/hashicorp/golang-lru"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewExistsFS(fs fs.FS) ExistsFS {
|
func NewExistsFS(fs fs.FS) ExistsFS {
|
||||||
|
cache, err := lru.New(1 << 24)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
return ExistsFS{
|
return ExistsFS{
|
||||||
FS: fs,
|
FS: fs,
|
||||||
cache: cache.New(cache.NoExpiration, cache.NoExpiration),
|
cache: cache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExistsFS struct {
|
type ExistsFS struct {
|
||||||
fs.FS
|
fs.FS
|
||||||
UseCache bool
|
UseCache bool
|
||||||
cache *cache.Cache
|
cache *lru.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (efs ExistsFS) WithCache(withCache bool) ExistsFS {
|
func (efs ExistsFS) WithCache(withCache bool) ExistsFS {
|
||||||
@ -34,7 +39,7 @@ func (efs ExistsFS) Exists(name string) bool {
|
|||||||
_, err := fs.Stat(efs.FS, name)
|
_, err := fs.Stat(efs.FS, name)
|
||||||
result := err == nil
|
result := err == nil
|
||||||
if efs.UseCache {
|
if efs.UseCache {
|
||||||
efs.cache.SetDefault(name, result)
|
efs.cache.Add(name, result)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
@ -1 +1 @@
|
|||||||
2.2.0
|
2.2.3
|
@ -12,7 +12,7 @@
|
|||||||
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
|
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
|
||||||
<div class="mx-1">
|
<div class="mx-1">
|
||||||
<a href="login" class="btn-primary">
|
<a href="login" class="btn-primary">
|
||||||
<span class="iconify inline" data-icon="fluent:key-24-filled"></span> Login️</a>
|
<span class="iconify inline" data-icon="fluent:key-24-filled"></span> Login</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -31,13 +31,13 @@
|
|||||||
Forgot password?
|
Forgot password?
|
||||||
</a>
|
</a>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
{{ if eq .AllowSignup true }}
|
{{ if .AllowSignup }}
|
||||||
<a href="signup">
|
<a href="signup">
|
||||||
<button type="button" class="btn-default">Sign up</button>
|
<button type="button" class="btn-default">Sign up</button>
|
||||||
</a>
|
</a>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<a title="The administrator of this instance has disabled sign up.">
|
<a title="The administrator of this instance has disabled sign up.">
|
||||||
<button type="button" class="btn-disabled" disabled > Sign up </button>
|
<button type="button" class="btn-disabled" disabled> Sign up </button>
|
||||||
</a>
|
</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<button type="submit" class="btn-primary">Log in</button>
|
<button type="submit" class="btn-primary">Log in</button>
|
||||||
|
@ -83,7 +83,7 @@
|
|||||||
<a href="login">
|
<a href="login">
|
||||||
<button type="button" class="btn-default">Log in</button>
|
<button type="button" class="btn-default">Log in</button>
|
||||||
</a>
|
</a>
|
||||||
{{ if eq .AllowSignup true }}
|
{{ if .AllowSignup }}
|
||||||
<button type="submit" class="btn-primary">
|
<button type="submit" class="btn-primary">
|
||||||
Create Account
|
Create Account
|
||||||
</button>
|
</button>
|
||||||
|
@ -189,12 +189,11 @@
|
|||||||
# <strong>Step 1:</strong> Download WakaTime plugin for your IDE<br>
|
# <strong>Step 1:</strong> Download WakaTime plugin for your IDE<br>
|
||||||
# See: https://wakatime.com/plugins<br><br>
|
# See: https://wakatime.com/plugins<br><br>
|
||||||
|
|
||||||
# <strong>Step 2:</strong> Adapt your config<br>
|
# <strong>Step 2:</strong> Set your ~/.wakatime.cfg to this:<br><br>
|
||||||
$ vi ~/.wakatime.cfg<br>
|
|
||||||
|
|
||||||
<!-- https://github.com/muety/wakapi/issues/224#issuecomment-890855563 -->
|
<!-- https://github.com/muety/wakapi/issues/224#issuecomment-890855563 -->
|
||||||
# Set <em>api_url = <span class="with-url-inner">%s/api</span></em><br>
|
[settings]<br>
|
||||||
# Set <em>api_key = <span id="api-key-instruction">{{ .ApiKey }}</span></em><br><br>
|
api_url = <span class="with-url-inner">%s/api</span><br>
|
||||||
|
api_key = <span id="api-key-instruction">{{ .ApiKey }}</span><br><br>
|
||||||
|
|
||||||
# <strong>Step 3:</strong> Start coding and then check back here!
|
# <strong>Step 3:</strong> Start coding and then check back here!
|
||||||
</div>
|
</div>
|
||||||
|
Reference in New Issue
Block a user