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

Compare commits

..

16 Commits

Author SHA1 Message Date
ed9a7ccd5a fix: tests 2021-10-11 11:38:55 +02:00
9451848ad4 chore: bump version 2021-10-11 11:30:18 +02:00
6c0145b149 Merge branch 'gaocegege-auth' 2021-10-11 11:30:08 +02:00
a94092e31c test: add api tests for query auth 2021-10-11 11:29:38 +02:00
52744dbcd0 Merge branch 'auth' of https://github.com/gaocegege/wakapi into gaocegege-auth 2021-10-11 11:22:01 +02:00
cc11226eab fix: add missing non-zero field checks (fix #259) 2021-10-11 11:07:04 +02:00
8d073aaef2 feat: implement relay endpoint (see #237) 2021-10-11 11:00:50 +02:00
d2f078443e fix: Remove hard coded string 2021-10-11 16:00:48 +08:00
c6e1651d9e fix: Fix the empty key error 2021-10-11 15:58:29 +08:00
630090e38a feat: Support query parameter token 2021-10-11 15:10:30 +08:00
5394349c73 update readme [ci skip] 2021-09-29 21:04:00 +02:00
5cd3bf83a6 Merge pull request #253 from muety/ci-test
feat: add test steps to Linux workflow
2021-09-18 14:25:15 +02:00
13cf911edf fix: make test script fail if tests fail [ci skip] 2021-09-18 14:24:16 +02:00
fe0f41cecb feat: add test steps to Linux workflow 2021-09-17 21:24:12 +10:00
265080453a Merge pull request #252 from kondr1/gitpod
Config for working with repo use gitpod [ci skip]
2021-09-09 12:22:47 +02:00
2f9b8fbcfe .gitpod.yml 2021-09-07 13:29:36 +00:00
18 changed files with 835 additions and 539 deletions

View File

@ -1,4 +1,4 @@
name: Build Wakapi on Linux
name: Linux
on:
push:
@ -10,7 +10,7 @@ on:
jobs:
build-and-release:
name: Build
name: Linux - Build, Test & Release
runs-on: ubuntu-latest
steps:
@ -24,8 +24,15 @@ jobs:
uses: actions/checkout@v2
- name: Get dependencies
run: go get
- name: Unit Tests
run: go test ./... -run ./...
- name: API Tests
run: |
go get
npm -g install newman
./testing/run_api_tests.sh
- name: Build
run: GO111MODULE=on go build -v .

View File

@ -1,4 +1,4 @@
name: Build Wakapi on Windows
name: Windows
on:
push:
@ -10,7 +10,7 @@ on:
jobs:
build-and-release:
name: Build
name: Windows - Build & Release
runs-on: windows-latest
steps:

6
.gitpod.yml Normal file
View File

@ -0,0 +1,6 @@
# List the start up tasks. Learn more https://www.gitpod.io/docs/config-start-tasks/
tasks:
- before: printf "\n[settings]\napi_key = $WAKA_TIME_API_KEY\napi_url = $WAKA_TIME_API_URL\n" > ~/.wakatime.cfg
ports:
- port: 3000
visibility: public

View File

@ -4,14 +4,11 @@
<p align="center">
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
<a href="#-treeware"><img src="https://badges.fw-web.space:/treeware/trees/muety/wakapi?color=%234EC820&label=%F0%9F%8C%B3%20trees"></a>
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi">
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
</p>
<p align="center">
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a>
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
</p>
@ -39,7 +36,7 @@
* [User Survey](#-user-survey)
* [Features](#-features)
* [Roadmap](#-roadmap)
* [How to use](#-how-to-use)
* [How to use](#%EF%B8%8F-how-to-use)
* [Configuration Options](#-configuration-options)
* [API Endpoints](#-api-endpoints)
* [Integrations](#-integrations)
@ -306,10 +303,7 @@ To get a predictable environment, tests are run against a fresh and clean Wakapi
# 1. sqlite (cli)
$ sudo apt install sqlite # Fedora: sudo dnf install sqlite
# 2. screen
$ sudo apt install screen # Fedora: sudo dnf install screen
# 3. newman
# 2. newman
$ npm install -g newman
```
@ -335,9 +329,6 @@ $ node scripts/bundle_icons.js
New icons can be added by editing the `icons` array in [scripts/bundle_icons.js](scripts/bundle_icons.js).
## 🙏 Support
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
## ❔ FAQs
Since Wakapi heavily relies on the concepts provided by WakaTime, [their FAQs](https://wakatime.com/faq) apply to Wakapi for large parts as well. You might find answers there.
@ -411,6 +402,12 @@ It is unclear how to handle the three minutes in between. Did the developer do a
Wakapi adds a "padding" of two minutes before the third heartbeat. This is why total times will slightly vary between Wakapi and WakaTime.
</details>
## 🌳 Treeware
This package is [Treeware](https://treeware.earth). If you use it in production, then we ask that you [**buy the world a tree**](https://plant.treeware.earth/muety/wakapi) to thank us for our work. By contributing to the Treeware forest youll be creating employment for local families and restoring wildlife habitats.
## 👏 Support
Coding in open source is my passion and I would love to do it on a full-time basis and make a living from it one day. So if you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts my motivation to keep improving Wakapi!
## 🙏 Thanks
I highly appreciate the efforts of **[@alanhamlett](https://github.com/alanhamlett)** and the WakaTime team and am thankful for their software being open source.

View File

@ -39,6 +39,7 @@ security:
cookie_max_age: 172800
allow_signup: true
expose_metrics: false
enable_proxy: false # only intended for production instance at wakapi.dev
sentry:
dsn: # leave blank to disable sentry integration

View File

@ -75,6 +75,7 @@ type appConfig struct {
type securityConfig struct {
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
ExposeMetrics bool `yaml:"expose_metrics" default:"false" env:"WAKAPI_EXPOSE_METRICS"`
EnableProxy bool `yaml:"enable_proxy" default:"false" env:"WAKAPI_ENABLE_PROXY"` // only intended for production instance at wakapi.dev
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ package main
import (
"embed"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes/relay"
"io/fs"
"log"
"net"
@ -191,6 +192,9 @@ func main() {
loginHandler := routes.NewLoginHandler(userService, mailService)
imprintHandler := routes.NewImprintHandler(keyValueService)
// Other Handlers
relayHandler := relay.NewRelayHandler()
// Setup Routers
router := mux.NewRouter()
rootRouter := router.PathPrefix("/").Subrouter()
@ -219,6 +223,7 @@ func main() {
imprintHandler.RegisterRoutes(rootRouter)
summaryHandler.RegisterRoutes(rootRouter)
settingsHandler.RegisterRoutes(rootRouter)
relayHandler.RegisterRoutes(rootRouter)
// API route registrations
summaryApiHandler.RegisterRoutes(apiRouter)

View File

@ -1,12 +1,23 @@
package middlewares
import (
"fmt"
"net/http"
"strings"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"strings"
)
const (
// queryApiKey is the query parameter name for api key.
queryApiKey = "api_key"
)
var (
errEmptyKey = fmt.Errorf("the api_key is empty")
)
type AuthenticateMiddleware struct {
@ -45,7 +56,10 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
user, err := m.tryGetUserByCookie(r)
if err != nil {
user, err = m.tryGetUserByApiKey(r)
user, err = m.tryGetUserByApiKeyHeader(r)
}
if err != nil {
user, err = m.tryGetUserByApiKeyQuery(r)
}
if err != nil || user == nil {
@ -77,7 +91,7 @@ func (m *AuthenticateMiddleware) isOptional(requestPath string) bool {
return false
}
func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, error) {
func (m *AuthenticateMiddleware) tryGetUserByApiKeyHeader(r *http.Request) (*models.User, error) {
key, err := utils.ExtractBearerAuth(r)
if err != nil {
return nil, err
@ -92,6 +106,20 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.Us
return user, nil
}
func (m *AuthenticateMiddleware) tryGetUserByApiKeyQuery(r *http.Request) (*models.User, error) {
key := r.URL.Query().Get(queryApiKey)
var user *models.User
userKey := strings.TrimSpace(key)
if userKey == "" {
return nil, errEmptyKey
}
user, err := m.userSrvc.GetUserByKey(userKey)
if err != nil {
return nil, err
}
return user, nil
}
func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
username, err := utils.ExtractCookieAuth(r, m.config)
if err != nil {

View File

@ -3,14 +3,16 @@ package middlewares
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"testing"
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"net/http"
"testing"
)
func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Success(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
testUser := &models.User{ApiKey: testApiKey}
@ -26,13 +28,13 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
sut := NewAuthenticateMiddleware(userServiceMock)
result, err := sut.tryGetUserByApiKey(mockRequest)
result, err := sut.tryGetUserByApiKeyHeader(mockRequest)
assert.Nil(t, err)
assert.Equal(t, testUser, result)
}
func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Invalid(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
@ -47,10 +49,55 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
sut := NewAuthenticateMiddleware(userServiceMock)
result, err := sut.tryGetUserByApiKey(mockRequest)
result, err := sut.tryGetUserByApiKeyHeader(mockRequest)
assert.Error(t, err)
assert.Nil(t, result)
}
func TestAuthenticateMiddleware_tryGetUserByApiKeyQuery_Success(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testUser := &models.User{ApiKey: testApiKey}
params := url.Values{}
params.Add("api_key", testApiKey)
mockRequest := &http.Request{
URL: &url.URL{
RawQuery: params.Encode(),
},
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock)
result, err := sut.tryGetUserByApiKeyQuery(mockRequest)
assert.Nil(t, err)
assert.Equal(t, testUser, result)
}
func TestAuthenticateMiddleware_tryGetUserByApiKeyQuery_Invalid(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
params := url.Values{}
params.Add("token", testApiKey)
mockRequest := &http.Request{
URL: &url.URL{
RawQuery: params.Encode(),
},
}
userServiceMock := new(mocks.UserServiceMock)
sut := NewAuthenticateMiddleware(userServiceMock)
result, actualErr := sut.tryGetUserByApiKeyQuery(mockRequest)
assert.Error(t, actualErr)
assert.Equal(t, errEmptyKey, actualErr)
assert.Nil(t, result)
}
// TODO: somehow test cookie auth function

View File

@ -24,6 +24,9 @@ func (r *AliasRepository) GetAll() ([]*models.Alias, error) {
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
var aliases []*models.Alias
if userId == "" {
return aliases, nil
}
if err := r.db.
Where(&models.Alias{UserID: userId}).
Find(&aliases).Error; err != nil {
@ -34,6 +37,9 @@ func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
func (r *AliasRepository) GetByUserAndKey(userId, key string) ([]*models.Alias, error) {
var aliases []*models.Alias
if userId == "" {
return aliases, nil
}
if err := r.db.
Where(&models.Alias{
UserID: userId,
@ -47,6 +53,9 @@ func (r *AliasRepository) GetByUserAndKey(userId, key string) ([]*models.Alias,
func (r *AliasRepository) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
var aliases []*models.Alias
if userId == "" {
return aliases, nil
}
if err := r.db.
Where(&models.Alias{
UserID: userId,
@ -61,6 +70,9 @@ func (r *AliasRepository) GetByUserAndKeyAndType(userId, key string, summaryType
func (r *AliasRepository) GetByUserAndTypeAndValue(userId string, summaryType uint8, value string) (*models.Alias, error) {
alias := &models.Alias{}
if userId == "" {
return nil, errors.New("invalid input")
}
if err := r.db.
Where(&models.Alias{
UserID: userId,

View File

@ -34,6 +34,9 @@ func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, e
func (r *LanguageMappingRepository) GetByUser(userId string) ([]*models.LanguageMapping, error) {
var mappings []*models.LanguageMapping
if userId == "" {
return mappings, nil
}
if err := r.db.
Where(&models.LanguageMapping{UserID: userId}).
Find(&mappings).Error; err != nil {

View File

@ -33,6 +33,9 @@ func (r *ProjectLabelRepository) GetById(id uint) (*models.ProjectLabel, error)
}
func (r *ProjectLabelRepository) GetByUser(userId string) ([]*models.ProjectLabel, error) {
if userId == "" {
return []*models.ProjectLabel{}, nil
}
var labels []*models.ProjectLabel
if err := r.db.
Where(&models.ProjectLabel{UserID: userId}).

View File

@ -35,6 +35,9 @@ func (r *UserRepository) GetByIds(userIds []string) ([]*models.User, error) {
}
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
if key == "" {
return nil, errors.New("invalid input")
}
u := &models.User{}
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
return u, err

75
routes/relay/relay.go Normal file
View File

@ -0,0 +1,75 @@
package relay
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"net/http"
"net/http/httputil"
"net/url"
"regexp"
)
const targetUrlHeader = "X-Target-URL"
const pathMatcherPattern = `^/api/(heartbeat|heartbeats|summary|users|v1/users|compat/wakatime)`
type RelayHandler struct {
config *conf.Config
}
func NewRelayHandler() *RelayHandler {
return &RelayHandler{
config: conf.Get(),
}
}
type filteringMiddleware struct {
handler http.Handler
pathMatcher *regexp.Regexp
}
func newFilteringMiddleware() func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return &filteringMiddleware{
handler: h,
pathMatcher: regexp.MustCompile(pathMatcherPattern),
}
}
}
func (m *filteringMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
targetUrl, err := url.Parse(r.Header.Get(targetUrlHeader))
if err != nil || !m.pathMatcher.MatchString(targetUrl.Path) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte{})
return
}
m.handler.ServeHTTP(w, r)
}
func (h *RelayHandler) RegisterRoutes(router *mux.Router) {
if !h.config.Security.EnableProxy {
return
}
r := router.PathPrefix("/relay").Subrouter()
r.Use(newFilteringMiddleware())
r.Path("").HandlerFunc(h.Any)
}
func (h *RelayHandler) Any(w http.ResponseWriter, r *http.Request) {
targetUrl, err := url.Parse(r.Header.Get(targetUrlHeader))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte{})
return
}
p := httputil.ReverseProxy{
Director: func(r *http.Request) {
r.URL = targetUrl
r.Host = targetUrl.Host
},
}
p.ServeHTTP(w, r)
}

View File

@ -1,6 +1,6 @@
{
"info": {
"_postman_id": "36595622-81dc-4f4a-826e-345ae63fc83b",
"_postman_id": "da93a75e-e931-4f00-80b8-428f0e7ae824",
"name": "Wakapi API Tests",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
@ -251,6 +251,105 @@
}
},
"response": []
},
{
"name": "Authenticate (header)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true,
"followRedirects": false
},
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{WRITEUSER_TOKEN}}",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE_URL}}/api/summary?interval=today",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"summary"
],
"query": [
{
"key": "interval",
"value": "today"
}
]
}
},
"response": []
},
{
"name": "Authenticate (query param)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true,
"followRedirects": false
},
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE_URL}}/api/summary?interval=today&api_key={{WRITEUSER_API_KEY}}",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"summary"
],
"query": [
{
"key": "interval",
"value": "today"
},
{
"key": "api_key",
"value": "{{WRITEUSER_API_KEY}}"
}
]
}
},
"response": []
}
]
},

View File

@ -1,9 +1,7 @@
#!/bin/bash
if [ ! -f "wakapi" ]; then
echo "Wakapi executable not found. Compiling."
go build
fi
echo "Compiling."
go build
if ! command -v newman &> /dev/null
then
@ -20,7 +18,8 @@ echo "Importing seed data ..."
sqlite3 wakapi_testing.db < data.sql
echo "Running Wakapi testing instance in background ..."
screen -S wakapi_testing -dm bash -c "../wakapi -config config.testing.yml"
../wakapi -config config.testing.yml &
pid=$!
echo "Waiting for Wakapi to come up ..."
until $(curl --output /dev/null --silent --get --fail http://localhost:3000/api/health); do
@ -32,9 +31,12 @@ echo ""
echo "Running test collection ..."
newman run "Wakapi API Tests.postman_collection.json"
exit_code=$?
echo "Shutting down Wakapi ..."
screen -S wakapi_testing -X quit
kill -TERM $pid
echo "Deleting database ..."
rm wakapi_testing.db
rm wakapi_testing.db
exit $exit_code

View File

@ -1 +1 @@
1.29.6
1.30.0