diff --git a/middlewares/authenticate.go b/middlewares/authenticate.go index 311db11..2e77d29 100644 --- a/middlewares/authenticate.go +++ b/middlewares/authenticate.go @@ -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 { diff --git a/middlewares/authenticate_test.go b/middlewares/authenticate_test.go index 960d269..2d4201f 100644 --- a/middlewares/authenticate_test.go +++ b/middlewares/authenticate_test.go @@ -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) diff --git a/migrations/20210206_drop_badges_column_add_sharing_flags.go b/migrations/20210206_drop_badges_column_add_sharing_flags.go new file mode 100644 index 0000000..aa1008a --- /dev/null +++ b/migrations/20210206_drop_badges_column_add_sharing_flags.go @@ -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) +} diff --git a/mocks/user_service.go b/mocks/user_service.go index a8ccd2d..c6bca7e 100644 --- a/mocks/user_service.go +++ b/mocks/user_service.go @@ -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() +} diff --git a/models/user.go b/models/user.go index 50aff5f..fa37fe1 100644 --- a/models/user.go +++ b/models/user.go @@ -1,13 +1,18 @@ package models type User struct { - ID string `json:"id" gorm:"primary_key"` - ApiKey string `json:"api_key" gorm:"unique"` - 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"` - WakatimeApiKey string `json:"-"` + ID string `json:"id" gorm:"primary_key"` + ApiKey string `json:"api_key" gorm:"unique"` + Password string `json:"-"` + CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"` + LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"` + 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:"-"` } type Login struct { diff --git a/repositories/user.go b/repositories/user.go index a210802..43e080d 100644 --- a/repositories/user.go +++ b/repositories/user.go @@ -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 } diff --git a/routes/compat/shields/v1/badge.go b/routes/compat/shields/v1/badge.go index 6e43ab2..8591d4e 100644 --- a/routes/compat/shields/v1/badge.go +++ b/routes/compat/shields/v1/badge.go @@ -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": diff --git a/routes/compat/wakatime/v1/stats.go b/routes/compat/wakatime/v1/stats.go index 02925cb..3d0335d 100644 --- a/routes/compat/wakatime/v1/stats.go +++ b/routes/compat/wakatime/v1/stats.go @@ -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) + utils.RespondJSON(w, http.StatusOK, stats) } -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 - } - +func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) { overallParams := &models.SummaryParams{ From: start, To: end, diff --git a/routes/settings.go b/routes/settings.go index b3a6f2a..ca7f771 100644 --- a/routes/settings.go +++ b/routes/settings.go @@ -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() diff --git a/services/services.go b/services/services.go index 9780586..38d9941 100644 --- a/services/services.go +++ b/services/services.go @@ -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() } diff --git a/services/user.go b/services/user.go index be96baf..e7f3f9f 100644 --- a/services/user.go +++ b/services/user.go @@ -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() +} diff --git a/views/settings.tpl.html b/views/settings.tpl.html index 269bfb1..8667160 100644 --- a/views/settings.tpl.html +++ b/views/settings.tpl.html @@ -33,237 +33,355 @@
- -
-

- Change Password -

- -
- -
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-

- Reset API Key -

- -
- -
- ⚠️ Caution: Resetting your API key requires you to update your .wakatime.cfg files on all of your computers to make the WakaTime - client send heartbeats again. -
- -
- -
-
-
- -
-

- Aliases -

- -
- You can specify aliases for any type of entity. For instance, you can define a rule, that both myapp-frontend and myapp-backend are combined under a - project called myapp. -
- - {{ if .Aliases }} -

Rules

- {{ range $i, $alias := .Aliases }} -
-
- ▸  All {{ $alias.Type | typeName }}s named - {{ range $j, $value := $alias.Values }} - {{- $value -}} - {{ if lt $j (add (len $alias.Values) -2) }} - {{- ", " | capitalize -}} - {{ else if lt $j (add (len $alias.Values) -1) }} - {{- "or" -}} - {{ end }} - {{ end }} - are mapped to {{ $alias.Type | typeName }} {{ $alias.Key }}. -
-
- - - - +
+ +

+ Change Password +

+
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ +
- {{end}} -
- {{end}} +
-

Add Rule

-
- -
- Map - - named - - to - -
- + {{ end }} + are mapped to {{ $alias.Type | typeName }} {{ $alias.Key }}.
+ + + + + +
- -
+ {{end}} +
+ {{end}} -
-
- Languages & File Extensions -
- -
- You can specify custom mapping from file extensions to programming languages, for instance a .jsx file could be mapped to the React language. -
- - {{ if .LanguageMappings }} -

Rules

- {{ range $i, $mapping := .LanguageMappings }} -
-
- ▸  When filename ends in {{ $mapping.Extension }} - then change the language to {{ $mapping.Language }} -
-
- - - +

Add Rule

+ + +
+ Map + + named + + to + +
+ +
+
- {{end}} -
- {{end}} + -

Add Rule

-
- -
- When filename ends in - - change language to - -
- -
+
+ +

+ Custom Mappings +

+
+
+
+ You can specify custom mapping from file extensions to programming languages, for instance a .jsx file could be mapped to the React language.
- -
-
-
- Badges -
- -
- -
- {{ if .User.BadgesEnabled }} -

Badges are currently enabled. You can disable the feature by deactivating the respective API - endpoint.

- -
- GET /api/compat/shields/v1 + {{ if .LanguageMappings }} +

Rules

+ {{ range $i, $mapping := .LanguageMappings }} +
+
+ ▸  When filename ends in {{ $mapping.Extension }} + then change the language to {{ $mapping.Language }} +
+ + + + +
+ {{end}} +
+ {{end}} + +

Add Rule

+
+ +
+ When filename ends in + + change language to + +
+ +
+
+
+
+
+ +
+ +

+ Public Data +

+
+ +
+

Some features require public access to your data without authentication. This mainly includes Badges and the integration with GitHub Readme Stats, corresponding to these API endpoints:

+
    +
  • /api/compat/shields/v1/{user}
  • +
  • /api/v1/users/{user}/stats/{range}
  • +
+ +
+ +
+ Publicly accessible data range:
(in days; 0 = not public)
+
+ +
+
+
+
+ Share projects: +
+
+ +
+
+
+
+ Share languages: +
+
+ +
+
+
+
+ Share editors: +
+
+ +
+
+
+
+ Share operating systems: +
+
+ +
+
+
+
+ Share machines: +
+
+ +
-

Examples

+
+ +
+
+
+
+ +
+ +

+ Integrations +

+
+
+
+

+ WakaTime +

+ +
+ WakaTime Logo +

You can connect Wakapi with the official WakaTime in a + way + that all heartbeats sent to Wakapi are relayed. This way, you can use both services + at + the same time. To get started, get your API key and paste it here. +

+
+ +
+ + + {{ $placeholderText := "Paste your WakaTime API key here ..." }} + {{ if .User.WakatimeApiKey }} + {{ $placeholderText = "********" }} + {{ end }} + +
+ + +
+ {{ if not .User.WakatimeApiKey }} + + {{ else }} + + {{ end }} +
+
+ + {{ if .User.WakatimeApiKey }} +
+ +
+ {{ end }} +
+ +
+ +
+ +

+ 👉 Please note: + When enabling this feature, the operators of this server will, in theory (!), have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (#94) to be implemented. +

+
+ +
+

+ Badges (Shields.io) +

+ + {{ if gt .User.ShareDataMaxDays 0 }} +

Examples

@@ -295,152 +413,97 @@

You have the ability to create badges from your coding statistics using Shields.io. To do so, you need to grant public, unauthorized - access to the respective endpoint.

-
- GET /api/compat/shields/v1 -
+
+
+ +
+ +

+ ⚠️ Danger Zone +

+
+
+
+

+ Regenerate summaries +

+

+ 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 + database in a static fashion afterwards, unless you pass &recompute=true + with your request. +

+

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

+

+ Note: 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. +

+
+
+
+ + -
- {{ end }} -
- -
- -
-

- Integrations -

- -
-

- WakaTime -

- -
- WakaTime Logo -

You can connect Wakapi with the official WakaTime in a way - that all heartbeats sent to Wakapi are relayed. This way, you can use both services - at - the same time. To get started, get your API key and paste it here.

+
-
- +
+

+ Reset API Key +

- {{ $placeholderText := "Paste your WakaTime API key here ..." }} - {{ if .User.WakatimeApiKey }} - {{ $placeholderText = "********" }} - {{ end }} - -
- - -
- {{ if not .User.WakatimeApiKey }} - - {{ else }} - - {{ end }} + + +
+ ⚠️ Caution: Resetting your API key requires you to update your .wakatime.cfg files on all of your computers to make the + WakaTime + client send heartbeats again.
-
- {{ if .User.WakatimeApiKey }} -
- +
+ +
+ +
+

+ Delete Account +

+

+ Deleting your account will cause all data, including all your heartbeats, to be erased from the + server immediately. This action is irreversible. Be careful! +

+
+
+
+ + -
- {{ end }} - - -
- -
- -

- 👉 Please note: - When enabling this feature, the operators of this server will, in theory (!), have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (#94) to be implemented. -

+ +
-
- - -
-
- ⚠️ Danger Zone -
-
-

- Regenerate summaries -

-

- 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 - database in a static fashion afterwards, unless you pass &recompute=true - with your request. -

-

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

-

- Note: 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. -

-
-
-
- - -
-
- -
-

- Delete Account -

-

- Deleting your account will cause all data, including all your heartbeats, to be erased from the - server immediately. This action is irreversible. Be careful! -

-
-
-
- - -
-
-
+