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

feat: option to publicly share stats data (resolve #36)

This commit is contained in:
Ferdinand Mütsch
2021-02-06 22:32:03 +01:00
parent d1dc73b5e6
commit fca12f522f
12 changed files with 605 additions and 414 deletions

View File

@@ -25,8 +25,9 @@ func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateM
} }
} }
func (m *AuthenticateMiddleware) WithOptionalFor(paths []string) { func (m *AuthenticateMiddleware) WithOptionalFor(paths []string) *AuthenticateMiddleware {
m.optionalForPaths = paths m.optionalForPaths = paths
return m
} }
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler { func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {

View File

@@ -24,7 +24,7 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
userServiceMock := new(mocks.UserServiceMock) userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil) userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock, []string{}) sut := NewAuthenticateMiddleware(userServiceMock)
result, err := sut.tryGetUserByApiKey(mockRequest) result, err := sut.tryGetUserByApiKey(mockRequest)
@@ -45,7 +45,7 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
userServiceMock := new(mocks.UserServiceMock) userServiceMock := new(mocks.UserServiceMock)
sut := NewAuthenticateMiddleware(userServiceMock, []string{}) sut := NewAuthenticateMiddleware(userServiceMock)
result, err := sut.tryGetUserByApiKey(mockRequest) result, err := sut.tryGetUserByApiKey(mockRequest)

View File

@@ -0,0 +1,51 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
f := migrationFunc{
name: "20210206_drop_badges_column_add_sharing_flags",
f: func(db *gorm.DB, cfg *config.Config) error {
migrator := db.Migrator()
if !migrator.HasColumn(&models.User{}, "badges_enabled") {
// empty database, nothing to migrate
return nil
}
if err := db.Exec("UPDATE users SET share_data_max_days = 30 WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_editors = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_languages = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_projects = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_oss = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_machines = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := migrator.DropColumn(&models.User{}, "badges_enabled"); err != nil {
return err
} else {
logbuch.Info("dropped column 'badges_enabled' after substituting it by sharing indicators")
}
return nil
},
}
registerPostMigration(f)
}

View File

@@ -58,3 +58,7 @@ func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Lo
args := m.Called(user, login) args := m.Called(user, login)
return args.Get(0).(*models.User), args.Error(1) return args.Get(0).(*models.User), args.Error(1)
} }
func (m *UserServiceMock) FlushCache() {
m.Called()
}

View File

