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

feat: implement badges endpoint and sharing functionality

This commit is contained in:
Ferdinand Mütsch 2020-09-12 16:09:23 +02:00
parent 19a8c61f77
commit d60dddb550
19 changed files with 409 additions and 102 deletions

View File

@ -4,7 +4,7 @@
[![Say thanks](https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg?style=flat-square)](https://saythanks.io/to/n1try) [![Say thanks](https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg?style=flat-square)](https://saythanks.io/to/n1try)
![](https://img.shields.io/github/license/muety/wakapi?style=flat-square) ![](https://img.shields.io/github/license/muety/wakapi?style=flat-square)
[![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi) [![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi)
![Coding Activity](https://img.shields.io/endpoint?url=https://apps.muetsch.io/wakapi/api/compat/shields/v1/n1try/interval:any/project:wakapi&style=flat-square&color=blue)
--- ---
**A minimalist, self-hosted WakaTime-compatible backend for coding statistics** **A minimalist, self-hosted WakaTime-compatible backend for coding statistics**
@ -97,7 +97,10 @@ If you want to export your Wakapi statistics to Prometheus to view them in a Gra
It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible. It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible.
Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/v1"` and set your API key accordingly. Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly.
## Badges
## Best Practices ## Best Practices
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS). It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).

24
main.go
View File

@ -15,7 +15,8 @@ import (
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes" "github.com/muety/wakapi/routes"
v1Routes "github.com/muety/wakapi/routes/compat/v1" shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
) )
@ -95,8 +96,9 @@ func main() {
healthHandler := routes.NewHealthHandler(db) healthHandler := routes.NewHealthHandler(db)
settingsHandler := routes.NewSettingsHandler(userService) settingsHandler := routes.NewSettingsHandler(userService)
publicHandler := routes.NewIndexHandler(userService, keyValueService) publicHandler := routes.NewIndexHandler(userService, keyValueService)
compatV1AllHandler := v1Routes.NewCompatV1AllHandler(summaryService) wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
compatV1SummariesHandler := v1Routes.NewCompatV1SummariesHandler(summaryService) wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// Setup Routers // Setup Routers
router := mux.NewRouter() router := mux.NewRouter()
@ -104,7 +106,9 @@ func main() {
settingsRouter := publicRouter.PathPrefix("/settings").Subrouter() settingsRouter := publicRouter.PathPrefix("/settings").Subrouter()
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter() summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter() apiRouter := router.PathPrefix("/api").Subrouter()
compatV1Router := apiRouter.PathPrefix("/compat/v1").Subrouter() compatRouter := apiRouter.PathPrefix("/compat").Subrouter()
wakatimeV1Router := compatRouter.PathPrefix("/wakatime/v1").Subrouter()
shieldsV1Router := compatRouter.PathPrefix("/shields/v1").Subrouter()
// Middlewares // Middlewares
recoveryMiddleware := handlers.RecoveryHandler() recoveryMiddleware := handlers.RecoveryHandler()
@ -112,7 +116,7 @@ func main() {
corsMiddleware := handlers.CORS() corsMiddleware := handlers.CORS()
authenticateMiddleware := middlewares.NewAuthenticateMiddleware( authenticateMiddleware := middlewares.NewAuthenticateMiddleware(
userService, userService,
[]string{"/api/health"}, []string{"/api/health", "/api/compat/shields/v1"},
).Handler ).Handler
// Router configs // Router configs
@ -136,15 +140,19 @@ func main() {
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex) settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials) settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey) settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
// API Routes // API Routes
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost) apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet) apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet)
apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet) apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet)
// Compat V1 API Routes // Wakatime compat V1 API Routes
compatV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(compatV1AllHandler.ApiGet) wakatimeV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(wakatimeV1AllHandler.ApiGet)
compatV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(compatV1SummariesHandler.ApiGet) wakatimeV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(wakatimeV1SummariesHandler.ApiGet)
// Shields.io compat API Routes
shieldsV1Router.PathPrefix("/{user}").Methods(http.MethodGet).HandlerFunc(shieldV1BadgeHandler.ApiGet)
// Static Routes // Static Routes
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static"))) router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))

