mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
42 Commits
Author | SHA1 | Date | |
---|---|---|---|
f15efcd6f2 | |||
22e91ad362 | |||
932ba111cc | |||
23d00d574b | |||
d4b15e7959 | |||
42808fa38a | |||
52269c780f | |||
302eb33b1b | |||
784adec3c1 | |||
d2cdd35fff | |||
33d65fb33a | |||
6d762f5fd6 | |||
222024dabb | |||
660a09475e | |||
5cc932177f | |||
ac9d96c563 | |||
3758eecc96 | |||
e21788b8b5 | |||
e7f3432113 | |||
7159df30c2 | |||
fce3a3ea20 | |||
bd2a8c5a7f | |||
632a3d4a91 | |||
8a344ce4a2 | |||
cbbb592143 | |||
67f0d19a65 | |||
03b104a390 | |||
4a3fe48cce | |||
1033343702 | |||
31c462c275 | |||
0a7ebc4dc7 | |||
91768cf927 | |||
e967a74e36 | |||
8e6719f0b7 | |||
bcbd6236df | |||
d1cbabf662 | |||
ad4d251154 | |||
2bb3b886c2 | |||
4f183ed637 | |||
b66f9b5cf5 | |||
bf7f93fcd4 | |||
36c96dafca |
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
|
28
.github/workflows/docker.yml
vendored
28
.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
|
||||
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
|
||||
|
26
Caddyfile
Normal file
26
Caddyfile
Normal file
@ -0,0 +1,26 @@
|
||||
wakapi.yourdomain.tld {
|
||||
encode zstd gzip
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=2592000; includeSubDomains"
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/wakapi.dev.access.log
|
||||
format single_field common_log
|
||||
}
|
||||
|
||||
reverse_proxy http://[::1]:3000
|
||||
|
||||
@api path_regexp "^/api.*"
|
||||
@notapi not path_regexp "^/api.*"
|
||||
|
||||
push @notapi /assets/vendor/source-sans-3.css
|
||||
push @notapi /assets/css/app.dist.css
|
||||
push @notapi /assets/vendor/petite-vue.min.js
|
||||
push @notapi /assets/vendor/chart.min.js
|
||||
push @notapi /assets/vendor/iconify.basic.min.js
|
||||
push @notapi /assets/js/icons.dist.js
|
||||
push @notapi /assets/js/base.js
|
||||
push @notapi /assets/images/logo.svg
|
||||
}
|
25
Dockerfile
25
Dockerfile
@ -1,19 +1,26 @@
|
||||
# Build Stage
|
||||
# To build locally: docker buildx build . -t wakapi --load
|
||||
|
||||
FROM golang:1.16-alpine AS build-env
|
||||
# Preparation to save some time
|
||||
FROM --platform=$BUILDPLATFORM golang:1.17-alpine AS prep-env
|
||||
WORKDIR /src
|
||||
|
||||
# Required for go-sqlite3
|
||||
RUN apk add gcc musl-dev
|
||||
|
||||
ADD ./go.mod .
|
||||
RUN go mod download
|
||||
ADD . .
|
||||
|
||||
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \
|
||||
chmod +x wait-for-it.sh
|
||||
|
||||
ADD . .
|
||||
RUN go build -o wakapi
|
||||
# Build Stage
|
||||
FROM golang:1.17-alpine AS build-env
|
||||
|
||||
# Required for go-sqlite3
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
WORKDIR /src
|
||||
COPY --from=prep-env /src .
|
||||
|
||||
RUN go build -v -o wakapi
|
||||
|
||||
WORKDIR /app
|
||||
RUN cp /src/wakapi . && \
|
||||
@ -31,7 +38,7 @@ RUN cp /src/wakapi . && \
|
||||
FROM alpine:3
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk update && apk add bash ca-certificates tzdata && rm -rf /var/cache/apk
|
||||
RUN apk add --no-cache bash ca-certificates tzdata
|
||||
|
||||
# See README.md and config.default.yml for all config options
|
||||
ENV ENVIRONMENT prod
|
||||
@ -49,4 +56,6 @@ COPY --from=build-env /app .
|
||||
|
||||
VOLUME /data
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT /app/entrypoint.sh
|
||||
|
12
README.md
12
README.md
@ -29,14 +29,11 @@
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<img src="static/assets/images/screenshot.png" width="500px">
|
||||
<img src="static/assets/images/screenshot.webp" width="500px">
|
||||
</p>
|
||||
|
||||
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
@ -1,8 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ "$WAKAPI_DB_TYPE" == "sqlite3" ] || [ "$WAKAPI_DB_TYPE" == "" ]; then
|
||||
./wakapi
|
||||
exec ./wakapi
|
||||
else
|
||||
echo "Waiting for database to come up"
|
||||
./wait-for-it.sh "$WAKAPI_DB_HOST:$WAKAPI_DB_PORT" -s -t 60 -- ./wakapi
|
||||
fi
|
||||
exec ./wait-for-it.sh "$WAKAPI_DB_HOST:$WAKAPI_DB_PORT" -s -t 60 -- ./wakapi
|
||||
fi
|
||||
|
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -29,6 +29,11 @@ func (m *AliasServiceMock) GetByUser(s string) ([]*models.Alias, error) {
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) GetByUserAndType(s string, u uint8) ([]*models.Alias, error) {
|
||||
args := m.Called(s, u)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) GetByUserAndKeyAndType(s string, s2 string, u uint8) ([]*models.Alias, error) {
|
||||
args := m.Called(s, s2, u)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -17,7 +17,7 @@ import (
|
||||
|
||||
const (
|
||||
intervalPattern = `interval:([a-z0-9_]+)`
|
||||
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-\s]+)`
|
||||
entityFilterPattern = `(project|os|editor|language|machine|label):([_a-zA-Z0-9-\s\.]+)`
|
||||
)
|
||||
|
||||
type BadgeHandler struct {
|
||||
|
43
routes/compat/shields/v1/badge_test.go
Normal file
43
routes/compat/shields/v1/badge_test.go
Normal file
@ -0,0 +1,43 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBadgeHandler_EntityPattern(t *testing.T) {
|
||||
type test struct {
|
||||
test string
|
||||
key string
|
||||
val string
|
||||
}
|
||||
|
||||
pathPrefix := "/compat/shields/v1/current/today/"
|
||||
|
||||
tests := []test{
|
||||
{test: pathPrefix + "project:wakapi", key: "project", val: "wakapi"},
|
||||
{test: pathPrefix + "os:Linux", key: "os", val: "Linux"},
|
||||
{test: pathPrefix + "editor:VSCode", key: "editor", val: "VSCode"},
|
||||
{test: pathPrefix + "language:Java", key: "language", val: "Java"},
|
||||
{test: pathPrefix + "machine:devmachine", key: "machine", val: "devmachine"},
|
||||
{test: pathPrefix + "label:work", key: "label", val: "work"},
|
||||
{test: pathPrefix + "foo:bar", key: "", val: ""}, // invalid entity
|
||||
{test: pathPrefix + "project:01234", key: "project", val: "01234"}, // digits only
|
||||
{test: pathPrefix + "project:anchr-web-ext", key: "project", val: "anchr-web-ext"}, // with dashes
|
||||
{test: pathPrefix + "project:wakapi v2", key: "project", val: "wakapi v2"}, // with blank space
|
||||
{test: pathPrefix + "project:project", key: "project", val: "project"},
|
||||
{test: pathPrefix + "project:Anchr-Android_v2.0", key: "project", val: "Anchr-Android_v2.0"}, // all the way
|
||||
}
|
||||
|
||||
sut := regexp.MustCompile(entityFilterPattern)
|
||||
|
||||
for _, tc := range tests {
|
||||
var key, val string
|
||||
if groups := sut.FindStringSubmatch(tc.test); len(groups) > 2 {
|
||||
key, val = groups[1], groups[2]
|
||||
}
|
||||
assert.Equal(t, tc.key, key)
|
||||
assert.Equal(t, tc.val, val)
|
||||
}
|
||||
}
|
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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/services/imports"
|
||||
"github.com/muety/wakapi/utils"
|
||||
@ -229,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", ""
|
||||
}
|
||||
|
||||
@ -430,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
|
||||
}
|
||||
|
||||
@ -487,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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -513,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)
|
||||
}
|
||||
@ -541,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) {
|
||||
@ -558,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{
|
||||
@ -579,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 {
|
||||
@ -669,12 +678,11 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
||||
})
|
||||
|
||||
// projects
|
||||
projects, err := h.heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
|
||||
projects, err := routeutils.GetEffectiveProjectsList(user, h.heartbeatSrvc, h.aliasSrvc)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("error while fetching projects - %v", err)
|
||||
return &view.SettingsViewModel{Error: criticalError}
|
||||
}
|
||||
sort.Strings(projects)
|
||||
|
||||
return &view.SettingsViewModel{
|
||||
User: user,
|
||||
|
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
|
||||
}
|
53
routes/utils/project_utils.go
Normal file
53
routes/utils/project_utils.go
Normal file
@ -0,0 +1,53 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// GetEffectiveProjectsList returns the user's projects, including all alias targets and excluding all remapped project names (alias sources)
|
||||
// Example: "A" mapped to "AB" using an alias
|
||||
// -> "A" itself should not appear as a project anymore
|
||||
// -> Instead, the "virtual" project "AB" shall appear
|
||||
// See https://github.com/muety/wakapi/issues/231
|
||||
func GetEffectiveProjectsList(user *models.User, heartbeatSrvc services.IHeartbeatService, aliasSrvc services.IAliasService) ([]string, error) {
|
||||
projectsMap := make(map[string]bool) // proper sets as part of stdlib would be nice...
|
||||
|
||||
// extract actual projects from heartbeats
|
||||
realProjects, err := heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
|
||||
// create a "set" / lookup table
|
||||
for _, p := range realProjects {
|
||||
projectsMap[p] = true
|
||||
}
|
||||
|
||||
// fetch aliases
|
||||
projectAliases, err := aliasSrvc.GetByUserAndType(user.ID, models.SummaryProject)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
|
||||
// remove alias values (source of a mapping)
|
||||
// add alias key (target of a mapping) instead
|
||||
for _, a := range projectAliases {
|
||||
if projectsMap[a.Value] {
|
||||
projectsMap[a.Value] = false
|
||||
}
|
||||
projectsMap[a.Key] = true
|
||||
}
|
||||
|
||||
projects := make([]string, 0, len(projectsMap))
|
||||
for key, val := range projectsMap {
|
||||
if !val {
|
||||
continue
|
||||
}
|
||||
projects = append(projects, key)
|
||||
}
|
||||
|
||||
sort.Strings(projects)
|
||||
return projects, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -56,21 +56,18 @@ func (srv *AliasService) GetByUser(userId string) ([]*models.Alias, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetByUserAndType(userId string, summaryType uint8) ([]*models.Alias, error) {
|
||||
check := func(a *models.Alias) bool {
|
||||
return a.Type == summaryType
|
||||
}
|
||||
return srv.getFiltered(userId, check)
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
|
||||
if !srv.IsInitialized(userId) {
|
||||
srv.MayInitializeUser(userId)
|
||||
}
|
||||
if aliases, ok := userAliases.Load(userId); ok {
|
||||
filteredAliases := make([]*models.Alias, 0, len(aliases.([]*models.Alias)))
|
||||
for _, a := range aliases.([]*models.Alias) {
|
||||
if a.Key == key && a.Type == summaryType {
|
||||
filteredAliases = append(filteredAliases, a)
|
||||
}
|
||||
}
|
||||
return filteredAliases, nil
|
||||
} else {
|
||||
return nil, errors.New(fmt.Sprintf("no user aliases loaded for user %s", userId))
|
||||
check := func(a *models.Alias) bool {
|
||||
return a.Key == key && a.Type == summaryType
|
||||
}
|
||||
return srv.getFiltered(userId, check)
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, value string) (string, error) {
|
||||
@ -94,7 +91,11 @@ func (srv *AliasService) Create(alias *models.Alias) (*models.Alias, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// manually update cache
|
||||
srv.updateCache(alias, false)
|
||||
// reload entire cache (async, though)
|
||||
go srv.MayInitializeUser(alias.UserID)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@ -103,7 +104,14 @@ func (srv *AliasService) Delete(alias *models.Alias) error {
|
||||
return errors.New("no user id specified")
|
||||
}
|
||||
err := srv.repository.Delete(alias.ID)
|
||||
|
||||
// manually update cache
|
||||
if err == nil {
|
||||
srv.updateCache(alias, false)
|
||||
}
|
||||
// reload entire cache (async, though)
|
||||
go srv.MayInitializeUser(alias.UserID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@ -120,9 +128,53 @@ func (srv *AliasService) DeleteMulti(aliases []*models.Alias) error {
|
||||
|
||||
err := srv.repository.DeleteBatch(ids)
|
||||
|
||||
// manually update cache
|
||||
if err == nil {
|
||||
for _, a := range aliases {
|
||||
srv.updateCache(a, true)
|
||||
}
|
||||
}
|
||||
// reload entire cache (async, though)
|
||||
for k := range affectedUsers {
|
||||
go srv.MayInitializeUser(k)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv *AliasService) updateCache(reason *models.Alias, removal bool) {
|
||||
if !removal {
|
||||
if aliases, ok := userAliases.Load(reason.UserID); ok {
|
||||
updatedAliases := aliases.([]*models.Alias)
|
||||
updatedAliases = append(updatedAliases, reason)
|
||||
userAliases.Store(reason.UserID, updatedAliases)
|
||||
}
|
||||
} else {
|
||||
if aliases, ok := userAliases.Load(reason.UserID); ok {
|
||||
updatedAliases := make([]*models.Alias, 0, len(aliases.([]*models.Alias))) // if we only had generics...
|
||||
for _, a := range aliases.([]*models.Alias) {
|
||||
if a.ID != reason.ID {
|
||||
updatedAliases = append(updatedAliases, a)
|
||||
}
|
||||
}
|
||||
userAliases.Store(reason.UserID, updatedAliases)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *AliasService) getFiltered(userId string, check func(alias *models.Alias) bool) ([]*models.Alias, error) {
|
||||
if !srv.IsInitialized(userId) {
|
||||
srv.MayInitializeUser(userId)
|
||||
}
|
||||
if aliases, ok := userAliases.Load(userId); ok {
|
||||
filteredAliases := make([]*models.Alias, 0, len(aliases.([]*models.Alias)))
|
||||
for _, a := range aliases.([]*models.Alias) {
|
||||
if check(a) {
|
||||
filteredAliases = append(filteredAliases, a)
|
||||
}
|
||||
}
|
||||
return filteredAliases, nil
|
||||
} else {
|
||||
return nil, errors.New(fmt.Sprintf("no user aliases loaded for user %s", userId))
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -21,6 +21,7 @@ type IAliasService interface {
|
||||
IsInitialized(string) bool
|
||||
InitializeUser(string) error
|
||||
GetByUser(string) ([]*models.Alias, error)
|
||||
GetByUserAndType(string, uint8) ([]*models.Alias, error)
|
||||
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
|
||||
GetAliasOrDefault(string, uint8, string) (string, error)
|
||||
}
|
||||
@ -106,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.
Binary file not shown.
Before Width: | Height: | Size: 34 KiB |
BIN
static/assets/images/screenshot.webp
Normal file
BIN
static/assets/images/screenshot.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
@ -1,4 +1,3 @@
|
||||
const LEGEND_MAX_ENTRIES = 9
|
||||
// dirty hack to vertically align legends across multiple charts
|
||||
// however, without monospace font, it's still not perfectly aligned
|
||||
// waiting for https://github.com/chartjs/Chart.js/discussions/9890
|
||||
@ -31,8 +30,8 @@ let topNPickers = [...document.getElementsByClassName('top-picker')]
|
||||
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
|
||||
topNPickers.forEach(e => {
|
||||
const idx = parseInt(e.attributes['data-entity'].value)
|
||||
e.max = Math.min(data[idx].length, 10)
|
||||
e.value = e.max
|
||||
e.max = data[idx].length
|
||||
e.value = Math.min(e.max, 9)
|
||||
})
|
||||
|
||||
let charts = []
|
||||
@ -77,7 +76,7 @@ function draw(subselection) {
|
||||
function filterLegendItem(item) {
|
||||
item.text = item.text.length > LEGEND_CHARACTERS ? item.text.slice(0, LEGEND_CHARACTERS - 3).padEnd(LEGEND_CHARACTERS, '.') : item.text
|
||||
item.text = item.text.padEnd(LEGEND_CHARACTERS + 3)
|
||||
return item.index < LEGEND_MAX_ENTRIES
|
||||
return true
|
||||
}
|
||||
|
||||
function shouldUpdate(index) {
|
||||
@ -133,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('project', name)
|
||||
window.location.href = url.href
|
||||
},
|
||||
onHover: (event, elem) => {
|
||||
event.native.target.style.cursor = elem[0] ? 'pointer' : 'default'
|
||||
@ -332,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))
|
||||
@ -350,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"
|
@ -46,7 +46,7 @@ func Add(i, j int) int {
|
||||
}
|
||||
|
||||
func ParseUserAgent(ua string) (string, string, error) {
|
||||
re := regexp.MustCompile(`(?iU)^wakatime\/v?[\d+.]+\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`)
|
||||
re := regexp.MustCompile(`(?iU)^wakatime\/(?:v?[\d+.]+|unset)\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`)
|
||||
groups := re.FindAllStringSubmatch(ua, -1)
|
||||
if len(groups) == 0 || len(groups[0]) != 3 {
|
||||
return "", "", errors.New("failed to parse user agent string")
|
||||
|
@ -43,6 +43,12 @@ func TestCommon_ParseUserAgent(t *testing.T) {
|
||||
"emacs",
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"wakatime/unset (linux-5.11.0-44-generic-x86_64) go1.16.13 emacs-wakatime/1.0.2",
|
||||
"linux",
|
||||
"emacs",
|
||||
nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
@ -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.0-RC1
|
||||
2.2.4
|
@ -5,7 +5,7 @@
|
||||
<meta property="og:title" content="Wakapi - Coding Statistics" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="Wakapi is an open-source tool that helps you keep track of the time you have spent coding on different projects in different programming languages and more. Ideal for statistics freaks and anyone else.">
|
||||
<meta property="og:image" content="assets/images/screenshot.png" />
|
||||
<meta property="og:image" content="assets/images/screenshot.webp" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/images/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="assets/images/favicon-16x16.png">
|
||||
|
@ -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>
|
||||
|
||||
@ -58,7 +58,7 @@
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center my-8">
|
||||
<img alt="App screenshot" src="assets/images/screenshot.png">
|
||||
<img alt="App screenshot" src="assets/images/screenshot.webp">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center mt-10">
|
||||
|
@ -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>
|
||||
|
@ -266,33 +266,33 @@
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="mb-8"></div>
|
||||
|
||||
{{ if .Projects }}
|
||||
<h3 class="inline-block font-semibold text-gray-300">Add Label</h3>
|
||||
<form action="" method="post">
|
||||
<input type="hidden" name="action" value="add_label">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex items-center mt-2 w-full text-gray-500 text-sm space-x-4">
|
||||
<select name="key" id="select-project"
|
||||
class="select-default flex-grow">
|
||||
{{ range $i, $p := .Projects }}
|
||||
<option value="{{ $p }}">{{ $p }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<input class="input-default"
|
||||
type="text" id="label-value"
|
||||
name="value" placeholder="Label" minlength="1" required>
|
||||
<button type="submit" class="btn-primary">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{ else }}
|
||||
<div class="text-gray-300 text-sm mb-4 mt-6">You don't have any projects, yet. Start out by sending a few heartbeats before you can then assign labels.</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{ if .Projects }}
|
||||
<h3 class="inline-block font-semibold text-gray-300">Add Label</h3>
|
||||
<form action="" method="post">
|
||||
<input type="hidden" name="action" value="add_label">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex items-center mt-2 w-full text-gray-500 text-sm space-x-4">
|
||||
<select name="key" id="select-project"
|
||||
class="select-default flex-grow">
|
||||
{{ range $i, $p := .Projects }}
|
||||
<option value="{{ $p }}">{{ $p }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<input class="input-default"
|
||||
type="text" id="label-value"
|
||||
name="value" placeholder="Label" minlength="1" required>
|
||||
<button type="submit" class="btn-primary">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{ else }}
|
||||
<div class="text-gray-300 text-sm mb-4 mt-6">You don't have any projects, yet. Start out by sending a few heartbeats before you can then assign labels.</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -476,7 +476,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-cloak id="integrations" class="tab flex flex-col space-y-4" v-if="isActive('integrations')">
|
||||
<div v-cloak id="integrations" class="tab flex flex-col space-y-4" v-show="isActive('integrations')">
|
||||
<form action="" method="post" class="w-full lg:w-3/4">
|
||||
<input type="hidden" name="action" value="toggle_wakatime">
|
||||
|
||||
@ -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