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:
parent
d1dc73b5e6
commit
fca12f522f
@ -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
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
|
||||
|
@ -24,7 +24,7 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
|
||||
userServiceMock := new(mocks.UserServiceMock)
|
||||
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
|
||||
|
||||
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
|
||||
sut := NewAuthenticateMiddleware(userServiceMock)
|
||||
|
||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
||||
|
||||
@ -45,7 +45,7 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
|
||||
|
||||
userServiceMock := new(mocks.UserServiceMock)
|
||||
|
||||
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
|
||||
sut := NewAuthenticateMiddleware(userServiceMock)
|
||||
|
||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
||||
|
||||
|
51
migrations/20210206_drop_badges_column_add_sharing_flags.go
Normal file
51
migrations/20210206_drop_badges_column_add_sharing_flags.go
Normal 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)
|
||||
}
|
@ -58,3 +58,7 @@ func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Lo
|
||||
args := m.Called(user, login)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) FlushCache() {
|
||||
m.Called()
|
||||
}
|
||||
|
@ -6,7 +6,12 @@ type User struct {
|
||||
Password string `json:"-"`
|
||||
CreatedAt 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:"-"`
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -46,13 +47,6 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(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
|
||||
switch filterEntity {
|
||||
case "project":
|
||||
|
@ -1,7 +1,6 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
@ -30,7 +29,7 @@ func NewStatsHandler(userService services.IUserService, summaryService services.
|
||||
func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("/wakatime/v1/users/{user}/stats/{range}").Subrouter()
|
||||
r.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithOptionalFor([]string{"/"}).Handler,
|
||||
)
|
||||
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)
|
||||
|
||||
func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
requestedUser := vars["user"]
|
||||
requestedRange := vars["range"]
|
||||
var vars = mux.Vars(r)
|
||||
var authorizedUser, requestedUser *models.User
|
||||
|
||||
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" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
if authorizedUser != nil && vars["user"] == "current" {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
filters := &models.Filters{}
|
||||
if projectQuery := r.URL.Query().Get("project"); projectQuery != "" {
|
||||
filters.Project = projectQuery
|
||||
stats := v1.NewStatsFrom(summary, &models.Filters{})
|
||||
|
||||
// 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, vm)
|
||||
}
|
||||
|
||||
func (h *StatsHandler) loadUserSummary(user *models.User, rangeKey string) (*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
|
||||
utils.RespondJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) {
|
||||
overallParams := &models.SummaryParams{
|
||||
From: start,
|
||||
To: end,
|
||||
|
@ -2,6 +2,7 @@ package routes
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/mux"
|
||||
@ -127,8 +128,8 @@ func (h *SettingsHandler) dispatchAction(action string) action {
|
||||
return h.actionDeleteLanguageMapping
|
||||
case "add_mapping":
|
||||
return h.actionAddLanguageMapping
|
||||
case "toggle_badges":
|
||||
return h.actionToggleBadges
|
||||
case "update_sharing":
|
||||
return h.actionUpdateSharing
|
||||
case "toggle_wakatime":
|
||||
return h.actionSetWakatimeApiKey
|
||||
case "import_wakatime":
|
||||
@ -202,6 +203,38 @@ func (h *SettingsHandler) actionResetApiKey(w http.ResponseWriter, r *http.Reque
|
||||
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) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
@ -299,19 +332,6 @@ func (h *SettingsHandler) actionAddLanguageMapping(w http.ResponseWriter, r *htt
|
||||
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) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
|
@ -67,7 +67,7 @@ type IUserService interface {
|
||||
Update(*models.User) (*models.User, error)
|
||||
Delete(*models.User) error
|
||||
ResetApiKey(*models.User) (*models.User, error)
|
||||
ToggleBadges(*models.User) (*models.User, error)
|
||||
SetWakatimeApiKey(*models.User, string) (*models.User, error)
|
||||
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
|
||||
FlushCache()
|
||||
}
|
||||
|
@ -83,11 +83,6 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
|
||||
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) {
|
||||
srv.cache.Flush()
|
||||
return srv.repository.UpdateField(user, "wakatime_api_key", apiKey)
|
||||
@ -108,3 +103,7 @@ func (srv *UserService) Delete(user *models.User) error {
|
||||
srv.cache.Flush()
|
||||
return srv.repository.Delete(user)
|
||||
}
|
||||
|
||||
func (srv *UserService) FlushCache() {
|
||||
srv.cache.Flush()
|
||||
}
|
||||
|
@ -33,37 +33,14 @@
|
||||
|
||||
<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="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">
|
||||
Change Password
|
||||
</h2>
|
||||
|
||||
</summary>
|
||||
<div class="w-full">
|
||||
<form class="mt-10" action="" method="post">
|
||||
<input type="hidden" name="action" value="change_password">
|
||||
<div class="mb-8">
|
||||
@ -91,33 +68,15 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="w-full mt-4 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">
|
||||
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">
|
||||
<details class="mb-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">
|
||||
Aliases
|
||||
</h2>
|
||||
|
||||
</summary>
|
||||
<div class="w-full" id="aliases">
|
||||
<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
|
||||
class="inline-block mb-1 text-gray-500 italic">myapp-frontend</span> and <span
|
||||
@ -185,12 +144,16 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="w-full mt-4 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">
|
||||
Languages & File Extensions
|
||||
</div>
|
||||
|
||||
<details class="mb-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="languages">
|
||||
Custom Mappings
|
||||
</h2>
|
||||
</summary>
|
||||
<div class="w-full">
|
||||
<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
|
||||
class="inline-block mb-1 text-gray-500 italic">.jsx</span> file could be mapped to the <span
|
||||
@ -241,80 +204,105 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="w-full mt-4 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="badges">
|
||||
Badges
|
||||
</div>
|
||||
<details class="mb-8 pb-8 border-b border-gray-700" id="public_data">
|
||||
<summary class="cursor-pointer">
|
||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
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>
|
||||
<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">
|
||||
<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>
|
||||
<ul class="list-disc list-inside text-gray-300">
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
<form action="" method="post" class="mt-8">
|
||||
<input type="hidden" name="action" value="update_sharing">
|
||||
<div class="flex items-center w-full text-gray-300 text-sm 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>
|
||||
<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"/>
|
||||
<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"
|
||||
style="width: 70px;" type="number" id="max_days" name="max_days" min="0" max="365" required value="{{ .User.ShareDataMaxDays }}">
|
||||
</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 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 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>
|
||||
|
||||
<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.</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
|
||||
<div class="flex justify-between float-right mt-4">
|
||||
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm" style="width: 100px;">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="w-full mt-4 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">
|
||||
<details class="mb-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="integrations">
|
||||
Integrations
|
||||
</h2>
|
||||
|
||||
<div class="mt-10 text-gray-300 text-sm">
|
||||
</summary>
|
||||
<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">
|
||||
WakaTime
|
||||
</h3>
|
||||
@ -326,13 +314,15 @@
|
||||
<p class="text-sm">You can connect Wakapi with the official <a class="underline"
|
||||
href="https://wakatime.com"
|
||||
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
|
||||
at
|
||||
the same time. To get started, <a class="underline"
|
||||
href="https://wakatime.com/developers#authentication"
|
||||
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>
|
||||
|
||||
<form action="" method="post">
|
||||
@ -384,13 +374,58 @@
|
||||
rel="noopener noreferrer">#94</a>) to be implemented.</span>
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger">
|
||||
<details class="mb-8 pb-8">
|
||||
<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
|
||||
</div>
|
||||
</h2>
|
||||
</summary>
|
||||
<div class="w-full">
|
||||
<div class="mt-10 text-gray-300 text-sm">
|
||||
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
|
||||
Regenerate summaries
|
||||
@ -398,17 +433,21 @@
|
||||
<p>
|
||||
Wakapi improves its efficiency and speed by automatically aggregating individual heartbeats to
|
||||
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
|
||||
class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">&recompute=true</span>
|
||||
with your request.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
If, for some reason, these aggregated summaries are faulty or preconditions have change (e.g. you
|
||||
modified language mappings retrospectively), you may want to re-generate them from raw heartbeats.
|
||||
If, for some reason, these aggregated summaries are faulty or preconditions have change (e.g.
|
||||
you
|
||||
modified language mappings retrospectively), you may want to re-generate them from raw
|
||||
heartbeats.
|
||||
</p>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
@ -422,6 +461,29 @@
|
||||
</form>
|
||||
</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">
|
||||
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
|
||||
Delete Account
|
||||
@ -441,6 +503,7 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user