View File

@ -0,0 +1,11 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
alter table users
add column `badges_enabled` tinyint(1) default 0 not null;
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
alter table users
drop column `badges_enabled`;

View File

@ -0,0 +1,37 @@
package v1
import (
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"time"
)
// https://shields.io/endpoint
const (
defaultLabel = "coding time"
defaultColor = "#2D3748" // not working
)
type BadgeData struct {
SchemaVersion int `json:"schemaVersion"`
Label string `json:"label"`
Message string `json:"message"`
Color string `json:"color"`
}
func NewBadgeDataFrom(summary *models.Summary, filters *models.Filters) *BadgeData {
var total time.Duration
if hasFilter, filterType, filterKey := filters.First(); hasFilter {
total = summary.TotalTimeByKey(filterType, filterKey)
} else {
total = summary.TotalTime()
}
return &BadgeData{
SchemaVersion: 1,
Label: defaultLabel,
Message: utils.FmtWakatimeDuration(total),
Color: defaultColor,
}
}

View File

@ -1,5 +0,0 @@
package v1
type Filters struct {
Project string
}

View File

@ -8,17 +8,17 @@ import (
// https://wakatime.com/developers#all_time_since_today // https://wakatime.com/developers#all_time_since_today
type WakatimeAllTime struct { type AllTimeViewModel struct {
Data *wakatimeAllTimeData `json:"data"` Data *allTimeData `json:"data"`
} }
type wakatimeAllTimeData struct { type allTimeData struct {
TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created
Text string `json:"text"` // total time logged since account created as human readable string> Text string `json:"text"` // total time logged since account created as human readable string>
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon> IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
} }
func NewAllTimeFrom(summary *models.Summary, filters *Filters) *WakatimeAllTime { func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {
var total time.Duration var total time.Duration
if key := filters.Project; key != "" { if key := filters.Project; key != "" {
total = summary.TotalTimeByKey(models.SummaryProject, key) total = summary.TotalTimeByKey(models.SummaryProject, key)
@ -26,8 +26,8 @@ func NewAllTimeFrom(summary *models.Summary, filters *Filters) *WakatimeAllTime
total = summary.TotalTime() total = summary.TotalTime()
} }
return &WakatimeAllTime{ return &AllTimeViewModel{
Data: &wakatimeAllTimeData{ Data: &allTimeData{
TotalSeconds: float32(total.Seconds()), TotalSeconds: float32(total.Seconds()),
Text: utils.FmtWakatimeDuration(total), Text: utils.FmtWakatimeDuration(total),
IsUpToDate: true, IsUpToDate: true,

View File

@ -12,25 +12,25 @@ import (
// https://wakatime.com/developers#summaries // https://wakatime.com/developers#summaries
// https://pastr.de/v/736450 // https://pastr.de/v/736450
type WakatimeSummaries struct { type SummariesViewModel struct {
Data []*wakatimeSummariesData `json:"data"` Data []*summariesData `json:"data"`
End time.Time `json:"end"` End time.Time `json:"end"`
Start time.Time `json:"start"` Start time.Time `json:"start"`
} }
type wakatimeSummariesData struct { type summariesData struct {
Categories []*wakatimeSummariesEntry `json:"categories"` Categories []*summariesEntry `json:"categories"`
Dependencies []*wakatimeSummariesEntry `json:"dependencies"` Dependencies []*summariesEntry `json:"dependencies"`
Editors []*wakatimeSummariesEntry `json:"editors"` Editors []*summariesEntry `json:"editors"`
Languages []*wakatimeSummariesEntry `json:"languages"` Languages []*summariesEntry `json:"languages"`
Machines []*wakatimeSummariesEntry `json:"machines"` Machines []*summariesEntry `json:"machines"`
OperatingSystems []*wakatimeSummariesEntry `json:"operating_systems"` OperatingSystems []*summariesEntry `json:"operating_systems"`
Projects []*wakatimeSummariesEntry `json:"projects"` Projects []*summariesEntry `json:"projects"`
GrandTotal *wakatimeSummariesGrandTotal `json:"grand_total"` GrandTotal *summariesGrandTotal `json:"grand_total"`
Range *wakatimeSummariesRange `json:"range"` Range *summariesRange `json:"range"`
} }
type wakatimeSummariesEntry struct { type summariesEntry struct {
Digital string `json:"digital"` Digital string `json:"digital"`
Hours int `json:"hours"` Hours int `json:"hours"`
Minutes int `json:"minutes"` Minutes int `json:"minutes"`
@ -41,7 +41,7 @@ type wakatimeSummariesEntry struct {
TotalSeconds float64 `json:"total_seconds"` TotalSeconds float64 `json:"total_seconds"`
} }
type wakatimeSummariesGrandTotal struct { type summariesGrandTotal struct {
Digital string `json:"digital"` Digital string `json:"digital"`
Hours int `json:"hours"` Hours int `json:"hours"`
Minutes int `json:"minutes"` Minutes int `json:"minutes"`
@ -49,7 +49,7 @@ type wakatimeSummariesGrandTotal struct {
TotalSeconds float64 `json:"total_seconds"` TotalSeconds float64 `json:"total_seconds"`
} }
type wakatimeSummariesRange struct { type summariesRange struct {
Date string `json:"date"` Date string `json:"date"`
End time.Time `json:"end"` End time.Time `json:"end"`
Start time.Time `json:"start"` Start time.Time `json:"start"`
@ -57,8 +57,8 @@ type wakatimeSummariesRange struct {
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
} }
func NewSummariesFrom(summaries []*models.Summary, filters *Filters) *WakatimeSummaries { func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
data := make([]*wakatimeSummariesData, len(summaries)) data := make([]*summariesData, len(summaries))
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{} minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
for i, s := range summaries { for i, s := range summaries {
@ -72,34 +72,34 @@ func NewSummariesFrom(summaries []*models.Summary, filters *Filters) *WakatimeSu
} }
} }
return &WakatimeSummaries{ return &SummariesViewModel{
Data: data, Data: data,
End: maxDate, End: maxDate,
Start: minDate, Start: minDate,
} }
} }
func newDataFrom(s *models.Summary) *wakatimeSummariesData { func newDataFrom(s *models.Summary) *summariesData {
zone, _ := time.Now().Zone() zone, _ := time.Now().Zone()
total := s.TotalTime() total := s.TotalTime()
totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes()) totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes())
data := &wakatimeSummariesData{ data := &summariesData{
Categories: make([]*wakatimeSummariesEntry, 0), Categories: make([]*summariesEntry, 0),
Dependencies: make([]*wakatimeSummariesEntry, 0), Dependencies: make([]*summariesEntry, 0),
Editors: make([]*wakatimeSummariesEntry, len(s.Editors)), Editors: make([]*summariesEntry, len(s.Editors)),
Languages: make([]*wakatimeSummariesEntry, len(s.Languages)), Languages: make([]*summariesEntry, len(s.Languages)),
Machines: make([]*wakatimeSummariesEntry, len(s.Machines)), Machines: make([]*summariesEntry, len(s.Machines)),
OperatingSystems: make([]*wakatimeSummariesEntry, len(s.OperatingSystems)), OperatingSystems: make([]*summariesEntry, len(s.OperatingSystems)),
Projects: make([]*wakatimeSummariesEntry, len(s.Projects)), Projects: make([]*summariesEntry, len(s.Projects)),
GrandTotal: &wakatimeSummariesGrandTotal{ GrandTotal: &summariesGrandTotal{
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins), Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
Hours: totalHrs, Hours: totalHrs,
Minutes: totalMins, Minutes: totalMins,
Text: utils.FmtWakatimeDuration(total), Text: utils.FmtWakatimeDuration(total),
TotalSeconds: total.Seconds(), TotalSeconds: total.Seconds(),
}, },
Range: &wakatimeSummariesRange{ Range: &summariesRange{
Date: time.Now().Format(time.RFC3339), Date: time.Now().Format(time.RFC3339),
End: s.ToTime, End: s.ToTime,
Start: s.FromTime, Start: s.FromTime,
@ -111,21 +111,21 @@ func newDataFrom(s *models.Summary) *wakatimeSummariesData {
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(5) wg.Add(5)
go func(data *wakatimeSummariesData) { go func(data *summariesData) {
defer wg.Done() defer wg.Done()
for i, e := range s.Projects { for i, e := range s.Projects {
data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject)) data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject))
} }
}(data) }(data)
go func(data *wakatimeSummariesData) { go func(data *summariesData) {
defer wg.Done() defer wg.Done()
for i, e := range s.Editors { for i, e := range s.Editors {
data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor)) data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor))
} }
}(data) }(data)
go func(data *wakatimeSummariesData) { go func(data *summariesData) {
defer wg.Done() defer wg.Done()
for i, e := range s.Languages { for i, e := range s.Languages {
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage)) data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
@ -133,14 +133,14 @@ func newDataFrom(s *models.Summary) *wakatimeSummariesData {
} }
}(data) }(data)
go func(data *wakatimeSummariesData) { go func(data *summariesData) {
defer wg.Done() defer wg.Done()
for i, e := range s.OperatingSystems { for i, e := range s.OperatingSystems {
data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS)) data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS))
} }
}(data) }(data)
go func(data *wakatimeSummariesData) { go func(data *summariesData) {
defer wg.Done() defer wg.Done()
for i, e := range s.Machines { for i, e := range s.Machines {
data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine)) data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine))
@ -151,7 +151,7 @@ func newDataFrom(s *models.Summary) *wakatimeSummariesData {
return data return data
} }
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *wakatimeSummariesEntry { func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEntry {
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds // this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
// TODO: fix some day, while migrating persisted summary items // TODO: fix some day, while migrating persisted summary items
total := e.Total * time.Second total := e.Total * time.Second
@ -159,7 +159,7 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *wakatimeSum
mins := int((total - time.Duration(hrs)*time.Hour).Minutes()) mins := int((total - time.Duration(hrs)*time.Hour).Minutes())
secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds()) secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds())
return &wakatimeSummariesEntry{ return &summariesEntry{
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs), Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
Hours: hrs, Hours: hrs,
Minutes: mins, Minutes: mins,

View File

@ -24,6 +24,45 @@ type KeyStringValue struct {
Value string `gorm:"type:text"` Value string `gorm:"type:text"`
} }
type Filters struct {
Project string
OS string
Language string
Editor string
Machine string
}
func NewFiltersWith(entity uint8, key string) *Filters {
switch entity {
case SummaryProject:
return &Filters{Project: key}
case SummaryOS:
return &Filters{Project: key}
case SummaryLanguage:
return &Filters{Project: key}
case SummaryEditor:
return &Filters{Project: key}
case SummaryMachine:
return &Filters{Project: key}
}
return &Filters{}
}
func (f *Filters) First() (bool, uint8, string) {
if f.Project != "" {
return true, SummaryProject, f.Project
} else if f.OS != "" {
return true, SummaryOS, f.OS
} else if f.Language != "" {
return true, SummaryLanguage, f.Language
} else if f.Editor != "" {
return true, SummaryEditor, f.Editor
} else if f.Machine != "" {
return true, SummaryMachine, f.Machine
}
return false, 0, ""
}
type CustomTime time.Time type CustomTime time.Time
func (j *CustomTime) UnmarshalJSON(b []byte) error { func (j *CustomTime) UnmarshalJSON(b []byte) error {

View File

@ -25,6 +25,12 @@ const (
IntervalAny string = "any" IntervalAny string = "any"
) )
func Intervals() []string {
return []string{
IntervalToday, IntervalYesterday, IntervalThisWeek, IntervalThisMonth, IntervalThisYear, IntervalPast7Days, IntervalPast30Days, IntervalPast12Months, IntervalAny,
}
}
const UnknownSummaryKey = "unknown" const UnknownSummaryKey = "unknown"
type Summary struct { type Summary struct {

View File

@ -6,6 +6,7 @@ type User struct {
Password string `json:"-"` Password string `json:"-"`
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"` CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"` LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
BadgesEnabled bool `json:"-" gorm:"not null; default:false; type: bool"`
} }
type Login struct { type Login struct {

View File

@ -0,0 +1,96 @@
package v1
import (
"github.com/gorilla/mux"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/shields/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"regexp"
)
const (
intervalPattern = `interval:([a-z0-9_]+)`
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)`
)
type BadgeHandler struct {
userSrvc *services.UserService
summarySrvc *services.SummaryService
config *models.Config
}
func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler {
return &BadgeHandler{
summarySrvc: summaryService,
userSrvc: userService,
config: models.GetConfig(),
}
}
func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
intervalReg := regexp.MustCompile(intervalPattern)
entityFilterReg := regexp.MustCompile(entityFilterPattern)
requestedUserId := mux.Vars(r)["user"]
user, err := h.userSrvc.GetUserById(requestedUserId)
if err != nil || !user.BadgesEnabled {
w.WriteHeader(http.StatusUnauthorized)
return
}
var filterEntity, filterKey string
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
filterEntity, filterKey = groups[1], groups[2]
}
var interval = models.IntervalPast30Days
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
interval = groups[1]
}
filters := &models.Filters{}
switch filterEntity {
case "project":
filters.Project = filterKey
case "os":
filters.OS = filterKey
case "editor":
filters.Editor = filterKey
case "language":
filters.Language = filterKey
case "machine":
filters.Machine = filterKey
}
summary, err, status := h.loadUserSummary(user, interval)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
vm := v1.NewBadgeDataFrom(summary, filters)
utils.RespondJSON(w, http.StatusOK, vm)
}
func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*models.Summary, error, int) {
err, from, to := utils.ResolveInterval(interval)
if err != nil {
return nil, err, http.StatusBadRequest
}
summaryParams := &models.SummaryParams{
From: from,
To: to,
User: user,
}
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute)
if err != nil {
return nil, err, http.StatusInternalServerError
}
return summary, nil, http.StatusOK
}