@@ -6,7 +6,12 @@ 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:"default:false; type:bool"` ShareDataMaxDays uint `json:"-" gorm:"default:0"`
ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
WakatimeApiKey string `json:"-"` WakatimeApiKey string `json:"-"`
} }

View File

@@ -54,7 +54,20 @@ func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, err
} }
func (r *UserRepository) Update(user *models.User) (*models.User, error) { func (r *UserRepository) Update(user *models.User) (*models.User, error) {
result := r.db.Model(user).Updates(user) updateMap := map[string]interface{}{
"api_key": user.ApiKey,
"password": user.Password,
"last_logged_in_at": user.LastLoggedInAt,
"share_data_max_days": user.ShareDataMaxDays,
"share_editors": user.ShareEditors,
"share_languages": user.ShareLanguages,
"share_oss": user.ShareOSs,
"share_projects": user.ShareProjects,
"share_machines": user.ShareMachines,
"wakatime_api_key": user.WakatimeApiKey,
}
result := r.db.Model(user).Updates(updateMap)
if err := result.Error; err != nil { if err := result.Error; err != nil {
return nil, err return nil, err
} }

View File

@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
"time"
) )
const ( const (
@@ -46,13 +47,6 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
return return
} }
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 var filterEntity, filterKey string
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 { if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
filterEntity, filterKey = groups[1], groups[2] filterEntity, filterKey = groups[1], groups[2]
@@ -65,6 +59,21 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
} }
} }
requestedUserId := mux.Vars(r)["user"]
user, err := h.userSrvc.GetUserById(requestedUserId)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
_, rangeFrom, rangeTo := utils.ResolveInterval(interval)
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)))
if rangeFrom.Before(minStart) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("requested time range too broad"))
return
}
var filters *models.Filters var filters *models.Filters
switch filterEntity { switch filterEntity {
case "project": case "project":

View File

@@ -1,7 +1,6 @@
package v1 package v1
import ( import (
"errors"
"github.com/gorilla/mux" "github.com/gorilla/mux"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
@@ -30,7 +29,7 @@ func NewStatsHandler(userService services.IUserService, summaryService services.
func (h *StatsHandler) RegisterRoutes(router *mux.Router) { func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/wakatime/v1/users/{user}/stats/{range}").Subrouter() r := router.PathPrefix("/wakatime/v1/users/{user}/stats/{range}").Subrouter()
r.Use( r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler, middlewares.NewAuthenticateMiddleware(h.userSrvc).WithOptionalFor([]string{"/"}).Handler,
) )
r.Methods(http.MethodGet).HandlerFunc(h.Get) r.Methods(http.MethodGet).HandlerFunc(h.Get)
} }
@@ -38,42 +37,69 @@ func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
// TODO: support filtering (requires https://github.com/muety/wakapi/issues/108) // TODO: support filtering (requires https://github.com/muety/wakapi/issues/108)
func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) var vars = mux.Vars(r)
requestedUser := vars["user"] var authorizedUser, requestedUser *models.User
requestedRange := vars["range"]
user := r.Context().Value(models.UserKey).(*models.User) if u := r.Context().Value(models.UserKey); u != nil {
authorizedUser = u.(*models.User)
}
if requestedUser != user.ID && requestedUser != "current" { if authorizedUser != nil && vars["user"] == "current" {
w.WriteHeader(http.StatusForbidden) vars["user"] = authorizedUser.ID
}
requestedUser, err := h.userSrvc.GetUserById(vars["user"])
if err != nil {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("user not found"))
return return
} }
summary, err, status := h.loadUserSummary(user, requestedRange) err, rangeFrom, rangeTo := utils.ResolveIntervalRaw(vars["range"])
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid range"))
return
}
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays)))
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
(requestedUser.ShareDataMaxDays == 0 || rangeFrom.Before(minStart)) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("requested time range too broad"))
return
}
summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo)
if err != nil { if err != nil {
w.WriteHeader(status) w.WriteHeader(status)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
return return
} }
filters := &models.Filters{} stats := v1.NewStatsFrom(summary, &models.Filters{})
if projectQuery := r.URL.Query().Get("project"); projectQuery != "" {
filters.Project = projectQuery // post filter stats according to user's given sharing permissions
if !requestedUser.ShareEditors {
stats.Data.Editors = nil
}
if !requestedUser.ShareLanguages {
stats.Data.Languages = nil
}
if !requestedUser.ShareProjects {
stats.Data.Projects = nil
}
if !requestedUser.ShareOSs {
stats.Data.OperatingSystems = nil
}
if !requestedUser.ShareMachines {
stats.Data.Machines = nil
} }
vm := v1.NewStatsFrom(summary, filters) utils.RespondJSON(w, http.StatusOK, stats)
utils.RespondJSON(w, http.StatusOK, vm)
} }
func (h *StatsHandler) loadUserSummary(user *models.User, rangeKey string) (*models.Summary, error, int) { func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) {
var start, end time.Time
if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(rangeKey); err == nil {
start, end = parsedFrom, parsedTo
} else {
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
}
overallParams := &models.SummaryParams{ overallParams := &models.SummaryParams{
From: start, From: start,
To: end, To: end,

View File

@@ -2,6 +2,7 @@ package routes
import ( import (
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@@ -127,8 +128,8 @@ func (h *SettingsHandler) dispatchAction(action string) action {
return h.actionDeleteLanguageMapping return h.actionDeleteLanguageMapping
case "add_mapping": case "add_mapping":
return h.actionAddLanguageMapping return h.actionAddLanguageMapping
case "toggle_badges": case "update_sharing":
return h.actionToggleBadges return h.actionUpdateSharing
case "toggle_wakatime": case "toggle_wakatime":
return h.actionSetWakatimeApiKey return h.actionSetWakatimeApiKey
case "import_wakatime": case "import_wakatime":
@@ -202,6 +203,38 @@ func (h *SettingsHandler) actionResetApiKey(w http.ResponseWriter, r *http.Reque
return http.StatusOK, msg, "" return http.StatusOK, msg, ""
} }
func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
var err error
user := r.Context().Value(models.UserKey).(*models.User)
defer h.userSrvc.FlushCache()
user.ShareProjects, err = strconv.ParseBool(r.PostFormValue("share_projects"))
user.ShareLanguages, err = strconv.ParseBool(r.PostFormValue("share_languages"))
user.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors"))
user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss"))
user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines"))
if v, e := strconv.Atoi(r.PostFormValue("max_days")); e == nil && v >= 0 {
user.ShareDataMaxDays = uint(v)
} else {
err = errors.New("")
}
if err != nil {
return http.StatusBadRequest, "", "invalid input"
}
if _, err := h.userSrvc.Update(user); err != nil {
return http.StatusInternalServerError, "", "internal sever error"
}
return http.StatusOK, "settings updated", ""
}
func (h *SettingsHandler) actionDeleteAlias(w http.ResponseWriter, r *http.Request) (int, string, string) { func (h *SettingsHandler) actionDeleteAlias(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
@@ -299,19 +332,6 @@ func (h *SettingsHandler) actionAddLanguageMapping(w http.ResponseWriter, r *htt
return http.StatusOK, "mapping added successfully", "" return http.StatusOK, "mapping added successfully", ""
} }
func (h *SettingsHandler) actionToggleBadges(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
return http.StatusInternalServerError, "", "internal server error"
}
return http.StatusOK, "", ""
}
func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http.Request) (int, string, string) { func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()

View File

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

View File

@@ -83,11 +83,6 @@ 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) {
srv.cache.Flush()
return srv.repository.UpdateField(user, "badges_enabled", !user.BadgesEnabled)
}
func (srv *UserService) SetWakatimeApiKey(user *models.User, apiKey string) (*models.User, error) { func (srv *UserService) SetWakatimeApiKey(user *models.User, apiKey string) (*models.User, error) {
srv.cache.Flush() srv.cache.Flush()
return srv.repository.UpdateField(user, "wakatime_api_key", apiKey) return srv.repository.UpdateField(user, "wakatime_api_key", apiKey)
@@ -108,3 +103,7 @@ func (srv *UserService) Delete(user *models.User) error {
srv.cache.Flush() srv.cache.Flush()
return srv.repository.Delete(user) return srv.repository.Delete(user)
} }
func (srv *UserService) FlushCache() {
srv.cache.Flush()
}

View File

@@ -33,37 +33,14 @@
<main class="mt-4 flex-grow flex justify-center w-full"> <main class="mt-4 flex-grow flex justify-center w-full">
<div class="flex flex-col flex-grow max-w-xl mt-8"> <div class="flex flex-col flex-grow max-w-xl mt-8">
<div class="text-gray-500 text-xs mb-8">
<ul class="flex justify-center flex-wrap space-x-1 inline-bullet-list px-12">
<li class="hover:text-gray-400 mb-1">
<a href="settings#password">Change Password</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#apikey">Reset API Key</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#aliases">Aliases</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#languages">Languages & File Extensions</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#badges">Badges</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#integrations">Integrations</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#danger">Danger Zone</a>
</li>
</ul>
</div>
<div class="w-full my-8 pb-8 border-b border-gray-700"> <details class="my-8 pb-8 border-b border-gray-700">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password"> <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
Change Password Change Password
</h2> </h2>
</summary>
<div class="w-full">
<form class="mt-10" action="" method="post"> <form class="mt-10" action="" method="post">
<input type="hidden" name="action" value="change_password"> <input type="hidden" name="action" value="change_password">
<div class="mb-8"> <div class="mb-8">
@@ -91,33 +68,15 @@
</div> </div>
</form> </form>
</div> </div>
</details>
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700"> <details class="mb-8 pb-8 border-b border-gray-700">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="apikey"> <summary class="cursor-pointer">
Reset API Key
</h2>
<form class="mt-6" action="" method="post">
<input type="hidden" name="action" value="reset_apikey">
<div class="text-gray-300 text-sm mb-4">
<strong>⚠️ Caution:</strong> Resetting your API key requires you to update your <span
class="font-mono">.wakatime.cfg</span> files on all of your computers to make the WakaTime
client send heartbeats again.
</div>
<div class="flex justify-between float-right">
<button type="submit" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">
Reset
</button>
</div>
</form>
</div>
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700" id="aliases">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"> <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Aliases Aliases
</h2> </h2>
</summary>
<div class="w-full" id="aliases">
<div class="text-gray-300 text-sm mb-4 mt-6"> <div class="text-gray-300 text-sm mb-4 mt-6">
You can specify aliases for any type of entity. For instance, you can define a rule, that both <span You can specify aliases for any type of entity. For instance, you can define a rule, that both <span
class="inline-block mb-1 text-gray-500 italic">myapp-frontend</span> and <span class="inline-block mb-1 text-gray-500 italic">myapp-frontend</span> and <span
@@ -185,12 +144,16 @@
</div> </div>
</form> </form>
</div> </div>
</details>
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700"> <details class="mb-8 pb-8 border-b border-gray-700">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="languages"> <summary class="cursor-pointer">
Languages & File Extensions <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
</div> id="languages">
Custom Mappings
</h2>
</summary>
<div class="w-full">
<div class="text-gray-300 text-sm mb-4 mt-6"> <div class="text-gray-300 text-sm mb-4 mt-6">
You can specify custom mapping from file extensions to programming languages, for instance a <span You can specify custom mapping from file extensions to programming languages, for instance a <span
class="inline-block mb-1 text-gray-500 italic">.jsx</span> file could be mapped to the <span class="inline-block mb-1 text-gray-500 italic">.jsx</span> file could be mapped to the <span
@@ -241,80 +204,105 @@
</div> </div>
</form> </form>
</div> </div>
</details>
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700"> <details class="mb-8 pb-8 border-b border-gray-700" id="public_data">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="badges"> <summary class="cursor-pointer">
Badges <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
</div> Public Data
</h2>
</summary>
<form class="mt-6" action="" method="post">
<input type="hidden" name="action" value="toggle_badges">
<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> <div>
<img class="with-url-src" <p class="text-gray-300 text-sm mb-4 mt-6">Some features require public access to your data without authentication. This mainly includes <strong>Badges</strong> and the integration with <strong>GitHub Readme Stats</strong>, corresponding to these API endpoints:</p>
src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today" <ul class="list-disc list-inside text-gray-300">
alt="Shields.io badge"/> <li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/compat/shields/v1/{user}</span></li>
</div> <li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/v1/users/{user}/stats/{range}</span></li>
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" </ul>
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 <form action="" method="post" class="mt-8">
</span> <input type="hidden" name="action" value="update_sharing">
</div> <div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<div class="flex justify-between my-2"> <span class="mr-2">Publicly accessible data range:<br><span class="text-xs text-gray-500">(in days; 0 = not public)</span></span>
<div> <div>
<img class="with-url-src" <input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
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" style="width: 70px;" type="number" id="max_days" name="max_days" min="0" max="365" required value="{{ .User.ShareDataMaxDays }}">
alt="Shields.io badge"/>
</div> </div>
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" </div>
style="max-width: 300px;"> <div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
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 class="flex justify-start">
</span> <span class="mr-2">Share projects: </span>
</div>
<div class="flex justify-end">
<select autocomplete="off" name="share_projects" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected {{ end }}>No</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end }}>Yes</option>
</select>
</div>
</div>
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<div class="flex justify-start">
<span class="mr-2">Share languages: </span>
</div>
<div class="flex justify-end">
<select autocomplete="off" name="share_languages" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected {{ end }}>No</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{ end }}>Yes</option>
</select>
</div>
</div>
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<div class="flex justify-start">
<span class="mr-2">Share editors: </span>
</div>
<div class="flex justify-end">
<select autocomplete="off" name="share_editors" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ end }}>No</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end }}>Yes</option>
</select>
</div>
</div>
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<div class="flex justify-start">
<span class="mr-2">Share operating systems: </span>
</div>
<div class="flex justify-end">
<select autocomplete="off" name="share_oss" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end }}>No</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>Yes</option>
</select>
</div>
</div>
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<div class="flex justify-start">
<span class="mr-2">Share machines: </span>
</div>
<div class="flex justify-end">
<select autocomplete="off" name="share_machines" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected {{ end }}>No</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end }}>Yes</option>
</select>
</div> </div>
</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> <div class="flex justify-between float-right mt-4">
to the URL to filter by project.</p> <button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm" style="width: 100px;">
{{ else }} Save
<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"
rel="noopener noreferrer">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> </button>
</div> </div>
{{ end }}
</div>
</form> </form>
</div> </div>
</details>
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700"> <details class="mb-8 pb-8 border-b border-gray-700">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="integrations"> <summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
id="integrations">
Integrations Integrations
</h2> </h2>
</summary>
<div class="mt-10 text-gray-300 text-sm"> <div class="w-full">
<div class="mt-8 text-gray-300 text-sm">
<h3 class="inline-block font-semibold text-md mb-4 border-b border-green-700"> <h3 class="inline-block font-semibold text-md mb-4 border-b border-green-700">
WakaTime WakaTime
</h3> </h3>
@@ -326,13 +314,15 @@
<p class="text-sm">You can connect Wakapi with the official <a class="underline" <p class="text-sm">You can connect Wakapi with the official <a class="underline"
href="https://wakatime.com" href="https://wakatime.com"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank">WakaTime</a> in a way target="_blank">WakaTime</a> in a
way
that all heartbeats sent to Wakapi are relayed. This way, you can use both services that all heartbeats sent to Wakapi are relayed. This way, you can use both services
at at
the same time. To get started, <a class="underline" the same time. To get started, <a class="underline"
href="https://wakatime.com/developers#authentication" href="https://wakatime.com/developers#authentication"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank">get your API key</a> and paste it here.</p> target="_blank">get your API key</a> and paste it here.
</p>
</div> </div>
<form action="" method="post"> <form action="" method="post">
@@ -384,13 +374,58 @@
rel="noopener noreferrer">#94</a>) to be implemented.</span> rel="noopener noreferrer">#94</a>) to be implemented.</span>
</p> </p>
</div> </div>
<div class="mt-10 text-gray-300 text-sm">
<h3 class="inline-block font-semibold text-md mb-4 border-b border-green-700">
Badges (Shields.io)
</h3>
{{ if gt .User.ShareDataMaxDays 0 }}
<h3 class="font-semibold mb-2">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"
alt="Shields.io badge"/>
</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"
alt="Shields.io badge"/>
</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> </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"
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
access to the respective endpoint. See <a href="settings#public_data" class="underline">Public Data</a> setting.</p>
{{ end }}
</div>
</div>
</details>
<div class="w-full mt-4 mb-8 pb-8"> <details class="mb-8 pb-8">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger"> <summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger">
⚠️ Danger Zone ⚠️ Danger Zone
</div> </h2>
</summary>
<div class="w-full">
<div class="mt-10 text-gray-300 text-sm"> <div class="mt-10 text-gray-300 text-sm">
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block"> <h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
Regenerate summaries Regenerate summaries
@@ -398,17 +433,21 @@
<p> <p>
Wakapi improves its efficiency and speed by automatically aggregating individual heartbeats to Wakapi improves its efficiency and speed by automatically aggregating individual heartbeats to
summaries on a per-day basis. summaries on a per-day basis.
That is, historic summaries, i.e. such from past days, are generated once and only fetched from the That is, historic summaries, i.e. such from past days, are generated once and only fetched from
the
database in a static fashion afterwards, unless you pass <span database in a static fashion afterwards, unless you pass <span
class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">&recompute=true</span> class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">&recompute=true</span>
with your request. with your request.
</p> </p>
<p class="mt-2"> <p class="mt-2">
If, for some reason, these aggregated summaries are faulty or preconditions have change (e.g. you If, for some reason, these aggregated summaries are faulty or preconditions have change (e.g.
modified language mappings retrospectively), you may want to re-generate them from raw heartbeats. you
modified language mappings retrospectively), you may want to re-generate them from raw
heartbeats.
</p> </p>
<p class="mt-2"> <p class="mt-2">
<strong>Note:</strong> Only run this action if you know what you are doing. Data might be lost is <strong>Note:</strong> Only run this action if you know what you are doing. Data might be lost
is
case heartbeats were deleted after the respective summaries had been generated. case heartbeats were deleted after the respective summaries had been generated.
</p> </p>
</div> </div>
@@ -422,6 +461,29 @@
</form> </form>
</div> </div>
<div class="mt-10 text-gray-300 text-sm">
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block" id="apikey">
Reset API Key
</h3>
<form class="mt-2" action="" method="post">
<input type="hidden" name="action" value="reset_apikey">
<div class="text-gray-300 text-sm mb-4">
<strong>⚠️ Caution:</strong> Resetting your API key requires you to update your <span
class="font-mono">.wakatime.cfg</span> files on all of your computers to make the
WakaTime
client send heartbeats again.
</div>
<div class="flex justify-center">
<button type="submit"
class="mt-2 py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">
Reset API Key
</button>
</div>
</form>
</div>
<div class="mt-10 text-gray-300 text-sm"> <div class="mt-10 text-gray-300 text-sm">
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block"> <h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
Delete Account Delete Account
@@ -441,6 +503,7 @@
</form> </form>
</div> </div>
</div> </div>
</details>
</div> </div>
</main> </main>