1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

Compare commits

...

22 Commits
2.0.1 ... 2.2.3

Author SHA1 Message Date
23d00d574b chore: easier setup instructions (resolve #325) 2022-03-02 08:55:58 +01:00
d4b15e7959 fix: href 2022-03-02 08:51:27 +01:00
42808fa38a fix: href to a 404 when service on a subpath
click project detail will redirect to a not exist page, when the service runs with a base path.

For example, the base path is `wakatime`,  and the dashboard uri will be `/wakatime/summary`. When click project detail, page will be redirect to `/wakatime/wakatime/summary?project=demo` but the correct detail page is `/wakatime/summary?project=demo`.

And i think `pushing a history stack` is better than `replace`, so that can back to dashboard by backwards.
2022-03-02 11:35:40 +08:00
52269c780f add missing expose to Dockerfile 2022-03-01 18:19:47 +11:00
302eb33b1b fix: branches chart (resolve #322) 2022-02-22 08:19:51 +01:00
784adec3c1 docs: update readme 2022-02-21 19:49:43 +01:00
d2cdd35fff Merge pull request #320 from muety/gitattributes
ref: add gitattributes file, remove unnecessary unicode characters
2022-02-20 21:27:02 +01:00
33d65fb33a ref: add .gitattributes file for line normalisation 2022-02-18 19:53:04 +11:00
6d762f5fd6 ref: remove unnecessary unicode characters 2022-02-18 19:52:55 +11:00
222024dabb chore: cache avatars in memory 2022-02-17 10:34:33 +01:00
660a09475e chore: include avatar rendering into wakapi itself 2022-02-17 09:53:37 +01:00
5cc932177f chore: update precompressed assets 2022-02-16 08:57:00 +01:00
ac9d96c563 Remove "Create Account" button when AllowSignup is set to false (#319)
Merge pull request #319
2022-02-16 08:56:27 +01:00
3758eecc96 docs: extend postman collection by get heartbeats compat endpoint
(cherry picked from commit e6c6d0eb0d8b4ac6acf68c17002e069b6fe66626)
2022-02-13 11:04:34 +01:00
e21788b8b5 chore: minor fixes 2022-02-13 11:03:10 +01:00
e7f3432113 feat: GET /heartbeat endpoint (resolves #241) 2022-02-13 11:03:10 +01:00
7159df30c2 feat: allow to configure custom api url for relay and import (resolve #105) 2022-01-21 12:35:05 +01:00
fce3a3ea20 docs: update docker run command to ghcr [ci skip] 2022-01-19 15:21:26 +01:00
bd2a8c5a7f fix: make cookie path respect server.base_path (resolve #310) 2022-01-17 08:25:50 +01:00
632a3d4a91 docs: update readme [ci skip] 2022-01-16 06:39:45 +01:00
8a344ce4a2 Merge pull request #308 from muety/docker-version
ci: major and major.minor tags for Docker publish
2022-01-16 06:32:14 +01:00
cbbb592143 ci: major and major.minor tags for Docker publish
Resolves #307
2022-01-16 14:53:55 +11:00
53 changed files with 1743 additions and 794 deletions

6
.gitattributes vendored Normal file
View File

@ -0,0 +1,6 @@
* text=auto
*.db -text
*.png -text
*.br -text
*.ico -text
*.woff2 -text

View File

@ -10,10 +10,6 @@ jobs:
docker-publish:
runs-on: ubuntu-latest
steps:
# https://stackoverflow.com/questions/58177786
- name: Get version
run: echo "GIT_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
@ -33,18 +29,26 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v3
with:
images: |
ghcr.io/${{ github.repository }}
n1try/wakapi
tags: |
latest
alpine
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v2
with:
file: Dockerfile
push: true
tags: |
n1try/wakapi:latest
n1try/wakapi:alpine
n1try/wakapi:${{ env.GIT_TAG }}
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:alpine
ghcr.io/${{ github.repository }}:${{ env.GIT_TAG }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=registry,ref=n1try/wakapi:buildcache-alpine
cache-to: type=registry,ref=n1try/wakapi:buildcache-alpine,mode=max

View File

@ -56,4 +56,6 @@ COPY --from=build-env /app .
VOLUME /data
EXPOSE 3000
ENTRYPOINT /app/entrypoint.sh

View File

@ -34,9 +34,6 @@
Installation instructions can be found below and in the [Wiki](https://github.com/muety/wakapi/wiki).
## 🎉 **Wakapi's Year 2021**
Check out our latest [blog post](https://muetsch.io/wakapi-s-year-2021.html), featuring some interesting statistics about Wakapi in 2021!
## 🚀 Features
* ✅ 100 % free and open-source
* ✅ Built by developers for developers
@ -69,12 +66,15 @@ $ curl -L https://wakapi.dev/get | bash
# Create a persistent volume
$ docker volume create wakapi-data
$ SALT="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)"
# Run the container
$ docker run -d \
-p 3000:3000 \
-e "WAKAPI_PASSWORD_SALT=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)" \
-e "WAKAPI_PASSWORD_SALT=$SALT" \
-v wakapi-data:/data \
--name wakapi n1try/wakapi
--name wakapi \
ghcr.io/muety/wakapi:latest
```
**Note:** By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options.

View File

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

View File

@ -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(

View File

@ -140,7 +140,7 @@ func initSentry(config sentryConfig, debug bool) {
return event
},
}); err != nil {
logbuch.Fatal("failed to initialized sentry %v", err)
logbuch.Fatal("failed to initialized sentry - %v", err)
}
}

File diff suppressed because it is too large Load Diff

2
go.mod
View File

@ -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
View File

@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {

View File

@ -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": {

View File

@ -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
View 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)))
}

View File

@ -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]

View File

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

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

View File

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

View File

@ -56,6 +56,9 @@ func DefaultTemplateFuncs() template.FuncMap {
"avatarUrlTemplate": func() string {
return config.Get().App.AvatarURLTemplate
},
"defaultWakatimeUrl": func() string {
return config.WakatimeApiUrl
},
}
}

View File

@ -230,7 +230,7 @@ func (h *SettingsHandler) actionChangePassword(w http.ResponseWriter, r *http.Re
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded))
return http.StatusOK, "password was updated successfully", ""
}
@ -431,13 +431,17 @@ func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http
user := middlewares.GetPrincipal(r)
apiKey := r.PostFormValue("api_key")
apiUrl := r.PostFormValue("api_url")
if apiUrl == conf.WakatimeApiUrl || apiKey == "" {
apiUrl = ""
}
// Healthcheck, if a new API key is set, i.e. the feature is activated
if (user.WakatimeApiKey == "" && apiKey != "") && !h.validateWakatimeKey(apiKey) {
if (user.WakatimeApiKey == "" && apiKey != "") && !h.validateWakatimeKey(apiKey, apiUrl) {
return http.StatusBadRequest, "", "failed to connect to WakaTime, API key invalid?"
}
if _, err := h.userSrvc.SetWakatimeApiKey(user, apiKey); err != nil {
if _, err := h.userSrvc.SetWakatimeApiCredentials(user, apiKey, apiUrl); err != nil {
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
@ -488,7 +492,7 @@ func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Re
insert := func(batch []*models.Heartbeat) {
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
logbuch.Warn("failed to insert imported heartbeat, already existing? %v", err)
logbuch.Warn("failed to insert imported heartbeat, already existing? - %v", err)
}
}
@ -514,13 +518,13 @@ func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Re
if !user.HasData {
user.HasData = true
if _, err := h.userSrvc.Update(user); err != nil {
conf.Log().Request(r).Error("failed to set 'has_data' flag for user %s %v", user.ID, err)
conf.Log().Request(r).Error("failed to set 'has_data' flag for user %s - %v", user.ID, err)
}
}
if user.Email != "" {
if err := h.mailSrvc.SendImportNotification(user, time.Now().Sub(start), int(countAfter-countBefore)); err != nil {
conf.Log().Request(r).Error("failed to send import notification mail to %s %v", user.ID, err)
conf.Log().Request(r).Error("failed to send import notification mail to %s - %v", user.ID, err)
} else {
logbuch.Info("sent import notification mail to %s", user.ID)
}
@ -542,11 +546,11 @@ func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *ht
go func(user *models.User) {
if err := h.regenerateSummaries(user); err != nil {
conf.Log().Request(r).Error("failed to regenerate summaries for user '%s' %v", user.ID, err)
conf.Log().Request(r).Error("failed to regenerate summaries for user '%s' - %v", user.ID, err)
}
}(middlewares.GetPrincipal(r))
return http.StatusAccepted, "summaries are being regenerated this may take a up to a couple of minutes, please come back later", ""
return http.StatusAccepted, "summaries are being regenerated - this may take a up to a couple of minutes, please come back later", ""
}
func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Request) (int, string, string) {
@ -559,18 +563,22 @@ func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Reques
logbuch.Info("deleting user '%s' shortly", user.ID)
time.Sleep(5 * time.Minute)
if err := h.userSrvc.Delete(user); err != nil {
conf.Log().Request(r).Error("failed to delete user '%s' %v", user.ID, err)
conf.Log().Request(r).Error("failed to delete user '%s' - %v", user.ID, err)
} else {
logbuch.Info("successfully deleted user '%s'", user.ID)
}
}(user)
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey, "/"))
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey))
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "Your account will be deleted in a few minutes. Sorry to you go."), http.StatusFound)
return -1, "", ""
}
func (h *SettingsHandler) validateWakatimeKey(apiKey string) bool {
func (h *SettingsHandler) validateWakatimeKey(apiKey string, baseUrl string) bool {
if baseUrl == "" {
baseUrl = conf.WakatimeApiUrl
}
headers := http.Header{
"Accept": []string{"application/json"},
"Authorization": []string{
@ -580,7 +588,7 @@ func (h *SettingsHandler) validateWakatimeKey(apiKey string) bool {
request, err := http.NewRequest(
http.MethodGet,
conf.WakatimeApiUrl+conf.WakatimeApiUserUrl,
baseUrl+conf.WakatimeApiUserUrl,
nil,
)
if err != nil {

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

View File

@ -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()
))

View File

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

View File

@ -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()
}

View File

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

View File

@ -107,7 +107,7 @@ type IUserService interface {
Update(*models.User) (*models.User, error)
Delete(*models.User) error
ResetApiKey(*models.User) (*models.User, error)
SetWakatimeApiKey(*models.User, string) (*models.User, error)
SetWakatimeApiCredentials(*models.User, string, string) (*models.User, error)
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
GenerateResetToken(*models.User) (*models.User, error)
FlushCache()

View File

@ -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) {

View File

@ -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.

View File

@ -132,9 +132,9 @@ function draw(subselection) {
onClick: (event, data) => {
const idx = data[0].index
const name = wakapiData.projects[idx].key
const query = new URLSearchParams(window.location.search)
query.set('project', name)
window.location.replace(`${window.location.pathname.slice(1)}?${query.toString()}`)
const url = new URL(window.location.href)
url.searchParams.set('query', name)
window.location.href = url.href
},
onHover: (event, elem) => {
event.native.target.style.cursor = elem[0] ? 'pointer' : 'default'
@ -331,13 +331,13 @@ function draw(subselection) {
})
: null
let branchChart = branchesCanvas && !branchesCanvas.classList.contains('hidden') && shouldUpdate(0)
let branchChart = branchesCanvas && !branchesCanvas.classList.contains('hidden') && shouldUpdate(6)
? new Chart(branchesCanvas.getContext('2d'), {
type: "bar",
data: {
datasets: [{
data: wakapiData.branches
.slice(0, Math.min(showTopN[0], wakapiData.branches.length))
.slice(0, Math.min(showTopN[6], wakapiData.branches.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.branches.map((p, i) => {
const c = hexToRgb(getColor(p.key, i % baseColors.length))
@ -349,7 +349,7 @@ function draw(subselection) {
}),
}],
labels: wakapiData.branches
.slice(0, Math.min(showTopN[0], wakapiData.branches.length))
.slice(0, Math.min(showTopN[6], wakapiData.branches.length))
.map(p => p.key)
},
options: {

View File

@ -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{})
}

View File

@ -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": {

View File

@ -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: ""

View File

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

View File

@ -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"

View File

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

View File

@ -1 +1 @@
2.0.1
2.2.3

View File

@ -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> &nbsp;Login</a>
<span class="iconify inline" data-icon="fluent:key-24-filled"></span> &nbsp;Login</a>
</div>
</div>

View File

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

View File

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

View File

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

View File

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