View File

@ -3,7 +3,7 @@ package v1
import ( import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/v1" v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"net/http" "net/http"
@ -11,19 +11,19 @@ import (
"time" "time"
) )
type CompatV1AllHandler struct { type AllTimeHandler struct {
summarySrvc *services.SummaryService summarySrvc *services.SummaryService
config *models.Config config *models.Config
} }
func NewCompatV1AllHandler(summaryService *services.SummaryService) *CompatV1AllHandler { func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler {
return &CompatV1AllHandler{ return &AllTimeHandler{
summarySrvc: summaryService, summarySrvc: summaryService,
config: models.GetConfig(), config: models.GetConfig(),
} }
} }
func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) { func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
values, _ := url.ParseQuery(r.URL.RawQuery) values, _ := url.ParseQuery(r.URL.RawQuery)
@ -42,11 +42,11 @@ func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
return return
} }
vm := v1.NewAllTimeFrom(summary, &v1.Filters{Project: values.Get("project")}) vm := v1.NewAllTimeFrom(summary, &models.Filters{Project: values.Get("project")})
utils.RespondJSON(w, http.StatusOK, vm) utils.RespondJSON(w, http.StatusOK, vm)
} }
func (h *CompatV1AllHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) { func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
summaryParams := &models.SummaryParams{ summaryParams := &models.SummaryParams{
From: time.Time{}, From: time.Time{},
To: time.Now(), To: time.Now(),

View File

@ -4,7 +4,7 @@ import (
"errors" "errors"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/v1" v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"net/http" "net/http"
@ -12,13 +12,13 @@ import (
"time" "time"
) )
type CompatV1SummariesHandler struct { type SummariesHandler struct {
summarySrvc *services.SummaryService summarySrvc *services.SummaryService
config *models.Config config *models.Config
} }
func NewCompatV1SummariesHandler(summaryService *services.SummaryService) *CompatV1SummariesHandler { func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler {
return &CompatV1SummariesHandler{ return &SummariesHandler{
summarySrvc: summaryService, summarySrvc: summaryService,
config: models.GetConfig(), config: models.GetConfig(),
} }
@ -30,7 +30,7 @@ https://wakatime.com/developers#summaries
timezone can be specified via an offset suffix (e.g. +02:00) in date strings timezone can be specified via an offset suffix (e.g. +02:00) in date strings
*/ */
func (h *CompatV1SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) { func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
requestedUser := vars["user"] requestedUser := vars["user"]
authorizedUser := r.Context().Value(models.UserKey).(*models.User) authorizedUser := r.Context().Value(models.UserKey).(*models.User)
@ -47,11 +47,11 @@ func (h *CompatV1SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request
return return
} }
vm := v1.NewSummariesFrom(summaries, &v1.Filters{}) vm := v1.NewSummariesFrom(summaries, &models.Filters{})
utils.RespondJSON(w, http.StatusOK, vm) utils.RespondJSON(w, http.StatusOK, vm)
} }
func (h *CompatV1SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) { func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
user := r.Context().Value(models.UserKey).(*models.User) user := r.Context().Value(models.UserKey).(*models.User)
params := r.URL.Query() params := r.URL.Query()

View File

@ -29,10 +29,16 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
loadTemplates() loadTemplates()
} }
user := r.Context().Value(models.UserKey).(*models.User)
data := map[string]interface{}{
"User": user,
}
// TODO: when alerts are present, other data will not be passed to the template
if handleAlerts(w, r, "settings.tpl.html") { if handleAlerts(w, r, "settings.tpl.html") {
return return
} }
templates["settings.tpl.html"].Execute(w, nil) templates["settings.tpl.html"].Execute(w, data)
} }
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) { func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
@ -110,3 +116,18 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey)) msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound) http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
} }
func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.BasePath), http.StatusFound)
}

