mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
23d00d574b | |||
d4b15e7959 | |||
42808fa38a | |||
52269c780f | |||
302eb33b1b | |||
784adec3c1 | |||
d2cdd35fff | |||
33d65fb33a | |||
6d762f5fd6 | |||
222024dabb | |||
660a09475e | |||
5cc932177f | |||
ac9d96c563 | |||
3758eecc96 | |||
e21788b8b5 | |||
e7f3432113 | |||
7159df30c2 | |||
fce3a3ea20 | |||
bd2a8c5a7f | |||
632a3d4a91 | |||
8a344ce4a2 | |||
cbbb592143 |
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
|
26
.github/workflows/docker.yml
vendored
26
.github/workflows/docker.yml
vendored
@ -10,10 +10,6 @@ jobs:
|
||||
docker-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# https://stackoverflow.com/questions/58177786
|
||||
- name: Get version
|
||||
run: echo "GIT_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
@ -33,18 +29,26 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
n1try/wakapi
|
||||
tags: |
|
||||
latest
|
||||
alpine
|
||||
type=semver,pattern={{major}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
n1try/wakapi:latest
|
||||
n1try/wakapi:alpine
|
||||
n1try/wakapi:${{ env.GIT_TAG }}
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
ghcr.io/${{ github.repository }}:alpine
|
||||
ghcr.io/${{ github.repository }}:${{ env.GIT_TAG }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=registry,ref=n1try/wakapi:buildcache-alpine
|
||||
cache-to: type=registry,ref=n1try/wakapi:buildcache-alpine,mode=max
|
||||
|
@ -56,4 +56,6 @@ COPY --from=build-env /app .
|
||||
|
||||
VOLUME /data
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT /app/entrypoint.sh
|
||||
|
10
README.md
10
README.md
@ -34,9 +34,6 @@
|
||||
|
||||
Installation instructions can be found below and in the [Wiki](https://github.com/muety/wakapi/wiki).
|
||||
|
||||
## 🎉 **Wakapi's Year 2021**
|
||||
Check out our latest [blog post](https://muetsch.io/wakapi-s-year-2021.html), featuring some interesting statistics about Wakapi in 2021!
|
||||
|
||||
## 🚀 Features
|
||||
* ✅ 100 % free and open-source
|
||||
* ✅ Built by developers for developers
|
||||
@ -69,12 +66,15 @@ $ curl -L https://wakapi.dev/get | bash
|
||||
# Create a persistent volume
|
||||
$ 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
|
||||
$ docker run -d \
|
||||
-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 \
|
||||
--name wakapi n1try/wakapi
|
||||
--name wakapi \
|
||||
ghcr.io/muety/wakapi:latest
|
||||
```
|
||||
|
||||
**Note:** By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options.
|
||||
|
@ -23,7 +23,8 @@ app:
|
||||
|
||||
# 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
|
||||
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:
|
||||
host: # leave blank when using sqlite3
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
@ -68,7 +69,7 @@ type appConfig struct {
|
||||
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
|
||||
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"`
|
||||
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"`
|
||||
Colors map[string]map[string]string `yaml:"-"`
|
||||
}
|
||||
@ -143,6 +144,7 @@ type Config struct {
|
||||
Env string `default:"dev" env:"ENVIRONMENT"`
|
||||
Version string `yaml:"-"`
|
||||
QuickStart bool `yaml:"quick_start" env:"WAKAPI_QUICK_START"`
|
||||
InstanceId string `yaml:"-"` // only temporary, changes between runs
|
||||
App appConfig
|
||||
Security securityConfig
|
||||
Db dbConfig
|
||||
@ -151,12 +153,12 @@ type Config struct {
|
||||
Mail mailConfig
|
||||
}
|
||||
|
||||
func (c *Config) CreateCookie(name, value, path string) *http.Cookie {
|
||||
return c.createCookie(name, value, path, c.Security.CookieMaxAgeSec)
|
||||
func (c *Config) CreateCookie(name, value string) *http.Cookie {
|
||||
return c.createCookie(name, value, c.Server.BasePath, c.Security.CookieMaxAgeSec)
|
||||
}
|
||||
|
||||
func (c *Config) GetClearCookie(name, path string) *http.Cookie {
|
||||
return c.createCookie(name, "", path, -1)
|
||||
func (c *Config) GetClearCookie(name string) *http.Cookie {
|
||||
return c.createCookie(name, "", c.Server.BasePath, -1)
|
||||
}
|
||||
|
||||
func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie {
|
||||
@ -267,12 +269,12 @@ func IsDev(env string) bool {
|
||||
func readColors() map[string]map[string]string {
|
||||
// Read language colors
|
||||
// Source:
|
||||
// – https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
|
||||
// – https://wakatime.com/colors/operating_systems
|
||||
// - https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
|
||||
// - https://wakatime.com/colors/operating_systems
|
||||
// - https://wakatime.com/colors/editors
|
||||
// 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"]/div[1]/text()').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)
|
||||
|
||||
raw := data.ColorsFile
|
||||
if IsDev(env) {
|
||||
@ -355,6 +357,7 @@ func Load(version string) *Config {
|
||||
|
||||
env = config.Env
|
||||
config.Version = strings.TrimSpace(version)
|
||||
config.InstanceId = uuid.NewV4().String()
|
||||
config.App.Colors = readColors()
|
||||
config.Db.Dialect = resolveDbDialect(config.Db.Type)
|
||||
config.Security.SecureCookie = securecookie.New(
|
||||
|
@ -140,7 +140,7 @@ func initSentry(config sentryConfig, debug bool) {
|
||||
return event
|
||||
},
|
||||
}); err != nil {
|
||||
logbuch.Fatal("failed to initialized sentry – %v", err)
|
||||
logbuch.Fatal("failed to initialized sentry - %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
2
go.mod
2
go.mod
@ -3,6 +3,7 @@ module github.com/muety/wakapi
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69
|
||||
github.com/BurntSushi/toml v0.4.1 // indirect
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
|
||||
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/schema v1.2.0
|
||||
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/jinzhu/configor v1.2.1
|
||||
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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
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/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/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/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=
|
||||
|
11
main.go
11
main.go
@ -2,9 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"github.com/lpar/gzipped/v2"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/routes/relay"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
@ -13,6 +10,10 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/lpar/gzipped/v2"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/routes/relay"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/handlers"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
@ -179,6 +180,7 @@ func main() {
|
||||
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
|
||||
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
|
||||
diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService)
|
||||
avatarHandler := api.NewAvatarHandler()
|
||||
|
||||
// Compat Handlers
|
||||
wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService)
|
||||
@ -187,6 +189,7 @@ func main() {
|
||||
wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService)
|
||||
wakatimeV1UsersHandler := wtV1Routes.NewUsersHandler(userService, heartbeatService)
|
||||
wakatimeV1ProjectsHandler := wtV1Routes.NewProjectsHandler(userService, heartbeatService)
|
||||
wakatimeV1HeartbeatsHandler := wtV1Routes.NewHeartbeatHandler(userService, heartbeatService)
|
||||
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
||||
|
||||
// MVC Handlers
|
||||
@ -235,12 +238,14 @@ func main() {
|
||||
heartbeatApiHandler.RegisterRoutes(apiRouter)
|
||||
metricsHandler.RegisterRoutes(apiRouter)
|
||||
diagnosticsHandler.RegisterRoutes(apiRouter)
|
||||
avatarHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1StatusBarHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1UsersHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1ProjectsHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1HeartbeatsHandler.RegisterRoutes(apiRouter)
|
||||
shieldV1BadgeHandler.RegisterRoutes(apiRouter)
|
||||
|
||||
// Static Routes
|
||||
|
@ -72,7 +72,7 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(conf.ErrUnauthorized))
|
||||
} else {
|
||||
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/"))
|
||||
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey))
|
||||
http.Redirect(w, r, m.redirectTarget, http.StatusFound)
|
||||
}
|
||||
return
|
||||
|
@ -3,12 +3,15 @@ package relay
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/leandro-lugaresi/hub"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
@ -18,9 +21,10 @@ import (
|
||||
|
||||
const maxFailuresPerDay = 100
|
||||
|
||||
/* Middleware to conditionally relay heartbeats to Wakatime */
|
||||
// WakatimeRelayMiddleware is a middleware to conditionally relay heartbeats to Wakatime (and other compatible services)
|
||||
type WakatimeRelayMiddleware struct {
|
||||
httpClient *http.Client
|
||||
hashCache *cache.Cache
|
||||
failureCache *cache.Cache
|
||||
eventBus *hub.Hub
|
||||
}
|
||||
@ -30,6 +34,7 @@ func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
hashCache: cache.New(10*time.Minute, 10*time.Minute),
|
||||
failureCache: cache.New(24*time.Hour, 1*time.Hour),
|
||||
eventBus: config.EventBus(),
|
||||
}
|
||||
@ -44,7 +49,10 @@ func (m *WakatimeRelayMiddleware) Handler(h http.Handler) http.Handler {
|
||||
func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||
defer next(w, r)
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
ownInstanceId := config.Get().InstanceId
|
||||
originInstanceId := r.Header.Get("X-Origin-Instance")
|
||||
|
||||
if r.Method != http.MethodPost || originInstanceId == ownInstanceId {
|
||||
return
|
||||
}
|
||||
|
||||
@ -53,10 +61,22 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
err := m.filterByCache(r)
|
||||
if err != nil {
|
||||
logbuch.Warn("%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
// prevent cycles
|
||||
downstreamInstanceId := ownInstanceId
|
||||
if originInstanceId != "" {
|
||||
downstreamInstanceId = originInstanceId
|
||||
}
|
||||
|
||||
headers := http.Header{
|
||||
"X-Machine-Name": r.Header.Values("X-Machine-Name"),
|
||||
"Content-Type": r.Header.Values("Content-Type"),
|
||||
@ -65,14 +85,17 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
||||
"X-Origin": []string{
|
||||
fmt.Sprintf("wakapi v%s", config.Get().Version),
|
||||
},
|
||||
"X-Origin-Instance": []string{downstreamInstanceId},
|
||||
"Authorization": []string{
|
||||
fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(user.WakatimeApiKey))),
|
||||
},
|
||||
}
|
||||
|
||||
url := user.WakaTimeURL(config.WakatimeApiUrl) + config.WakatimeApiHeartbeatsBulkUrl
|
||||
|
||||
go m.send(
|
||||
http.MethodPost,
|
||||
config.WakatimeApiUrl+config.WakatimeApiHeartbeatsBulkUrl,
|
||||
url,
|
||||
bytes.NewReader(body),
|
||||
headers,
|
||||
user,
|
||||
@ -82,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) {
|
||||
request, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
logbuch.Warn("error constructing relayed request – %v", err)
|
||||
logbuch.Warn("error constructing relayed request - %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -94,7 +117,7 @@ func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, heade
|
||||
|
||||
response, err := m.httpClient.Do(request)
|
||||
if err != nil {
|
||||
logbuch.Warn("error executing relayed request – %v", err)
|
||||
logbuch.Warn("error executing relayed request - %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -115,3 +138,53 @@ func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, heade
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// filterByCache takes an HTTP request, tries to parse the body contents as heartbeats, checks against a local cache for whether a heartbeat has already been relayed before according to its hash and in-place filters these from the request's raw json body.
|
||||
// This method operates on the raw body data (interface{}), because serialization of models.Heartbeat is not necessarily identical to what the CLI has actually sent.
|
||||
// Purpose of this mechanism is mainly to prevent cyclic relays / loops.
|
||||
// Caution: this method does in-place changes to the request.
|
||||
func (m *WakatimeRelayMiddleware) filterByCache(r *http.Request) error {
|
||||
heartbeats, err := routeutils.ParseHeartbeats(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
var rawData interface{}
|
||||
if err := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body))).Decode(&rawData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newData := make([]interface{}, 0, len(heartbeats))
|
||||
|
||||
for i, hb := range heartbeats {
|
||||
hb = hb.Hashed()
|
||||
|
||||
// we didn't see this particular heartbeat before
|
||||
if _, found := m.hashCache.Get(hb.Hash); !found {
|
||||
m.hashCache.SetDefault(hb.Hash, true)
|
||||
newData = append(newData, rawData.([]interface{})[i])
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if len(newData) == 0 {
|
||||
return errors.New("no new heartbeats to relay")
|
||||
}
|
||||
|
||||
if len(newData) != len(heartbeats) {
|
||||
user := middlewares.GetPrincipal(r)
|
||||
logbuch.Warn("only relaying %d of %d heartbeats for user %s", len(newData), len(heartbeats), user.ID)
|
||||
}
|
||||
|
||||
buf := bytes.Buffer{}
|
||||
if err := json.NewEncoder(&buf).Encode(newData); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Body = ioutil.NopCloser(&buf)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -46,7 +46,7 @@ func RunPreMigrations(db *gorm.DB, cfg *config.Config) {
|
||||
for _, m := range preMigrations {
|
||||
logbuch.Info("potentially running migration '%s'", m.name)
|
||||
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 {
|
||||
logbuch.Info("potentially running migration '%s'", m.name)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -74,8 +74,8 @@ func (m *UserServiceMock) ToggleBadges(user *models.User) (*models.User, error)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) SetWakatimeApiKey(user *models.User, s string) (*models.User, error) {
|
||||
args := m.Called(user, s)
|
||||
func (m *UserServiceMock) SetWakatimeApiCredentials(user *models.User, s1, s2 string) (*models.User, error) {
|
||||
args := m.Called(user, s1, s2)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,9 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
@ -12,18 +15,40 @@ type HeartbeatsViewModel struct {
|
||||
// that is actually required for the import
|
||||
|
||||
type HeartbeatEntry struct {
|
||||
Id string `json:"id"`
|
||||
Branch string `json:"branch"`
|
||||
Category string `json:"category"`
|
||||
Entity string `json:"entity"`
|
||||
IsWrite bool `json:"is_write"`
|
||||
Language string `json:"language"`
|
||||
Project string `json:"project"`
|
||||
Time models.CustomTime `json:"time"`
|
||||
Type string `json:"type"`
|
||||
UserId string `json:"user_id"`
|
||||
MachineNameId string `json:"machine_name_id"`
|
||||
UserAgentId string `json:"user_agent_id"`
|
||||
CreatedAt models.CustomTime `json:"created_at"`
|
||||
ModifiedAt models.CustomTime `json:"created_at"`
|
||||
Id string `json:"id"`
|
||||
Branch string `json:"branch"`
|
||||
Category string `json:"category"`
|
||||
Entity string `json:"entity"`
|
||||
IsWrite bool `json:"is_write"`
|
||||
Language string `json:"language"`
|
||||
Project string `json:"project"`
|
||||
Time float64 `json:"time"`
|
||||
Type string `json:"type"`
|
||||
UserId string `json:"user_id"`
|
||||
MachineNameId string `json:"machine_name_id"`
|
||||
UserAgentId string `json:"user_agent_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func HeartbeatsToCompat(entries []*models.Heartbeat) []*HeartbeatEntry {
|
||||
out := make([]*HeartbeatEntry, len(entries))
|
||||
for i := 0; i < len(entries); i++ {
|
||||
entry := entries[i]
|
||||
out[i] = &HeartbeatEntry{
|
||||
Id: strconv.FormatUint(entry.ID, 10),
|
||||
Branch: entry.Branch,
|
||||
Category: entry.Category,
|
||||
Entity: entry.Entity,
|
||||
IsWrite: entry.IsWrite,
|
||||
Language: entry.Language,
|
||||
Project: entry.Project,
|
||||
Time: float64(entry.Time.T().Unix()),
|
||||
Type: entry.Type,
|
||||
UserId: entry.UserID,
|
||||
MachineNameId: entry.Machine,
|
||||
UserAgentId: entry.UserAgent,
|
||||
CreatedAt: entry.CreatedAt.T(),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
@ -40,7 +40,7 @@ func NewDurationFromHeartbeat(h *Heartbeat) *Duration {
|
||||
func (d *Duration) Hashed() *Duration {
|
||||
hash, err := hashstructure.Hash(d, hashstructure.FormatV2, 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)
|
||||
return d
|
||||
|
@ -103,7 +103,7 @@ func (f *Filters) IsEmpty() bool {
|
||||
func (f *Filters) Hash() string {
|
||||
hash, err := hashstructure.Hash(f, hashstructure.FormatV2, 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"
|
||||
}
|
||||
|
@ -94,7 +94,7 @@ func (h *Heartbeat) String() string {
|
||||
func (h *Heartbeat) Hashed() *Heartbeat {
|
||||
hash, err := hashstructure.Hash(h, hashstructure.FormatV2, 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"
|
||||
return h
|
||||
|
@ -29,7 +29,8 @@ type User struct {
|
||||
ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
|
||||
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
||||
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
||||
WakatimeApiKey string `json:"-"`
|
||||
WakatimeApiKey string `json:"-"` // for relay middleware and imports
|
||||
WakatimeApiUrl string `json:"-"` // for relay middleware and imports
|
||||
ResetToken string `json:"-"`
|
||||
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
|
||||
}
|
||||
@ -109,6 +110,14 @@ func (u *User) AvatarURL(urlTemplate string) string {
|
||||
return urlTemplate
|
||||
}
|
||||
|
||||
// WakaTimeURL returns the user's effective WakaTime URL, i.e. a custom one (which could also point to another Wakapi instance) or fallback if not specified otherwise.
|
||||
func (u *User) WakaTimeURL(fallback string) string {
|
||||
if u.WakatimeApiUrl != "" {
|
||||
return strings.TrimSuffix(u.WakatimeApiUrl, "/")
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (c *CredentialsReset) IsValid() bool {
|
||||
return ValidatePassword(c.PasswordNew) &&
|
||||
c.PasswordNew == c.PasswordRepeat
|
||||
|
@ -1,9 +1,10 @@
|
||||
package view
|
||||
|
||||
type LoginViewModel struct {
|
||||
Success string
|
||||
Error string
|
||||
TotalUsers int
|
||||
Success string
|
||||
Error string
|
||||
TotalUsers int
|
||||
AllowSignup bool
|
||||
}
|
||||
|
||||
type SetPasswordViewModel struct {
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "728a2979-6cb3-4b46-9be9-3273f3d20a3d",
|
||||
"_postman_id": "1043ce31-dc5c-4477-a74a-a29a0e1168b0",
|
||||
"name": "Wakapi",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
@ -245,6 +245,41 @@
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get heartbeats",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Basic {{TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date=2021-02-10",
|
||||
"host": [
|
||||
"{{BASE_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"compat",
|
||||
"wakatime",
|
||||
"v1",
|
||||
"users",
|
||||
"current",
|
||||
"heartbeats"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "date",
|
||||
"value": "2021-02-10"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get stats",
|
||||
"request": {
|
||||
|
@ -152,6 +152,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
||||
"share_machines": user.ShareMachines,
|
||||
"share_labels": user.ShareLabels,
|
||||
"wakatime_api_key": user.WakatimeApiKey,
|
||||
"wakatime_api_url": user.WakatimeApiUrl,
|
||||
"has_data": user.HasData,
|
||||
"reset_token": user.ResetToken,
|
||||
"location": user.Location,
|
||||
|
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)))
|
||||
}
|
@ -1,9 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@ -69,15 +66,12 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var heartbeats []*models.Heartbeat
|
||||
heartbeats, err = h.tryParseBulk(r)
|
||||
heartbeats, err = routeutils.ParseHeartbeats(r)
|
||||
if err != nil {
|
||||
heartbeats, err = h.tryParseSingle(r)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error(err.Error())
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
conf.Log().Request(r).Error(err.Error())
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := r.Header.Get("User-Agent")
|
||||
@ -104,7 +98,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
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
|
||||
}
|
||||
|
||||
@ -113,7 +107,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -123,36 +117,6 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
utils.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
|
||||
}
|
||||
|
||||
func (h *HeartbeatApiHandler) tryParseBulk(r *http.Request) ([]*models.Heartbeat, error) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
dec := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body)))
|
||||
if err := dec.Decode(&heartbeats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return heartbeats, nil
|
||||
}
|
||||
|
||||
func (h *HeartbeatApiHandler) tryParseSingle(r *http.Request) ([]*models.Heartbeat, error) {
|
||||
var heartbeat models.Heartbeat
|
||||
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
dec := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body)))
|
||||
if err := dec.Decode(&heartbeat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*models.Heartbeat{&heartbeat}, nil
|
||||
}
|
||||
|
||||
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)
|
||||
// to make the cli consider all heartbeats to having been successfully saved
|
||||
// response looks like: { "responses": [ [ null, 201 ], ... ] }
|
||||
@ -181,6 +145,7 @@ func constructSuccessResponse(n int) *heartbeatResponseVm {
|
||||
// @Tags heartbeat
|
||||
// @Accept json
|
||||
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
|
||||
// @Param user path string true "Username (or current)"
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 201
|
||||
// @Router /v1/users/{user}/heartbeats [post]
|
||||
@ -191,6 +156,7 @@ func (h *HeartbeatApiHandler) postAlias1() {}
|
||||
// @Tags heartbeat
|
||||
// @Accept json
|
||||
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
|
||||
// @Param user path string true "Username (or current)"
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 201
|
||||
// @Router /compat/wakatime/v1/users/{user}/heartbeats [post]
|
||||
@ -201,6 +167,7 @@ func (h *HeartbeatApiHandler) postAlias2() {}
|
||||
// @Tags heartbeat
|
||||
// @Accept json
|
||||
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
|
||||
// @Param user path string true "Username (or current)"
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 201
|
||||
// @Router /users/{user}/heartbeats [post]
|
||||
@ -221,6 +188,7 @@ func (h *HeartbeatApiHandler) postAlias4() {}
|
||||
// @Tags heartbeat
|
||||
// @Accept json
|
||||
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
|
||||
// @Param user path string true "Username (or current)"
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 201
|
||||
// @Router /v1/users/{user}/heartbeats.bulk [post]
|
||||
@ -231,6 +199,7 @@ func (h *HeartbeatApiHandler) postAlias5() {}
|
||||
// @Tags heartbeat
|
||||
// @Accept json
|
||||
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
|
||||
// @Param user path string true "Username (or current)"
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 201
|
||||
// @Router /compat/wakatime/v1/users/{user}/heartbeats.bulk [post]
|
||||
@ -241,6 +210,7 @@ func (h *HeartbeatApiHandler) postAlias6() {}
|
||||
// @Tags heartbeat
|
||||
// @Accept json
|
||||
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
|
||||
// @Param user path string true "Username (or current)"
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 201
|
||||
// @Router /users/{user}/heartbeats.bulk [post]
|
||||
|
@ -260,7 +260,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
|
||||
|
||||
activeUsers, err := h.userSrvc.GetActive(false)
|
||||
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
|
||||
}
|
||||
|
||||
|
85
routes/compat/wakatime/v1/heartbeat.go
Normal file
85
routes/compat/wakatime/v1/heartbeat.go
Normal file
@ -0,0 +1,85 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
wakatime "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"
|
||||
)
|
||||
|
||||
type HeartbeatsResult struct {
|
||||
Data []*wakatime.HeartbeatEntry `json:"data"`
|
||||
End string `json:"end"`
|
||||
Start string `json:"start"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
type HeartbeatHandler struct {
|
||||
userSrvc services.IUserService
|
||||
heartbeatSrvc services.IHeartbeatService
|
||||
}
|
||||
|
||||
func NewHeartbeatHandler(userService services.IUserService, heartbeatService services.IHeartbeatService) *HeartbeatHandler {
|
||||
return &HeartbeatHandler{
|
||||
userSrvc: userService,
|
||||
heartbeatSrvc: heartbeatService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HeartbeatHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("").Subrouter()
|
||||
r.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
|
||||
)
|
||||
r.Path("/compat/wakatime/v1/users/{user}/heartbeats").Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
}
|
||||
|
||||
// @Summary Get heartbeats of user for specified date
|
||||
// @ID get-heartbeats
|
||||
// @Tags heartbeat
|
||||
// @Param date query string true "Date"
|
||||
// @Param user path string true "Username (or current)"
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 200 {object} HeartbeatsResult
|
||||
// @Failure 400 {string} string "bad date"
|
||||
// @Router /compat/wakatime/v1/users/{user}/heartbeats [get]
|
||||
func (h *HeartbeatHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
||||
if err != nil {
|
||||
return // response was already sent by util function
|
||||
}
|
||||
|
||||
params := r.URL.Query()
|
||||
dateParam := params.Get("date")
|
||||
date, err := time.Parse(conf.SimpleDateFormat, dateParam)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("bad date"))
|
||||
return
|
||||
}
|
||||
|
||||
timezone := user.TZ()
|
||||
rangeFrom, rangeTo := utils.StartOfDay(date.In(timezone)), utils.EndOfDay(date.In(timezone))
|
||||
|
||||
heartbeats, err := h.heartbeatSrvc.GetAllWithin(rangeFrom, rangeTo, user)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(conf.ErrInternalServerError))
|
||||
conf.Log().Request(r).Error("failed to retrieve heartbeats - %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
res := HeartbeatsResult{
|
||||
Data: wakatime.HeartbeatsToCompat(heartbeats),
|
||||
Start: rangeFrom.UTC().Format(time.RFC3339),
|
||||
End: rangeTo.UTC().Format(time.RFC3339),
|
||||
Timezone: timezone.String(),
|
||||
}
|
||||
utils.RespondJSON(w, r, http.StatusOK, res)
|
||||
}
|
@ -98,7 +98,7 @@ func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
user.LastLoggedInAt = models.CustomTime(time.Now())
|
||||
h.userSrvc.Update(user)
|
||||
|
||||
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
|
||||
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
@ -107,7 +107,7 @@ func (h *LoginHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey, "/"))
|
||||
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
@ -284,7 +284,7 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
|
||||
go func(user *models.User) {
|
||||
link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken)
|
||||
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 {
|
||||
logbuch.Info("sent password reset mail to %s", user.ID)
|
||||
}
|
||||
@ -301,8 +301,9 @@ func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
|
||||
numUsers, _ := h.userSrvc.Count()
|
||||
|
||||
return &view.LoginViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
TotalUsers: int(numUsers),
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
TotalUsers: int(numUsers),
|
||||
AllowSignup: h.config.IsDev() || h.config.Security.AllowSignup,
|
||||
}
|
||||
}
|
||||
|
@ -56,6 +56,9 @@ func DefaultTemplateFuncs() template.FuncMap {
|
||||
"avatarUrlTemplate": func() string {
|
||||
return config.Get().App.AvatarURLTemplate
|
||||
},
|
||||
"defaultWakatimeUrl": func() string {
|
||||
return config.WakatimeApiUrl
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -230,7 +230,7 @@ func (h *SettingsHandler) actionChangePassword(w http.ResponseWriter, r *http.Re
|
||||
return http.StatusInternalServerError, "", conf.ErrInternalServerError
|
||||
}
|
||||
|
||||
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
|
||||
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded))
|
||||
return http.StatusOK, "password was updated successfully", ""
|
||||
}
|
||||
|
||||
@ -431,13 +431,17 @@ func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http
|
||||
|
||||
user := middlewares.GetPrincipal(r)
|
||||
apiKey := r.PostFormValue("api_key")
|
||||
apiUrl := r.PostFormValue("api_url")
|
||||
if apiUrl == conf.WakatimeApiUrl || apiKey == "" {
|
||||
apiUrl = ""
|
||||
}
|
||||
|
||||
// Healthcheck, if a new API key is set, i.e. the feature is activated
|
||||
if (user.WakatimeApiKey == "" && apiKey != "") && !h.validateWakatimeKey(apiKey) {
|
||||
if (user.WakatimeApiKey == "" && apiKey != "") && !h.validateWakatimeKey(apiKey, apiUrl) {
|
||||
return http.StatusBadRequest, "", "failed to connect to WakaTime, API key invalid?"
|
||||
}
|
||||
|
||||
if _, err := h.userSrvc.SetWakatimeApiKey(user, apiKey); err != nil {
|
||||
if _, err := h.userSrvc.SetWakatimeApiCredentials(user, apiKey, apiUrl); err != nil {
|
||||
return http.StatusInternalServerError, "", conf.ErrInternalServerError
|
||||
}
|
||||
|
||||
@ -488,7 +492,7 @@ func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Re
|
||||
|
||||
insert := func(batch []*models.Heartbeat) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -514,13 +518,13 @@ func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Re
|
||||
if !user.HasData {
|
||||
user.HasData = true
|
||||
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 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 {
|
||||
logbuch.Info("sent import notification mail to %s", user.ID)
|
||||
}
|
||||
@ -542,11 +546,11 @@ func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *ht
|
||||
|
||||
go func(user *models.User) {
|
||||
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))
|
||||
|
||||
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) {
|
||||
@ -559,18 +563,22 @@ func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Reques
|
||||
logbuch.Info("deleting user '%s' shortly", user.ID)
|
||||
time.Sleep(5 * time.Minute)
|
||||
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 {
|
||||
logbuch.Info("successfully deleted user '%s'", user.ID)
|
||||
}
|
||||
}(user)
|
||||
|
||||
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey, "/"))
|
||||
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "Your account will be deleted in a few minutes. Sorry to you go."), http.StatusFound)
|
||||
return -1, "", ""
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) validateWakatimeKey(apiKey string) bool {
|
||||
func (h *SettingsHandler) validateWakatimeKey(apiKey string, baseUrl string) bool {
|
||||
if baseUrl == "" {
|
||||
baseUrl = conf.WakatimeApiUrl
|
||||
}
|
||||
|
||||
headers := http.Header{
|
||||
"Accept": []string{"application/json"},
|
||||
"Authorization": []string{
|
||||
@ -580,7 +588,7 @@ func (h *SettingsHandler) validateWakatimeKey(apiKey string) bool {
|
||||
|
||||
request, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
conf.WakatimeApiUrl+conf.WakatimeApiUserUrl,
|
||||
baseUrl+conf.WakatimeApiUserUrl,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
|
53
routes/utils/heartbeat_utils.go
Normal file
53
routes/utils/heartbeat_utils.go
Normal file
@ -0,0 +1,53 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"github.com/muety/wakapi/models"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func ParseHeartbeats(r *http.Request) ([]*models.Heartbeat, error) {
|
||||
heartbeats, err := tryParseBulk(r)
|
||||
if err == nil {
|
||||
return heartbeats, err
|
||||
}
|
||||
|
||||
heartbeats, err = tryParseSingle(r)
|
||||
if err == nil {
|
||||
return heartbeats, err
|
||||
}
|
||||
|
||||
return []*models.Heartbeat{}, err
|
||||
}
|
||||
|
||||
func tryParseBulk(r *http.Request) ([]*models.Heartbeat, error) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
dec := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body)))
|
||||
if err := dec.Decode(&heartbeats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return heartbeats, nil
|
||||
}
|
||||
|
||||
func tryParseSingle(r *http.Request) ([]*models.Heartbeat, error) {
|
||||
var heartbeat models.Heartbeat
|
||||
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
dec := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body)))
|
||||
if err := dec.Decode(&heartbeat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*models.Heartbeat{&heartbeat}, nil
|
||||
}
|
@ -21,6 +21,7 @@ LANGUAGES = {
|
||||
'PHP': 'php',
|
||||
'Blade': 'blade.php'
|
||||
}
|
||||
BRANCHES = ['master', 'feature-1', 'feature-2']
|
||||
|
||||
|
||||
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)
|
||||
l: str = random.choice(languages)
|
||||
f: str = randomword(random.randint(2, 8))
|
||||
b: str = random.choice(BRANCHES)
|
||||
delta: timedelta = timedelta(
|
||||
hours=random.randint(0, n_past_hours - 1),
|
||||
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]}',
|
||||
project=p,
|
||||
language=l,
|
||||
branch=b,
|
||||
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) {
|
||||
for job := range jobs {
|
||||
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 {
|
||||
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
|
||||
summaries <- summary
|
||||
@ -95,7 +95,7 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
|
||||
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
|
||||
for summary := range summaries {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
@ -13,8 +16,6 @@ import (
|
||||
"github.com/muety/wakapi/utils"
|
||||
"go.uber.org/atomic"
|
||||
"golang.org/x/sync/semaphore"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const OriginWakatime = "wakatime"
|
||||
@ -40,9 +41,11 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
out := make(chan *models.Heartbeat)
|
||||
|
||||
go func(user *models.User, out chan *models.Heartbeat) {
|
||||
startDate, endDate, err := w.fetchRange()
|
||||
baseUrl := user.WakaTimeURL(config.WakatimeApiUrl)
|
||||
|
||||
startDate, endDate, err := w.fetchRange(baseUrl)
|
||||
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
|
||||
}
|
||||
|
||||
@ -53,15 +56,15 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
endDate = maxTo
|
||||
}
|
||||
|
||||
userAgents, err := w.fetchUserAgents()
|
||||
userAgents, err := w.fetchUserAgents(baseUrl)
|
||||
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
|
||||
}
|
||||
|
||||
machinesNames, err := w.fetchMachineNames()
|
||||
machinesNames, err := w.fetchMachineNames(baseUrl)
|
||||
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
|
||||
}
|
||||
|
||||
@ -73,7 +76,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
|
||||
for _, d := range days {
|
||||
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
|
||||
}
|
||||
|
||||
@ -82,9 +85,9 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
defer time.Sleep(throttleDelay)
|
||||
|
||||
d := day.Format(config.SimpleDateFormat)
|
||||
heartbeats, err := w.fetchHeartbeats(d)
|
||||
heartbeats, err := w.fetchHeartbeats(d, baseUrl)
|
||||
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 {
|
||||
@ -107,10 +110,10 @@ func (w *WakatimeHeartbeatImporter) ImportAll(user *models.User) <-chan *models.
|
||||
|
||||
// https://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05
|
||||
// https://pastr.de/p/b5p4od5s8w0pfntmwoi117jy
|
||||
func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string) ([]*wakatime.HeartbeatEntry, error) {
|
||||
func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string) ([]*wakatime.HeartbeatEntry, error) {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiHeartbeatsUrl, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiHeartbeatsUrl, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -136,12 +139,12 @@ func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string) ([]*wakatime.Hea
|
||||
|
||||
// https://wakatime.com/api/v1/users/current/all_time_since_today
|
||||
// https://pastr.de/p/w8xb4biv575pu32pox7jj2gr
|
||||
func (w *WakatimeHeartbeatImporter) fetchRange() (time.Time, time.Time, error) {
|
||||
func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.Time, error) {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
notime := time.Time{}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiAllTimeUrl, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiAllTimeUrl, nil)
|
||||
if err != nil {
|
||||
return notime, notime, err
|
||||
}
|
||||
@ -171,13 +174,13 @@ func (w *WakatimeHeartbeatImporter) fetchRange() (time.Time, time.Time, error) {
|
||||
|
||||
// https://wakatime.com/api/v1/users/current/user_agents
|
||||
// https://pastr.de/p/05k5do8q108k94lic4lfl3pc
|
||||
func (w *WakatimeHeartbeatImporter) fetchUserAgents() (map[string]*wakatime.UserAgentEntry, error) {
|
||||
func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]*wakatime.UserAgentEntry, error) {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
userAgents := make(map[string]*wakatime.UserAgentEntry)
|
||||
|
||||
for page := 1; ; page++ {
|
||||
url := fmt.Sprintf("%s%s?page=%d", config.WakatimeApiUrl, config.WakatimeApiUserAgentsUrl, page)
|
||||
url := fmt.Sprintf("%s%s?page=%d", baseUrl, config.WakatimeApiUserAgentsUrl, page)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -207,13 +210,13 @@ func (w *WakatimeHeartbeatImporter) fetchUserAgents() (map[string]*wakatime.User
|
||||
|
||||
// https://wakatime.com/api/v1/users/current/machine_names
|
||||
// https://pastr.de/p/v58cv0xrupp3zvyyv8o6973j
|
||||
func (w *WakatimeHeartbeatImporter) fetchMachineNames() (map[string]*wakatime.MachineEntry, error) {
|
||||
func (w *WakatimeHeartbeatImporter) fetchMachineNames(baseUrl string) (map[string]*wakatime.MachineEntry, error) {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
machines := make(map[string]*wakatime.MachineEntry)
|
||||
|
||||
for page := 1; ; page++ {
|
||||
url := fmt.Sprintf("%s%s?page=%d", config.WakatimeApiUrl, config.WakatimeApiMachineNamesUrl, page)
|
||||
url := fmt.Sprintf("%s%s?page=%d", baseUrl, config.WakatimeApiMachineNamesUrl, page)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -282,9 +285,10 @@ func mapHeartbeat(
|
||||
OperatingSystem: ua.Os,
|
||||
Machine: ma.Value,
|
||||
UserAgent: ua.Value,
|
||||
Time: entry.Time,
|
||||
Time: models.CustomTime(time.Unix(0, int64(entry.Time*1e9))),
|
||||
Origin: OriginWakatime,
|
||||
OriginId: entry.Id,
|
||||
CreatedAt: models.CustomTime(entry.CreatedAt),
|
||||
}).Hashed()
|
||||
}
|
||||
|
||||
|
@ -89,7 +89,7 @@ func (srv *ReportService) SyncSchedule(u *models.User) bool {
|
||||
At(t).
|
||||
Tag(u.ID).
|
||||
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 {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@ -126,7 +126,7 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -107,7 +107,7 @@ type IUserService interface {
|
||||
Update(*models.User) (*models.User, error)
|
||||
Delete(*models.User) error
|
||||
ResetApiKey(*models.User) (*models.User, error)
|
||||
SetWakatimeApiKey(*models.User, string) (*models.User, error)
|
||||
SetWakatimeApiCredentials(*models.User, string, string) (*models.User, error)
|
||||
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
|
||||
GenerateResetToken(*models.User) (*models.User, error)
|
||||
FlushCache()
|
||||
|
@ -38,7 +38,7 @@ func NewUserService(mailService IMailService, userRepo repositories.IUserReposit
|
||||
|
||||
logbuch.Warn("resetting wakatime api key for user %s, because of too many failures (%d)", user.ID, n)
|
||||
|
||||
if _, err := srv.SetWakatimeApiKey(user, ""); err != nil {
|
||||
if _, err := srv.SetWakatimeApiCredentials(user, "", ""); err != nil {
|
||||
logbuch.Error("failed to set wakatime api key for user %s", user.ID)
|
||||
}
|
||||
|
||||
@ -154,9 +154,20 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
|
||||
return srv.Update(user)
|
||||
}
|
||||
|
||||
func (srv *UserService) SetWakatimeApiKey(user *models.User, apiKey string) (*models.User, error) {
|
||||
func (srv *UserService) SetWakatimeApiCredentials(user *models.User, apiKey string, apiUrl string) (*models.User, error) {
|
||||
srv.cache.Flush()
|
||||
return srv.repository.UpdateField(user, "wakatime_api_key", apiKey)
|
||||
|
||||
if apiKey != user.WakatimeApiKey {
|
||||
if u, err := srv.repository.UpdateField(user, "wakatime_api_key", apiKey); err != nil {
|
||||
return u, err
|
||||
}
|
||||
}
|
||||
|
||||
if apiUrl != user.WakatimeApiUrl {
|
||||
return srv.repository.UpdateField(user, "wakatime_api_url", apiUrl)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
||||
|
@ -57,6 +57,10 @@ body {
|
||||
@apply py-2 px-4 font-semibold rounded bg-gray-800 hover:bg-gray-850 text-white text-sm;
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
@apply py-2 px-4 font-semibold rounded bg-gray-800 text-gray-600 text-sm;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply py-2 px-4 font-semibold rounded bg-green-700 hover:bg-green-800 text-white text-sm;
|
||||
}
|
||||
@ -92,4 +96,4 @@ body {
|
||||
::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because one or more lines are too long
Binary file not shown.
@ -132,9 +132,9 @@ function draw(subselection) {
|
||||
onClick: (event, data) => {
|
||||
const idx = data[0].index
|
||||
const name = wakapiData.projects[idx].key
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
query.set('project', name)
|
||||
window.location.replace(`${window.location.pathname.slice(1)}?${query.toString()}`)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('query', name)
|
||||
window.location.href = url.href
|
||||
},
|
||||
onHover: (event, elem) => {
|
||||
event.native.target.style.cursor = elem[0] ? 'pointer' : 'default'
|
||||
@ -331,13 +331,13 @@ function draw(subselection) {
|
||||
})
|
||||
: null
|
||||
|
||||
let branchChart = branchesCanvas && !branchesCanvas.classList.contains('hidden') && shouldUpdate(0)
|
||||
let branchChart = branchesCanvas && !branchesCanvas.classList.contains('hidden') && shouldUpdate(6)
|
||||
? new Chart(branchesCanvas.getContext('2d'), {
|
||||
type: "bar",
|
||||
data: {
|
||||
datasets: [{
|
||||
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)),
|
||||
backgroundColor: wakapiData.branches.map((p, i) => {
|
||||
const c = hexToRgb(getColor(p.key, i % baseColors.length))
|
||||
@ -349,7 +349,7 @@ function draw(subselection) {
|
||||
}),
|
||||
}],
|
||||
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)
|
||||
},
|
||||
options: {
|
||||
|
@ -1,13 +1,14 @@
|
||||
// Package docs GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
|
||||
// GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
|
||||
// This file was generated by swaggo/swag
|
||||
|
||||
package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/alecthomas/template"
|
||||
"github.com/swaggo/swag"
|
||||
)
|
||||
|
||||
@ -15,7 +16,7 @@ var doc = `{
|
||||
"schemes": {{ marshal .Schemes }},
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "{{escape .Description}}",
|
||||
"description": "{{.Description}}",
|
||||
"title": "{{.Title}}",
|
||||
"contact": {
|
||||
"name": "Ferdinand Mütsch",
|
||||
@ -160,6 +161,48 @@ var doc = `{
|
||||
}
|
||||
},
|
||||
"/compat/wakatime/v1/users/{user}/heartbeats": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"heartbeat"
|
||||
],
|
||||
"summary": "Get heartbeats of user for specified date",
|
||||
"operationId": "get-heartbeats",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Date",
|
||||
"name": "date",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.HeartbeatsResult"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "bad date",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
@ -183,6 +226,13 @@ var doc = `{
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Heartbeat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -219,6 +269,13 @@ var doc = `{
|
||||
"$ref": "#/definitions/models.Heartbeat"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -863,6 +920,13 @@ var doc = `{
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Heartbeat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -899,6 +963,13 @@ var doc = `{
|
||||
"$ref": "#/definitions/models.Heartbeat"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -967,6 +1038,13 @@ var doc = `{
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Heartbeat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -1003,6 +1081,13 @@ var doc = `{
|
||||
"$ref": "#/definitions/models.Heartbeat"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -1094,6 +1179,13 @@ var doc = `{
|
||||
"models.Summary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branches": {
|
||||
"description": "branches are not persisted, but calculated at runtime in case a project filter is applied",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.SummaryItem"
|
||||
}
|
||||
},
|
||||
"editors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -1222,6 +1314,73 @@ var doc = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.HeartbeatEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"entity": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_write": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"language": {
|
||||
"type": "string"
|
||||
},
|
||||
"machine_name_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"modified_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"project": {
|
||||
"type": "string"
|
||||
},
|
||||
"time": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.HeartbeatsResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v1.HeartbeatEntry"
|
||||
}
|
||||
},
|
||||
"end": {
|
||||
"type": "string"
|
||||
},
|
||||
"start": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.Project": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -1250,6 +1409,12 @@ var doc = `{
|
||||
"v1.StatsData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branches": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v1.SummariesEntry"
|
||||
}
|
||||
},
|
||||
"daily_average": {
|
||||
"type": "number"
|
||||
},
|
||||
@ -1325,6 +1490,12 @@ var doc = `{
|
||||
"v1.SummariesData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branches": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v1.SummariesEntry"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -1556,13 +1727,6 @@ func (s *s) ReadDoc() string {
|
||||
a, _ := json.Marshal(v)
|
||||
return string(a)
|
||||
},
|
||||
"escape": func(v interface{}) string {
|
||||
// escape tabs
|
||||
str := strings.Replace(v.(string), "\t", "\\t", -1)
|
||||
// replace " with \", and if that results in \\", replace that with \\\"
|
||||
str = strings.Replace(str, "\"", "\\\"", -1)
|
||||
return strings.Replace(str, "\\\\\"", "\\\\\\\"", -1)
|
||||
},
|
||||
}).Parse(doc)
|
||||
if err != nil {
|
||||
return doc
|
||||
@ -1577,5 +1741,5 @@ func (s *s) ReadDoc() string {
|
||||
}
|
||||
|
||||
func init() {
|
||||
swag.Register("swagger", &s{})
|
||||
swag.Register(swag.Name, &s{})
|
||||
}
|
||||
|
@ -145,6 +145,48 @@
|
||||
}
|
||||
},
|
||||
"/compat/wakatime/v1/users/{user}/heartbeats": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"heartbeat"
|
||||
],
|
||||
"summary": "Get heartbeats of user for specified date",
|
||||
"operationId": "get-heartbeats",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Date",
|
||||
"name": "date",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.HeartbeatsResult"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "bad date",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
@ -168,6 +210,13 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Heartbeat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -204,6 +253,13 @@
|
||||
"$ref": "#/definitions/models.Heartbeat"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -848,6 +904,13 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Heartbeat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -884,6 +947,13 @@
|
||||
"$ref": "#/definitions/models.Heartbeat"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -952,6 +1022,13 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Heartbeat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -988,6 +1065,13 @@
|
||||
"$ref": "#/definitions/models.Heartbeat"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username (or current)",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -1079,6 +1163,13 @@
|
||||
"models.Summary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branches": {
|
||||
"description": "branches are not persisted, but calculated at runtime in case a project filter is applied",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.SummaryItem"
|
||||
}
|
||||
},
|
||||
"editors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -1207,6 +1298,73 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.HeartbeatEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"entity": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_write": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"language": {
|
||||
"type": "string"
|
||||
},
|
||||
"machine_name_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"modified_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"project": {
|
||||
"type": "string"
|
||||
},
|
||||
"time": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.HeartbeatsResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v1.HeartbeatEntry"
|
||||
}
|
||||
},
|
||||
"end": {
|
||||
"type": "string"
|
||||
},
|
||||
"start": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.Project": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -1235,6 +1393,12 @@
|
||||
"v1.StatsData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branches": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v1.SummariesEntry"
|
||||
}
|
||||
},
|
||||
"daily_average": {
|
||||
"type": "number"
|
||||
},
|
||||
@ -1310,6 +1474,12 @@
|
||||
"v1.SummariesData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branches": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v1.SummariesEntry"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
@ -54,6 +54,12 @@ definitions:
|
||||
type: object
|
||||
models.Summary:
|
||||
properties:
|
||||
branches:
|
||||
description: branches are not persisted, but calculated at runtime in case
|
||||
a project filter is applied
|
||||
items:
|
||||
$ref: '#/definitions/models.SummaryItem'
|
||||
type: array
|
||||
editors:
|
||||
items:
|
||||
$ref: '#/definitions/models.SummaryItem'
|
||||
@ -142,6 +148,50 @@ definitions:
|
||||
schemaVersion:
|
||||
type: integer
|
||||
type: object
|
||||
v1.HeartbeatEntry:
|
||||
properties:
|
||||
branch:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
entity:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
is_write:
|
||||
type: boolean
|
||||
language:
|
||||
type: string
|
||||
machine_name_id:
|
||||
type: string
|
||||
modified_at:
|
||||
type: string
|
||||
project:
|
||||
type: string
|
||||
time:
|
||||
type: number
|
||||
type:
|
||||
type: string
|
||||
user_agent_id:
|
||||
type: string
|
||||
user_id:
|
||||
type: string
|
||||
type: object
|
||||
v1.HeartbeatsResult:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/v1.HeartbeatEntry'
|
||||
type: array
|
||||
end:
|
||||
type: string
|
||||
start:
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
type: object
|
||||
v1.Project:
|
||||
properties:
|
||||
id:
|
||||
@ -160,6 +210,10 @@ definitions:
|
||||
type: object
|
||||
v1.StatsData:
|
||||
properties:
|
||||
branches:
|
||||
items:
|
||||
$ref: '#/definitions/v1.SummariesEntry'
|
||||
type: array
|
||||
daily_average:
|
||||
type: number
|
||||
days_including_holidays:
|
||||
@ -209,6 +263,10 @@ definitions:
|
||||
type: object
|
||||
v1.SummariesData:
|
||||
properties:
|
||||
branches:
|
||||
items:
|
||||
$ref: '#/definitions/v1.SummariesEntry'
|
||||
type: array
|
||||
categories:
|
||||
items:
|
||||
$ref: '#/definitions/v1.SummariesEntry'
|
||||
@ -441,6 +499,33 @@ paths:
|
||||
tags:
|
||||
- wakatime
|
||||
/compat/wakatime/v1/users/{user}/heartbeats:
|
||||
get:
|
||||
operationId: get-heartbeats
|
||||
parameters:
|
||||
- description: Date
|
||||
in: query
|
||||
name: date
|
||||
required: true
|
||||
type: string
|
||||
- description: Username (or current)
|
||||
in: path
|
||||
name: user
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/v1.HeartbeatsResult'
|
||||
"400":
|
||||
description: bad date
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get heartbeats of user for specified date
|
||||
tags:
|
||||
- heartbeat
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
@ -452,6 +537,11 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Heartbeat'
|
||||
- description: Username (or current)
|
||||
in: path
|
||||
name: user
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
description: ""
|
||||
@ -474,6 +564,11 @@ paths:
|
||||
items:
|
||||
$ref: '#/definitions/models.Heartbeat'
|
||||
type: array
|
||||
- description: Username (or current)
|
||||
in: path
|
||||
name: user
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
description: ""
|
||||
@ -900,6 +995,11 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Heartbeat'
|
||||
- description: Username (or current)
|
||||
in: path
|
||||
name: user
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
description: ""
|
||||
@ -922,6 +1022,11 @@ paths:
|
||||
items:
|
||||
$ref: '#/definitions/models.Heartbeat'
|
||||
type: array
|
||||
- description: Username (or current)
|
||||
in: path
|
||||
name: user
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
description: ""
|
||||
@ -965,6 +1070,11 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Heartbeat'
|
||||
- description: Username (or current)
|
||||
in: path
|
||||
name: user
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
description: ""
|
||||
@ -987,6 +1097,11 @@ paths:
|
||||
items:
|
||||
$ref: '#/definitions/models.Heartbeat'
|
||||
type: array
|
||||
- description: Username (or current)
|
||||
in: path
|
||||
name: user
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
description: ""
|
||||
|
@ -30,7 +30,7 @@ done
|
||||
echo ""
|
||||
|
||||
echo "Running test collection ..."
|
||||
newman run "Wakapi API Tests.postman_collection.json"
|
||||
newman run "wakapi_api_tests.postman_collection.json"
|
||||
exit_code=$?
|
||||
|
||||
echo "Shutting down Wakapi ..."
|
||||
@ -39,4 +39,4 @@ kill -TERM $pid
|
||||
echo "Deleting database ..."
|
||||
rm wakapi_testing.db
|
||||
|
||||
exit $exit_code
|
||||
exit $exit_code
|
||||
|
@ -965,6 +965,121 @@
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Create heartbeats (get heartbeats test)",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"// 1640995199 Friday, 31 December 2021 11:59:59 PM (Jan 1st in +1, +2)",
|
||||
"// 1641074399 Saturday, 1 January 2022 9:59:59 PM (Jan 1st in +1, +2)",
|
||||
"// 1641081599 Saturday, 1 January 2022 11:59:59 PM (Jan 2nd in +1, +2)",
|
||||
""
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"bearer": [
|
||||
{
|
||||
"key": "token",
|
||||
"value": "{{WRITEUSER_TOKEN}}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "[{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1640995199\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641074399\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641081599\n}]",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{BASE_URL}}/api/heartbeat",
|
||||
"host": [
|
||||
"{{BASE_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"heartbeat"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get heartbeats",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test(\"Status code is 200\", function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"",
|
||||
"pm.test(\"Response body is correct\", function () {",
|
||||
" var jsonData = pm.response.json();",
|
||||
" pm.expect(jsonData.timezone).to.eql(pm.collectionVariables.get('TZ'));",
|
||||
" var date = new Date(\"2022-01-01T00:00:00+0100\")",
|
||||
" pm.expect(new Date(jsonData.start)).to.eql(date);",
|
||||
" pm.expect(new Date(jsonData.end)).to.eql(new Date(date.getTime() + 3600 * 1000 * 24));",
|
||||
" pm.expect(jsonData.data.length).to.eql(2);",
|
||||
"});"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"protocolProfileBehavior": {
|
||||
"disableCookies": true
|
||||
},
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"bearer": [
|
||||
{
|
||||
"key": "token",
|
||||
"value": "{{WRITEUSER_TOKEN}}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date=2022-01-01",
|
||||
"host": [
|
||||
"{{BASE_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"compat",
|
||||
"wakatime",
|
||||
"v1",
|
||||
"users",
|
||||
"current",
|
||||
"heartbeats"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "date",
|
||||
"value": "2022-01-01"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -1982,7 +2097,7 @@
|
||||
"",
|
||||
"pm.test(\"Correct content\", function () {",
|
||||
" const jsonData = pm.response.json();",
|
||||
" pm.expect(jsonData.data.text).to.eql('0 hrs 2 mins');",
|
||||
" pm.expect(jsonData.data.text).to.eql('0 hrs 8 mins');",
|
||||
"});"
|
||||
],
|
||||
"type": "text/javascript"
|
@ -1,23 +1,28 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"github.com/patrickmn/go-cache"
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NewExistsFS(fs fs.FS) ExistsFS {
|
||||
cache, err := lru.New(1 << 24)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return ExistsFS{
|
||||
FS: fs,
|
||||
cache: cache.New(cache.NoExpiration, cache.NoExpiration),
|
||||
cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
type ExistsFS struct {
|
||||
fs.FS
|
||||
UseCache bool
|
||||
cache *cache.Cache
|
||||
cache *lru.Cache
|
||||
}
|
||||
|
||||
func (efs ExistsFS) WithCache(withCache bool) ExistsFS {
|
||||
@ -34,7 +39,7 @@ func (efs ExistsFS) Exists(name string) bool {
|
||||
_, err := fs.Stat(efs.FS, name)
|
||||
result := err == nil
|
||||
if efs.UseCache {
|
||||
efs.cache.SetDefault(name, result)
|
||||
efs.cache.Add(name, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
2.0.1
|
||||
2.2.3
|
@ -12,7 +12,7 @@
|
||||
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
|
||||
<div class="mx-1">
|
||||
<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>
|
||||
|
||||
|
@ -31,9 +31,15 @@
|
||||
Forgot password?
|
||||
</a>
|
||||
<div class="flex space-x-2">
|
||||
{{ if .AllowSignup }}
|
||||
<a href="signup">
|
||||
<button type="button" class="btn-default">Sign up</button>
|
||||
</a>
|
||||
{{ else }}
|
||||
<a title="The administrator of this instance has disabled sign up.">
|
||||
<button type="button" class="btn-disabled" disabled> Sign up </button>
|
||||
</a>
|
||||
{{ end }}
|
||||
<button type="submit" class="btn-primary">Log in</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -46,4 +52,4 @@
|
||||
{{ template "foot.tpl.html" . }}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
@ -489,14 +489,17 @@
|
||||
<div class="w-full md:w-1/2 mb-4 md:mb-0 inline-block">
|
||||
<label class="font-semibold text-gray-300" for="select-timezone">WakaTime</label>
|
||||
<span class="block text-sm text-gray-600">
|
||||
You can connect Wakapi with the official WakaTime in a way that all heartbeats sent to Wakapi are relayed. This way, you can use both services at the same time. To get started, get <a class="link" href="https://wakatime.com/developers#authentication" rel="noopener noreferrer" target="_blank">your API key</a> and paste it here.<br><br>
|
||||
You can connect Wakapi with the official WakaTime (or another Wakapi instance, when optionally specifying a custom API URL) in a way that all heartbeats sent to Wakapi are relayed. This way, you can use both services at the same time. To get started, get <a class="link" href="https://wakatime.com/developers#authentication" rel="noopener noreferrer" target="_blank">your API key</a> and paste it here.<br><br>
|
||||
Please note: When enabling this feature, the operators of this server will, in theory, have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a
|
||||
class="link" target="_blank" href="https://github.com/muety/wakapi/issues/94" rel="noopener noreferrer">#94</a>) to be implemented.
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full md:w-1/2">
|
||||
<input type="url" name="api_url" id="wakatime_api_url"
|
||||
class="w-full appearance-none bg-gray-850 text-gray-300 outline-none rounded py-2 px-4 mb-2 {{ if not .User.WakatimeApiKey }}focus:bg-gray-800{{ end }} {{ if .User.WakatimeApiKey }}cursor-not-allowed{{ end }}"
|
||||
placeholder="{{ defaultWakatimeUrl }}" {{ if .User.WakatimeApiKey }}readonly{{ end }} value="{{ .User.WakaTimeURL "" }}">
|
||||
<input type="password" name="api_key" id="wakatime_api_key"
|
||||
class="w-full appearance-none bg-gray-850 text-gray-300 outline-none rounded py-2 px-4 {{ if not .User.WakatimeApiKey }}focus:bg-gray-800{{ end }} {{ if .User.WakatimeApiKey }}cursor-not-allowed{{ end }}"
|
||||
class="w-full appearance-none bg-gray-850 text-gray-300 outline-none rounded py-2 px-4 mt-2 {{ if not .User.WakatimeApiKey }}focus:bg-gray-800{{ end }} {{ if .User.WakatimeApiKey }}cursor-not-allowed{{ end }}"
|
||||
placeholder="{{ $placeholderText }}" {{ if .User.WakatimeApiKey }}readonly{{ end }}>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -83,9 +83,15 @@
|
||||
<a href="login">
|
||||
<button type="button" class="btn-default">Log in</button>
|
||||
</a>
|
||||
{{ if .AllowSignup }}
|
||||
<button type="submit" class="btn-primary">
|
||||
Create Account
|
||||
</button>
|
||||
{{ else }}
|
||||
<button type="submit" class="btn-disabled" disabled title="The administrator of this instance has disabled sign up.">
|
||||
Create Account
|
||||
</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -97,4 +103,4 @@
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
@ -189,12 +189,11 @@
|
||||
# <strong>Step 1:</strong> Download WakaTime plugin for your IDE<br>
|
||||
# See: https://wakatime.com/plugins<br><br>
|
||||
|
||||
# <strong>Step 2:</strong> Adapt your config<br>
|
||||
$ vi ~/.wakatime.cfg<br>
|
||||
|
||||
# <strong>Step 2:</strong> Set your ~/.wakatime.cfg to this:<br><br>
|
||||
<!-- https://github.com/muety/wakapi/issues/224#issuecomment-890855563 -->
|
||||
# Set <em>api_url = <span class="with-url-inner">%s/api</span></em><br>
|
||||
# Set <em>api_key = <span id="api-key-instruction">{{ .ApiKey }}</span></em><br><br>
|
||||
[settings]<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!
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user