View File

@ -87,6 +87,19 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
return srv.Update(user) return srv.Update(user)
} }
func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) {
result := srv.Db.Model(user).Update("badges_enabled", !user.BadgesEnabled)
if err := result.Error; err != nil {
return nil, err
}
if result.RowsAffected != 1 {
return nil, errors.New("nothing updated")
}
return user, nil
}
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) { func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
user.Password = login.Password user.Password = login.Password
if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil { if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil {

View File

@ -8,3 +8,12 @@ import (
func Capitalize(s string) string { func Capitalize(s string) string {
return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:]) return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:])
} }
func FindString(needle string, haystack []string, defaultVal string) string {
for _, s := range haystack {
if s == needle {
return s
}
}
return defaultVal
}

View File

@ -7,41 +7,46 @@ import (
"time" "time"
) )
func ResolveInterval(interval string) (err error, from, to time.Time) {
to = time.Now()
switch interval {
case models.IntervalToday:
from = StartOfToday()
case models.IntervalYesterday:
from = StartOfToday().Add(-24 * time.Hour)
to = StartOfToday()
case models.IntervalThisWeek:
from = StartOfWeek()
case models.IntervalThisMonth:
from = StartOfMonth()
case models.IntervalThisYear:
from = StartOfYear()
case models.IntervalPast7Days:
from = StartOfToday().AddDate(0, 0, -7)
case models.IntervalPast30Days:
from = StartOfToday().AddDate(0, 0, -30)
case models.IntervalPast12Months:
from = StartOfToday().AddDate(0, -12, 0)
case models.IntervalAny:
from = time.Time{}
default:
err = errors.New("invalid interval")
}
return err, from, to
}
func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) { func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
user := r.Context().Value(models.UserKey).(*models.User) user := r.Context().Value(models.UserKey).(*models.User)
params := r.URL.Query() params := r.URL.Query()
var err error
var from, to time.Time var from, to time.Time
if interval := params.Get("interval"); interval != "" { if interval := params.Get("interval"); interval != "" {
to = time.Now() err, from, to = ResolveInterval(interval)
switch interval {
case models.IntervalToday:
from = StartOfToday()
case models.IntervalYesterday:
from = StartOfToday().Add(-24 * time.Hour)
to = StartOfToday()
case models.IntervalThisWeek:
from = StartOfWeek()
case models.IntervalThisMonth:
from = StartOfMonth()
case models.IntervalThisYear:
from = StartOfYear()
case models.IntervalPast7Days:
from = StartOfToday().AddDate(0, 0, -7)
case models.IntervalPast30Days:
from = StartOfToday().AddDate(0, 0, -30)
case models.IntervalPast12Months:
from = StartOfToday().AddDate(0, -12, 0)
case models.IntervalAny:
from = time.Time{}
default:
return nil, errors.New("invalid interval")
}
} else { } else {
var err error
from, err = ParseDate(params.Get("from")) from, err = ParseDate(params.Get("from"))
if err != nil { if err != nil {
return nil, errors.New("missing 'from' parameter") return nil, errors.New("missing 'from' parameter")

View File

@ -1 +1 @@
1.10.3 1.11.0

View File

@ -64,9 +64,72 @@
</div> </div>
</form> </form>
</div> </div>
<div class="w-full mt-4 mb-8 pb-8">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Badges
</div>
<form class="mt-6" action="settings/badges" method="post">
<div class="text-gray-300 text-sm mb-4">
{{ if .User.BadgesEnabled }}
<p>Badges are currently enabled. You can disable the feature by deactivating the respective API endpoint.</p>
<div class="flex justify-around mt-4">
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
<button type="submit" class="py-1 px-2 rounded bg-orange-700 hover:bg-orange-800 text-white text-xs" title="Disable support for badges to secure endpoint">
Status: public
</button>
</div>
<h3 class="font-semibold mb-2 mt-8">Examples</h3>
<div class="flex flex-col mb-4">
<div class="flex justify-between my-2">
<div>
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today"/>
</div>
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today
</span>
</div>
<div class="flex justify-between my-2">
<div>
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d"/>
</div>
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d
</span>
</div>
</div>
<p>You can also add <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">/project:your-cool-project</span> to the URL to filter by project.</p>
{{ else }}
<p>You have the ability to create badges from your coding statistics using <a href="https://shields.io" target="_blank" class="border-b border-green-800">Shields.io</a>. To do so, you need to grant public, unauthorized access to the respective endpoint.</p>
<div class="flex justify-around mt-4">
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
<button type="submit" class="py-1 px-2 rounded bg-green-700 hover:bg-green-800 text-white text-xs" title="Make endpoint public to enable badges">
Status: protected
</button>
</div>
{{ end }}
</div>
</form>
</div>
</div> </div>
</main> </main>
<script type="text/javascript">
const baseUrl = location.href.substring(0, location.href.indexOf('/settings'))
document.querySelectorAll('.with-url-src').forEach(e => {
e.setAttribute('src', e.getAttribute('src').replace('%s', baseUrl))
e.classList.remove('hidden')
})
document.querySelectorAll('.with-url-inner').forEach(e => {
e.innerHTML = e.innerHTML.replace('%s', baseUrl)
e.classList.remove('hidden')
})
</script>
{{ template "footer.tpl.html" . }} {{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }} {{ template "foot.tpl.html" . }}