├── .circleci └── config.yml ├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── cloudbuild.yaml ├── counter ├── counter.go ├── counter_test.go ├── hit.go ├── hit_test.go ├── option.go ├── option_test.go ├── rank.go └── rank_test.go ├── env ├── env.go └── env_test.go ├── go.mod ├── go.sum ├── handler ├── api │ ├── count.go │ ├── count_test.go │ ├── handler.go │ ├── handler_test.go │ ├── rank_task.go │ ├── rank_task_test.go │ ├── websocket.go │ └── websocket_test.go ├── context.go ├── context_test.go ├── error.go ├── error_test.go ├── handler.go ├── handler_test.go ├── healthcheck.go ├── healthcheck_test.go ├── icon.go ├── icon_test.go ├── index.go ├── index_test.go ├── wasm.go ├── wasm_test.go ├── websocket.go └── websocket_test.go ├── icon.png ├── internal ├── badge.go ├── badge_test.go ├── error.go ├── logger.go ├── logger_test.go ├── sentry.go ├── sentry_test.go ├── time.go ├── time_test.go ├── util.go └── util_test.go ├── main.go ├── middleware.go ├── middleware_test.go ├── option.go ├── option_test.go ├── public ├── background.svg ├── close.svg ├── favicon.ico ├── hits-logo-primary.svg ├── hits-logo-secondary.svg ├── icon.png ├── robots.txt ├── style.css └── wasm_exec.js ├── route.go ├── route_test.go ├── script ├── build.sh ├── cloudbuild.sh ├── dev_server_restarter.js └── encrypt_bucket_path ├── view ├── hits.wasm ├── index.html └── local.html └── wasm ├── README.md ├── go.mod ├── go.sum └── main.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: /go/src/github.com/gjbae1212/hit-counter 5 | 6 | branches: 7 | only: 8 | - master 9 | 10 | docker: 11 | - image: golang:1.14 12 | 13 | environment: 14 | GOPATH: /go 15 | 16 | steps: 17 | - checkout 18 | 19 | - run: go mod download 20 | 21 | - run: 22 | name: RUN UNIT TEST 23 | environment: 24 | GO111MODULE: "on" 25 | command: bash script/build.sh test 26 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .git 3 | .DS_Store 4 | .iml 5 | .gitignore 6 | .dockerignore 7 | README.md 8 | hit-counter 9 | coverage.txt 10 | Dockerfile 11 | production.yaml 12 | script/production.yaml 13 | /script/* 14 | /logs 15 | /vendor 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /vendor 3 | hit-counter 4 | coverage.txt 5 | debug 6 | packrd 7 | script/build_env.sh 8 | script/production.yaml 9 | production.yaml 10 | /logs 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.16.3-alpine AS builder 2 | 3 | WORKDIR /go/src/github.com/gjbae1212/hit-counter 4 | 5 | RUN go env -w GO111MODULE="on" 6 | 7 | # copy go.mod go.sum 8 | COPY ./go.mod ./go.sum ./ 9 | 10 | # download Library 11 | RUN go mod download 12 | 13 | # copy all 14 | COPY ./ ./ 15 | 16 | RUN CGO_ENABLED=0 go build -a -ldflags "-w -s" -o /go/bin/hit-counter 17 | 18 | # Minimize a docker image 19 | FROM gcr.io/distroless/base:latest 20 | 21 | COPY --from=builder /go/bin/hit-counter /go/bin/hit-counter 22 | 23 | COPY --from=builder /go/src/github.com/gjbae1212/hit-counter/public /public 24 | 25 | COPY --from=builder /go/src/github.com/gjbae1212/hit-counter/view /go/src/github.com/gjbae1212/hit-counter/view 26 | 27 | CMD ["/go/bin/hit-counter"] 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HITS 2 | 3 | ![Hits](https://storage.googleapis.com/hit-counter/main.png) 4 | A simple way to see how many people have visited your website or GitHub repo. 5 |

6 | 7 | 8 | license 9 | Go Report Card 10 |

11 | 12 | ## Overview 13 | 14 | [HITS](https://hits.seeyoufarm.com) provides the SVG badge presented **title** and **daily/total** page count. 15 | 16 | If you embed the badge on either website or GitHub or Notion, every page request (page hit) will be counted. 17 | 18 | The badge includes per day (from GMT) and the total (all) page count. 19 | 20 | [HITS](https://hits.seeyoufarm.com) also shows the GitHub projects with the highest visitors. (TOP 10) 21 | 22 | [HITS](https://hits.seeyoufarm.com) shows real-time page hits (using Websocket) of every project or site that is using this service. 23 | 24 | [HITS](https://hits.seeyoufarm.com) was made by gjbae1212@gmail.com using Golang, WebAssembly (Wasm), HTML, currently serving from Google Cloud platform. 25 | 26 | ## How To Use 27 | ### How To Generate The Badge 28 | You generate the badge through [HITS](https://hits.seeyoufarm.com/#badge). 29 | 30 | ![Hits](https://storage.googleapis.com/hit-counter/gen.png) 31 | 32 | ## Features 33 | - Displays daily and total page views on your page. 34 | - Support badge with customize style. 35 | - Support badge free icon (https://simpleicons.org). 36 | - Show a graph of your site about daily count of histories in recently 6 month. 37 | - Show ranks about github projects. 38 | - Show real-time stream. 39 | 40 | ## ETC 41 | [HITS](https://hits.seeyoufarm.com) counts every page hit without storing sensitive information (IP, headers, etc.). 42 | To protect from abuse by massive requests, parts of request information are converted to hashing data in local-cache, and it deletes after the elapsed time. 43 | 44 | Also, HITS does not use GitHub Traffic or Google Analytics data, it simply counts every page hit of your site or repo. 45 | 46 | ## LICENSE 47 | This project is licensed under GPL V3.0. 48 | -------------------------------------------------------------------------------- /cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: 'gcr.io/cloud-builders/gcloud' 3 | args: 4 | - kms 5 | - decrypt 6 | - --ciphertext-file=./script/encrypt_bucket_path 7 | - --plaintext-file=/root/config/bucket_path 8 | - --location=global 9 | - --keyring=allan 10 | - --key=cloud-build 11 | volumes: 12 | - name: 'config' 13 | path: /root/config 14 | 15 | - name: 'gcr.io/cloud-builders/gsutil' 16 | entrypoint: '/bin/bash' 17 | args: ['./script/cloudbuild.sh', 'download_config'] 18 | volumes: 19 | - name: 'config' 20 | path: /root/config 21 | 22 | - name: 'gcr.io/cloud-builders/gcloud' 23 | entrypoint: 'rm' 24 | args: ['./cloudbuild.yaml'] 25 | 26 | - name: 'gcr.io/cloud-builders/go:debian' 27 | entrypoint: 'go' 28 | args: ['mod', 'vendor'] 29 | env: 30 | - 'GO111MODULE=on' 31 | - 'PROJECT_ROOT=hit-counter' 32 | 33 | - name: 'gcr.io/cloud-builders/go:debian' 34 | entrypoint: '/bin/bash' 35 | args: ['./script/cloudbuild.sh', 'test'] 36 | env: 37 | - 'GO111MODULE=on' 38 | - 'PROJECT_ROOT=hit-counter' 39 | secretEnv: ['SENTRY_DSN'] 40 | 41 | - name: 'gcr.io/cloud-builders/gcloud' 42 | entrypoint: '/bin/bash' 43 | args: ['./script/cloudbuild.sh', 'deploy'] 44 | env: 45 | - 'GO111MODULE=on' 46 | - 'PROJECT_ROOT=hit-counter' 47 | timeout: 2000s 48 | volumes: 49 | - name: 'config' 50 | path: /root/config 51 | 52 | timeout: 2500s 53 | 54 | secrets: 55 | - kmsKeyName: projects/allan-212102/locations/global/keyRings/allan/cryptoKeys/cloud-build 56 | secretEnv: 57 | SENTRY_DSN: CiQA7jhEYh6qOP8btVMd+e9mDKJ0iEaAsHeduNgegvblBubK9hESYwDsYGtfYgHO2+1eP8qGJcvIkiuO4kM2IPORYfg1gYeDVpEf3xiJYatvG05xewSMhcYRzgYQGTCrNN7E5zXQpkR3UGgFtzJqDzWBTOw6cmy2kPlyiZ4kdF3pVXz1+FP5jgnOfg== 58 | -------------------------------------------------------------------------------- /counter/counter.go: -------------------------------------------------------------------------------- 1 | package counter 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/go-redis/redis/v8" 8 | ) 9 | 10 | var ( 11 | timeout = 10 * time.Second 12 | ) 13 | 14 | type ( 15 | Counter interface { 16 | IncreaseHitOfDaily(ctx context.Context, id string, t time.Time) (*Score, error) 17 | IncreaseHitOfTotal(ctx context.Context, id string) (*Score, error) 18 | GetHitOfDaily(ctx context.Context, id string, t time.Time) (*Score, error) 19 | GetHitOfTotal(ctx context.Context, id string) (*Score, error) 20 | GetHitOfDailyAndTotal(ctx context.Context, id string, t time.Time) (daily *Score, total *Score, err error) 21 | IncreaseRankOfDaily(ctx context.Context, group, id string, t time.Time) (*Score, error) 22 | IncreaseRankOfTotal(ctx context.Context, group, id string) (*Score, error) 23 | GetRankDailyByLimit(ctx context.Context, group string, limit int, t time.Time) ([]*Score, error) 24 | GetRankTotalByLimit(ctx context.Context, group string, limit int) ([]*Score, error) 25 | GetHitOfDailyByRange(ctx context.Context, id string, timeRange []time.Time) (scores []*Score, err error) 26 | } 27 | 28 | db struct { 29 | redisClient *redis.Client 30 | } 31 | ) 32 | 33 | // Score presents result for response. 34 | type Score struct { 35 | Name string 36 | Value int64 37 | } 38 | 39 | // NewCounter returns an object implemented counter interface. 40 | func NewCounter(opts ...Option) (Counter, error) { 41 | c := &db{} 42 | for _, opt := range opts { 43 | opt.apply(c) 44 | } 45 | 46 | // if redis client doesn't exist, a default redis will be set up to have `localhost:6379`. 47 | if c.redisClient == nil { 48 | c.redisClient = redis.NewClient(&redis.Options{ 49 | Addr: "localhost:6379", 50 | Password: "", 51 | DB: 0, 52 | MaxRetries: 1, 53 | }) 54 | } 55 | return Counter(c), nil 56 | } 57 | -------------------------------------------------------------------------------- /counter/counter_test.go: -------------------------------------------------------------------------------- 1 | package counter 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/alicebob/miniredis" 8 | "github.com/go-redis/redis/v8" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | var ( 13 | mockRedis *miniredis.Miniredis 14 | mockClient *redis.Client 15 | ) 16 | 17 | func TestNewCounter(t *testing.T) { 18 | assert := assert.New(t) 19 | 20 | tests := map[string]struct { 21 | opts []Option 22 | isErr bool 23 | }{ 24 | "success": { 25 | opts: []Option{WithRedisClient(mockClient)}, 26 | isErr: false, 27 | }, 28 | } 29 | 30 | for _, t := range tests { 31 | _, err := NewCounter(t.opts...) 32 | assert.Equal(t.isErr, err != nil) 33 | } 34 | } 35 | 36 | func TestMain(m *testing.M) { 37 | var err error 38 | mockRedis, err = miniredis.Run() 39 | if err != nil { 40 | panic(err) 41 | } 42 | mockClient = redis.NewClient(&redis.Options{Addr: mockRedis.Addr()}) 43 | code := m.Run() 44 | mockRedis.Close() 45 | mockClient.Close() 46 | os.Exit(code) 47 | } 48 | -------------------------------------------------------------------------------- /counter/hit.go: -------------------------------------------------------------------------------- 1 | package counter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/go-redis/redis/v8" 10 | 11 | "github.com/gjbae1212/hit-counter/internal" 12 | ) 13 | 14 | var ( 15 | hitDailyFormat = "hit:daily:%s:%s" 16 | hitTotalFormat = "hit:total:%s" 17 | ) 18 | 19 | // IncreaseHitOfDaily increases daily count. 20 | func (d *db) IncreaseHitOfDaily(ctx context.Context, id string, t time.Time) (*Score, error) { 21 | if id == "" || t.IsZero() { 22 | return nil, fmt.Errorf("[err] IncreaseHitOfDaily %w", internal.ErrorEmptyParams) 23 | } 24 | 25 | pipe := d.redisClient.Pipeline() 26 | daily := internal.TimeToDailyStringFormat(t) 27 | key := fmt.Sprintf(hitDailyFormat, daily, id) 28 | 29 | // expire 2 month. 30 | incrResult := pipe.Incr(ctx, key) 31 | pipe.Expire(ctx, key, time.Hour*24*60) 32 | 33 | if _, err := pipe.Exec(ctx); err != nil { 34 | return nil, fmt.Errorf("[err] IncreaseHitOfDaily %w", err) 35 | } 36 | 37 | incr, _ := incrResult.Result() 38 | return &Score{Name: key, Value: incr}, nil 39 | } 40 | 41 | // IncreaseHitOfTotal increases accumulate count. 42 | func (d *db) IncreaseHitOfTotal(ctx context.Context, id string) (*Score, error) { 43 | if id == "" { 44 | return nil, fmt.Errorf("[err] IncreaseHitOfTotal %w", internal.ErrorEmptyParams) 45 | } 46 | 47 | key := fmt.Sprintf(hitTotalFormat, id) 48 | v, err := d.redisClient.Incr(ctx, key).Result() 49 | if err != nil { 50 | return nil, fmt.Errorf("[err] IncreaseHitOfTotal %w", err) 51 | } 52 | return &Score{Name: key, Value: v}, nil 53 | } 54 | 55 | // GetHitOfDaily returns daily score. 56 | func (d *db) GetHitOfDaily(ctx context.Context, id string, t time.Time) (*Score, error) { 57 | if id == "" || t.IsZero() { 58 | return nil, fmt.Errorf("[err] GetHitOfDaily empty param") 59 | } 60 | 61 | daily := internal.TimeToDailyStringFormat(t) 62 | key := fmt.Sprintf(hitDailyFormat, daily, id) 63 | 64 | v, err := d.redisClient.Get(ctx, key).Result() 65 | if err == redis.Nil { 66 | return nil, nil 67 | } else if err != nil { 68 | return nil, fmt.Errorf("[err] GetHitOfDaily %w", err) 69 | } 70 | 71 | rt, err := strconv.ParseInt(v, 10, 64) 72 | if err != nil { 73 | return nil, fmt.Errorf("[err] GetHitOfDaily %w", err) 74 | } 75 | 76 | return &Score{Name: key, Value: rt}, nil 77 | } 78 | 79 | // GetHitOfTotal returns accumulate score. 80 | func (d *db) GetHitOfTotal(ctx context.Context, id string) (*Score, error) { 81 | if id == "" { 82 | return nil, fmt.Errorf("[err] GetHitOfTotal empty param") 83 | } 84 | 85 | key := fmt.Sprintf(hitTotalFormat, id) 86 | v, err := d.redisClient.Get(ctx, key).Result() 87 | if err == redis.Nil { 88 | return nil, nil 89 | } else if err != nil { 90 | return nil, fmt.Errorf("[err] GetHitOfTotal %w", err) 91 | } 92 | 93 | rt, err := strconv.ParseInt(v, 10, 64) 94 | if err != nil { 95 | return nil, fmt.Errorf("[err] GetHitOfTotal %w", err) 96 | } 97 | 98 | return &Score{Name: key, Value: rt}, nil 99 | } 100 | 101 | // GetHitOfDailyAndTotal returns daily score and accumulate score. 102 | func (d *db) GetHitOfDailyAndTotal(ctx context.Context, id string, t time.Time) (daily *Score, total *Score, retErr error) { 103 | if id == "" || t.IsZero() { 104 | retErr = fmt.Errorf("[err] GetHitOfDailyAndTotal %w", internal.ErrorEmptyParams) 105 | return 106 | } 107 | 108 | key1 := fmt.Sprintf(hitDailyFormat, internal.TimeToDailyStringFormat(t), id) 109 | key2 := fmt.Sprintf(hitTotalFormat, id) 110 | 111 | v, err := d.redisClient.MGet(ctx, key1, key2).Result() 112 | if err == redis.Nil { 113 | return 114 | } else if err != nil { 115 | retErr = fmt.Errorf("[err] GetHitOfDailyAndTotal %w", err) 116 | return 117 | } 118 | 119 | if v[0] != nil { 120 | dailyValue, err := strconv.ParseInt(v[0].(string), 10, 64) 121 | if err != nil { 122 | retErr = fmt.Errorf("[err] GetHitOfDailyAndTotal %w", err) 123 | return 124 | } 125 | daily = &Score{Name: key1, Value: dailyValue} 126 | } 127 | 128 | if v[1] != nil { 129 | totalValue, err := strconv.ParseInt(v[1].(string), 10, 64) 130 | if err != nil { 131 | retErr = fmt.Errorf("[err] GetHitOfDailyAndTotal %w", err) 132 | return 133 | } 134 | total = &Score{Name: key2, Value: totalValue} 135 | } 136 | return 137 | } 138 | 139 | // GetHitOfDailyByRange returns daily scores with range. 140 | func (d *db) GetHitOfDailyByRange(ctx context.Context, id string, timeRange []time.Time) (scores []*Score, retErr error) { 141 | if id == "" || len(timeRange) == 0 { 142 | retErr = fmt.Errorf("[err] GetHitOfDailyByRange %w", internal.ErrorEmptyParams) 143 | return 144 | } 145 | 146 | var keys []string 147 | for _, t := range timeRange { 148 | keys = append(keys, fmt.Sprintf(hitDailyFormat, internal.TimeToDailyStringFormat(t), id)) 149 | } 150 | 151 | v, err := d.redisClient.MGet(ctx, keys...).Result() 152 | if err == redis.Nil { 153 | return 154 | } else if err != nil { 155 | retErr = fmt.Errorf("[err] GetHitOfDailyByRange %w", err) 156 | } 157 | 158 | for i, key := range keys { 159 | if v[i] != nil { 160 | dailyValue, err := strconv.ParseInt(v[i].(string), 10, 64) 161 | if err != nil { 162 | err = fmt.Errorf("[err] GetHitOfDailyByRange %w", err) 163 | return 164 | } 165 | scores = append(scores, &Score{Name: key, Value: dailyValue}) 166 | } else { 167 | scores = append(scores, nil) 168 | } 169 | } 170 | 171 | return 172 | } 173 | -------------------------------------------------------------------------------- /counter/hit_test.go: -------------------------------------------------------------------------------- 1 | package counter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "testing" 8 | "time" 9 | 10 | "github.com/davecgh/go-spew/spew" 11 | "github.com/gjbae1212/hit-counter/internal" 12 | "github.com/google/go-cmp/cmp" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestDb_IncreaseHitOfDaily(t *testing.T) { 17 | assert := assert.New(t) 18 | defer mockRedis.FlushAll() 19 | 20 | ctx := context.Background() 21 | counter, err := NewCounter(WithRedisClient(mockClient)) 22 | assert.NoError(err) 23 | 24 | _, err = counter.IncreaseHitOfDaily(ctx, "", time.Time{}) 25 | assert.Error(err) 26 | 27 | now := time.Now() 28 | for i := 0; i < 2; i++ { 29 | count, err := counter.IncreaseHitOfDaily(ctx, "test", now) 30 | assert.NoError(err) 31 | assert.Equal(i+1, int(count.Value)) 32 | } 33 | 34 | daily := internal.TimeToDailyStringFormat(now) 35 | key := fmt.Sprintf(hitDailyFormat, daily, "test") 36 | log.Println(key) 37 | 38 | v, err := counter.(*db).redisClient.Get(ctx, key).Result() 39 | assert.NoError(err) 40 | assert.Equal("2", v) 41 | } 42 | 43 | func TestDb_IncreaseHitOfTotal(t *testing.T) { 44 | assert := assert.New(t) 45 | defer mockRedis.FlushAll() 46 | 47 | ctx := context.Background() 48 | counter, err := NewCounter(WithRedisClient(mockClient)) 49 | assert.NoError(err) 50 | 51 | _, err = counter.IncreaseHitOfTotal(ctx, "") 52 | assert.Error(err) 53 | 54 | for i := 0; i < 2; i++ { 55 | count, err := counter.IncreaseHitOfTotal(ctx, "test") 56 | assert.NoError(err) 57 | assert.Equal(i+1, int(count.Value)) 58 | } 59 | 60 | key := fmt.Sprintf(hitTotalFormat, "test") 61 | log.Println(key) 62 | v, err := counter.(*db).redisClient.Get(ctx, key).Result() 63 | assert.NoError(err) 64 | assert.Equal("2", v) 65 | } 66 | 67 | func TestDb_GetHitOfDaily(t *testing.T) { 68 | assert := assert.New(t) 69 | defer mockRedis.FlushAll() 70 | 71 | ctx := context.Background() 72 | counter, err := NewCounter(WithRedisClient(mockClient)) 73 | assert.NoError(err) 74 | 75 | now := time.Now() 76 | _, err = counter.GetHitOfDaily(ctx, "", time.Time{}) 77 | assert.Error(err) 78 | 79 | v, err := counter.GetHitOfDaily(ctx, "empty", now) 80 | assert.NoError(err) 81 | assert.Nil(v) 82 | 83 | for i := 0; i < 1000; i++ { 84 | count, err := counter.IncreaseHitOfDaily(ctx, "test", now) 85 | assert.NoError(err) 86 | assert.Equal(i+1, int(count.Value)) 87 | } 88 | 89 | v, err = counter.GetHitOfDaily(ctx, "test", now) 90 | assert.NoError(err) 91 | assert.Equal(1000, int(v.Value)) 92 | assert.Equal(fmt.Sprintf(hitDailyFormat, internal.TimeToDailyStringFormat(now), "test"), v.Name) 93 | } 94 | 95 | func TestDb_GetHitOfTotal(t *testing.T) { 96 | assert := assert.New(t) 97 | defer mockRedis.FlushAll() 98 | 99 | ctx := context.Background() 100 | counter, err := NewCounter(WithRedisClient(mockClient)) 101 | assert.NoError(err) 102 | 103 | _, err = counter.GetHitOfTotal(ctx, "") 104 | assert.Error(err) 105 | 106 | v, err := counter.GetHitOfTotal(ctx, "empty") 107 | assert.NoError(err) 108 | assert.Nil(v) 109 | 110 | for i := 0; i < 1000; i++ { 111 | count, err := counter.IncreaseHitOfTotal(ctx, "test") 112 | assert.NoError(err) 113 | assert.Equal(i+1, int(count.Value)) 114 | } 115 | 116 | v, err = counter.GetHitOfTotal(ctx, "test") 117 | assert.NoError(err) 118 | assert.Equal(1000, int(v.Value)) 119 | assert.Equal(fmt.Sprintf(hitTotalFormat, "test"), v.Name) 120 | } 121 | 122 | func TestDb_GetHitOfDailyAndTotal(t *testing.T) { 123 | assert := assert.New(t) 124 | defer mockRedis.FlushAll() 125 | 126 | ctx := context.Background() 127 | counter, err := NewCounter(WithRedisClient(mockClient)) 128 | assert.NoError(err) 129 | 130 | id := "allan" 131 | now := time.Now() 132 | tests := map[string]struct { 133 | inputs []interface{} 134 | wants []*Score 135 | err bool 136 | }{ 137 | "error1": {inputs: []interface{}{"", time.Time{}}, wants: []*Score{nil, nil}, err: true}, 138 | "error2": {inputs: []interface{}{id, time.Time{}}, wants: []*Score{nil, nil}, err: true}, 139 | "empty": {inputs: []interface{}{id, now}, wants: []*Score{nil, nil}, err: false}, 140 | "onlytotal": {inputs: []interface{}{"onlytotal", now}, wants: []*Score{nil, &Score{Name: fmt.Sprintf(hitTotalFormat, "onlytotal"), Value: 10}}, err: false}, 141 | "onlydaily": {inputs: []interface{}{"onlydaily", now}, wants: []*Score{&Score{Name: fmt.Sprintf(hitDailyFormat, internal.TimeToDailyStringFormat(now), "onlydaily"), Value: 10}, nil}, err: false}, 142 | "both": {inputs: []interface{}{"both", now}, wants: []*Score{&Score{Name: fmt.Sprintf(hitDailyFormat, internal.TimeToDailyStringFormat(now), "both"), Value: 10}, &Score{Name: fmt.Sprintf(hitTotalFormat, "both"), Value: 10}}, err: false}, 143 | } 144 | 145 | test := tests["error1"] 146 | _, _, err = counter.GetHitOfDailyAndTotal(ctx, test.inputs[0].(string), test.inputs[1].(time.Time)) 147 | assert.Error(err) 148 | test = tests["error2"] 149 | _, _, err = counter.GetHitOfDailyAndTotal(ctx, test.inputs[0].(string), test.inputs[1].(time.Time)) 150 | assert.Error(err) 151 | 152 | test = tests["empty"] 153 | daily, total, err := counter.GetHitOfDailyAndTotal(ctx, test.inputs[0].(string), test.inputs[1].(time.Time)) 154 | assert.NoError(err) 155 | assert.Equal(test.wants[0], daily) 156 | assert.Equal(test.wants[1], total) 157 | 158 | for i := 0; i < 10; i++ { 159 | _, err := counter.IncreaseHitOfTotal(ctx, "onlytotal") 160 | assert.NoError(err) 161 | } 162 | test = tests["onlytotal"] 163 | daily, total, err = counter.GetHitOfDailyAndTotal(ctx, test.inputs[0].(string), test.inputs[1].(time.Time)) 164 | assert.NoError(err) 165 | assert.True(cmp.Equal(test.wants[0], daily)) 166 | assert.True(cmp.Equal(test.wants[1], total)) 167 | 168 | for i := 0; i < 10; i++ { 169 | _, err := counter.IncreaseHitOfDaily(ctx, "onlydaily", now) 170 | assert.NoError(err) 171 | } 172 | 173 | test = tests["onlydaily"] 174 | daily, total, err = counter.GetHitOfDailyAndTotal(ctx, test.inputs[0].(string), test.inputs[1].(time.Time)) 175 | assert.NoError(err) 176 | assert.True(cmp.Equal(test.wants[0], daily)) 177 | assert.True(cmp.Equal(test.wants[1], total)) 178 | 179 | for i := 0; i < 10; i++ { 180 | _, err := counter.IncreaseHitOfDaily(ctx, "both", now) 181 | assert.NoError(err) 182 | _, err = counter.IncreaseHitOfTotal(ctx, "both") 183 | assert.NoError(err) 184 | } 185 | 186 | test = tests["both"] 187 | daily, total, err = counter.GetHitOfDailyAndTotal(ctx, test.inputs[0].(string), test.inputs[1].(time.Time)) 188 | assert.NoError(err) 189 | assert.True(cmp.Equal(test.wants[0], daily)) 190 | assert.True(cmp.Equal(test.wants[1], total)) 191 | } 192 | 193 | func TestDb_GetHitOfDailyByRange(t *testing.T) { 194 | assert := assert.New(t) 195 | defer mockRedis.FlushAll() 196 | 197 | ctx := context.Background() 198 | counter, err := NewCounter(WithRedisClient(mockClient)) 199 | assert.NoError(err) 200 | 201 | _, err = counter.GetHitOfDailyByRange(ctx, "", []time.Time{}) 202 | assert.Error(err) 203 | 204 | scores, err := counter.GetHitOfDailyByRange(ctx, "test.com", []time.Time{time.Now(), time.Now().Add(-1 * 24 * time.Hour)}) 205 | assert.NoError(err) 206 | assert.Len(scores, 2) 207 | for _, s := range scores { 208 | assert.Nil(s) 209 | } 210 | 211 | var timeRange []time.Time 212 | prev := time.Now().Add(-30 * 24 * time.Hour) 213 | now := time.Now() 214 | for now.Unix() > prev.Unix() { 215 | timeRange = append(timeRange, prev) 216 | _, err := counter.IncreaseHitOfDaily(ctx, "test.com", prev) 217 | assert.NoError(err) 218 | prev = prev.Add(24 * time.Hour) 219 | } 220 | 221 | scores, err = counter.GetHitOfDailyByRange(ctx, "test.com", timeRange) 222 | assert.NoError(err) 223 | assert.Len(scores, 30) 224 | spew.Dump(scores) 225 | for _, score := range scores { 226 | assert.Equal(1, int(score.Value)) 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /counter/option.go: -------------------------------------------------------------------------------- 1 | package counter 2 | 3 | import ( 4 | "github.com/go-redis/redis/v8" 5 | ) 6 | 7 | type ( 8 | Option interface{ apply(d *db) } 9 | OptionFunc func(d *db) 10 | ) 11 | 12 | func (f OptionFunc) apply(d *db) { f(d) } 13 | 14 | // WithRedisClient returns a function which sets redis client. 15 | func WithRedisClient(client *redis.Client) OptionFunc { 16 | return func(d *db) { 17 | d.redisClient = client 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /counter/option_test.go: -------------------------------------------------------------------------------- 1 | package counter 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-redis/redis/v8" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestWithRedisClient(t *testing.T) { 12 | assert := assert.New(t) 13 | 14 | tests := map[string]struct { 15 | client *redis.Client 16 | }{ 17 | "success": {client: mockClient}, 18 | } 19 | 20 | for _, t := range tests { 21 | opt := WithRedisClient(t.client) 22 | c := &db{} 23 | opt(c) 24 | assert.Equal(c.redisClient, t.client) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /counter/rank.go: -------------------------------------------------------------------------------- 1 | package counter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/go-redis/redis/v8" 9 | 10 | "github.com/gjbae1212/hit-counter/internal" 11 | ) 12 | 13 | var ( 14 | rankDailyFormat = "rank:daily:%s:%s" 15 | rankTotalFormat = "rank:total:%s" 16 | ) 17 | 18 | // IncreaseRankOfDaily increases daily rank score. 19 | func (d *db) IncreaseRankOfDaily(ctx context.Context, group, id string, t time.Time) (*Score, error) { 20 | if group == "" || id == "" || t.IsZero() { 21 | return nil, fmt.Errorf("[err] IncreaseRankOfDaily %w", internal.ErrorEmptyParams) 22 | } 23 | 24 | pipe := d.redisClient.Pipeline() 25 | 26 | daily := internal.TimeToDailyStringFormat(t) 27 | key := fmt.Sprintf(rankDailyFormat, daily, group) 28 | 29 | // expire 2 month. 30 | incrResult := pipe.ZIncrBy(ctx, key, 1, id) 31 | pipe.Expire(ctx, key, time.Hour*24*60) 32 | 33 | if _, err := pipe.Exec(ctx); err != nil { 34 | return nil, fmt.Errorf("[err] IncreaseRankOfDaily %w", err) 35 | } 36 | 37 | incr, _ := incrResult.Result() 38 | return &Score{Name: id, Value: int64(incr)}, nil 39 | } 40 | 41 | // IncreaseRankOfTotal increases accumulate rank score. 42 | func (d *db) IncreaseRankOfTotal(ctx context.Context, group, id string) (*Score, error) { 43 | if group == "" || id == "" { 44 | return nil, fmt.Errorf("[err] IncreaseRankOfTotal %w", internal.ErrorEmptyParams) 45 | } 46 | 47 | key := fmt.Sprintf(rankTotalFormat, group) 48 | v, err := d.redisClient.ZIncrBy(ctx, key, 1, id).Result() 49 | if err != nil { 50 | return nil, fmt.Errorf("[err] IncreaseRankOfTotal %w", err) 51 | } 52 | 53 | return &Score{Name: id, Value: int64(v)}, nil 54 | } 55 | 56 | // GetRankDailyByLimit returns daily rank scores by limit. 57 | func (d *db) GetRankDailyByLimit(ctx context.Context, group string, limit int, t time.Time) ([]*Score, error) { 58 | if group == "" || limit <= 0 { 59 | return nil, fmt.Errorf("[err] GetRankDailyByLimit %w", internal.ErrorEmptyParams) 60 | } 61 | var ret []*Score 62 | 63 | daily := internal.TimeToDailyStringFormat(t) 64 | key := fmt.Sprintf(rankDailyFormat, daily, group) 65 | 66 | scores, err := d.redisClient.ZRevRangeWithScores(ctx, key, 0, int64(limit-1)).Result() 67 | if err == redis.Nil { 68 | return ret, nil 69 | } else if err != nil { 70 | return nil, fmt.Errorf("[err] GetRankDailyByLimit %w", err) 71 | } 72 | 73 | for _, score := range scores { 74 | name := score.Member.(string) 75 | value := score.Score 76 | ret = append(ret, &Score{Name: name, Value: int64(value)}) 77 | } 78 | 79 | return ret, nil 80 | } 81 | 82 | // GetRankTotalByLimit returns total ranks. 83 | func (d *db) GetRankTotalByLimit(ctx context.Context, group string, limit int) ([]*Score, error) { 84 | if group == "" || limit <= 0 { 85 | return nil, fmt.Errorf("[err] GetRankTotalByLimit %w", internal.ErrorEmptyParams) 86 | } 87 | 88 | var ret []*Score 89 | 90 | key := fmt.Sprintf(rankTotalFormat, group) 91 | 92 | scores, err := d.redisClient.ZRevRangeWithScores(ctx, key, 0, int64(limit-1)).Result() 93 | if err == redis.Nil { 94 | return ret, nil 95 | } else if err != nil { 96 | return nil, fmt.Errorf("[err] GetRankTotalByLimit %w", err) 97 | } 98 | 99 | for _, score := range scores { 100 | name := score.Member.(string) 101 | value := score.Score 102 | ret = append(ret, &Score{Name: name, Value: int64(value)}) 103 | } 104 | 105 | return ret, nil 106 | } 107 | -------------------------------------------------------------------------------- /counter/rank_test.go: -------------------------------------------------------------------------------- 1 | package counter 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | "testing" 7 | "time" 8 | 9 | "github.com/davecgh/go-spew/spew" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestDb_IncreaseRankOfDaily(t *testing.T) { 14 | assert := assert.New(t) 15 | defer mockRedis.FlushAll() 16 | 17 | ctx := context.Background() 18 | counter, err := NewCounter(WithRedisClient(mockClient)) 19 | assert.NoError(err) 20 | 21 | now := time.Now() 22 | _, err = counter.IncreaseRankOfDaily(ctx, "", "", time.Time{}) 23 | assert.Error(err) 24 | 25 | group := "github.com" 26 | ids := []string{"allan", "gjbae1212", "dong", "jung"} 27 | values := make([]int64, 4) 28 | 29 | for i := 0; i < 1000; i++ { 30 | rnd := rand.Int() % 4 31 | v, err := counter.IncreaseRankOfDaily(ctx, group, ids[rnd], now) 32 | assert.NoError(err) 33 | assert.Equal(ids[rnd], v.Name) 34 | assert.Equal(values[rnd]+1, v.Value) 35 | values[rnd] = v.Value 36 | } 37 | } 38 | 39 | func TestDb_IncreaseRankOfTotal(t *testing.T) { 40 | assert := assert.New(t) 41 | defer mockRedis.FlushAll() 42 | 43 | ctx := context.Background() 44 | counter, err := NewCounter(WithRedisClient(mockClient)) 45 | assert.NoError(err) 46 | 47 | _, err = counter.IncreaseRankOfTotal(ctx, "", "") 48 | assert.Error(err) 49 | 50 | group := "github.com" 51 | ids := []string{"allan", "gjbae1212", "dong", "jung"} 52 | values := make([]int64, 4) 53 | 54 | for i := 0; i < 1000; i++ { 55 | rnd := rand.Int() % 4 56 | v, err := counter.IncreaseRankOfTotal(ctx, group, ids[rnd]) 57 | assert.NoError(err) 58 | assert.Equal(ids[rnd], v.Name) 59 | assert.Equal(values[rnd]+1, v.Value) 60 | values[rnd] = v.Value 61 | } 62 | } 63 | 64 | func TestDb_GetRankDailyByLimit(t *testing.T) { 65 | assert := assert.New(t) 66 | defer mockRedis.FlushAll() 67 | 68 | ctx := context.Background() 69 | counter, err := NewCounter(WithRedisClient(mockClient)) 70 | assert.NoError(err) 71 | 72 | group := "github.com" 73 | ids := []string{"allan", "gjbae1212", "dong", "jung"} 74 | values := make([]int64, 4) 75 | now := time.Now() 76 | 77 | for i := 0; i < 1000; i++ { 78 | rnd := rand.Int() % 4 79 | v, err := counter.IncreaseRankOfDaily(ctx, group, ids[rnd], now) 80 | assert.NoError(err) 81 | assert.Equal(ids[rnd], v.Name) 82 | assert.Equal(values[rnd]+1, v.Value) 83 | values[rnd] = v.Value 84 | } 85 | 86 | _, err = counter.GetRankDailyByLimit(ctx, "", 0, time.Time{}) 87 | assert.Error(err) 88 | 89 | scores, err := counter.GetRankDailyByLimit(ctx, "empty", 10, now) 90 | assert.NoError(err) 91 | assert.Len(scores, 0) 92 | 93 | scores, err = counter.GetRankDailyByLimit(ctx, group, 2, now) 94 | assert.NoError(err) 95 | assert.Len(scores, 2) 96 | for _, s := range scores { 97 | for i, id := range ids { 98 | if id == s.Name { 99 | assert.Equal(values[i], s.Value) 100 | } 101 | } 102 | } 103 | 104 | scores, err = counter.GetRankDailyByLimit(ctx, group, 100, now) 105 | assert.NoError(err) 106 | assert.Len(scores, 4) 107 | for _, s := range scores { 108 | spew.Dump(s) 109 | for i, id := range ids { 110 | if id == s.Name { 111 | assert.Equal(values[i], s.Value) 112 | } 113 | } 114 | } 115 | } 116 | 117 | func TestDb_GetRankTotalByLimit(t *testing.T) { 118 | assert := assert.New(t) 119 | defer mockRedis.FlushAll() 120 | 121 | ctx := context.Background() 122 | counter, err := NewCounter(WithRedisClient(mockClient)) 123 | assert.NoError(err) 124 | 125 | group := "github.com" 126 | ids := []string{"allan", "gjbae1212", "dong", "jung"} 127 | values := make([]int64, 4) 128 | 129 | for i := 0; i < 1000; i++ { 130 | rnd := rand.Int() % 4 131 | v, err := counter.IncreaseRankOfTotal(ctx, group, ids[rnd]) 132 | assert.NoError(err) 133 | assert.Equal(ids[rnd], v.Name) 134 | assert.Equal(values[rnd]+1, v.Value) 135 | values[rnd] = v.Value 136 | } 137 | 138 | _, err = counter.GetRankTotalByLimit(ctx, "", 0) 139 | assert.Error(err) 140 | 141 | scores, err := counter.GetRankTotalByLimit(ctx, "empty", 10) 142 | assert.NoError(err) 143 | assert.Len(scores, 0) 144 | 145 | scores, err = counter.GetRankTotalByLimit(ctx, group, 2) 146 | assert.NoError(err) 147 | assert.Len(scores, 2) 148 | for _, s := range scores { 149 | for i, id := range ids { 150 | if id == s.Name { 151 | assert.Equal(values[i], s.Value) 152 | } 153 | } 154 | } 155 | 156 | scores, err = counter.GetRankTotalByLimit(ctx, group, 100) 157 | assert.NoError(err) 158 | assert.Len(scores, 4) 159 | for _, s := range scores { 160 | spew.Dump(s) 161 | for i, id := range ids { 162 | if id == s.Name { 163 | assert.Equal(values[i], s.Value) 164 | } 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | logPath = os.Getenv("LOG_PATH") 12 | sentryDsn = os.Getenv("SENTRY_DSN") 13 | phase = os.Getenv("PHASE") 14 | ) 15 | 16 | var ( 17 | debug bool 18 | forceHTTPS bool 19 | redisAddrs []string 20 | cacheSize int 21 | ) 22 | 23 | func init() { 24 | var err error 25 | if os.Getenv("DEBUG") != "" { 26 | debug, err = strconv.ParseBool(os.Getenv("DEBUG")) 27 | if err != nil { 28 | log.Panic(err) 29 | } 30 | } 31 | if os.Getenv("FORCE_HTTPS") != "" { 32 | forceHTTPS, err = strconv.ParseBool(os.Getenv("FORCE_HTTPS")) 33 | if err != nil { 34 | log.Panic(err) 35 | } 36 | } 37 | if os.Getenv("REDIS_ADDRS") != "" { 38 | seps := strings.Split(os.Getenv("REDIS_ADDRS"), ",") 39 | for _, sep := range seps { 40 | redisAddrs = append(redisAddrs, strings.TrimSpace(sep)) 41 | } 42 | } 43 | } 44 | 45 | // GetDebug returns DEBUG global environment. 46 | func GetDebug() bool { 47 | return debug 48 | } 49 | 50 | // GetDebug returns LOG_PATH global environment. 51 | func GetLogPath() string { 52 | return logPath 53 | } 54 | 55 | // GetSentryDSN returns SENTRY_DSN global environment. 56 | func GetSentryDSN() string { 57 | return sentryDsn 58 | } 59 | 60 | // GetRedisAddrs returns REDIS_ADDRS global environment. 61 | func GetRedisAddrs() []string { 62 | return redisAddrs 63 | } 64 | 65 | // GetForceHTTPS returns FORCE_HTTPS global environment. 66 | func GetForceHTTPS() bool { 67 | return forceHTTPS 68 | } 69 | 70 | // GetPhase returns PHASE global environment. 71 | func GetPhase() string { 72 | return phase 73 | } 74 | -------------------------------------------------------------------------------- /env/env_test.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetDebug(t *testing.T) { 10 | assert := assert.New(t) 11 | d := GetDebug() 12 | assert.Equal(debug, d) 13 | } 14 | 15 | func TestGetSentryDSN(t *testing.T) { 16 | assert := assert.New(t) 17 | sd := GetSentryDSN() 18 | assert.Equal(sentryDsn, sd) 19 | } 20 | 21 | func TestGetLogPath(t *testing.T) { 22 | assert := assert.New(t) 23 | lp := GetLogPath() 24 | assert.Equal(logPath, lp) 25 | } 26 | 27 | func TestGetRedisAddrs(t *testing.T) { 28 | assert := assert.New(t) 29 | addrs := GetRedisAddrs() 30 | assert.Equal(redisAddrs, addrs) 31 | } 32 | 33 | func TestGetForceHTTPS(t *testing.T) { 34 | assert := assert.New(t) 35 | forcehttps := GetForceHTTPS() 36 | assert.Equal(forceHTTPS, forcehttps) 37 | } 38 | 39 | func TestGetPhase(t *testing.T) { 40 | assert := assert.New(t) 41 | e := GetPhase() 42 | assert.Equal(phase, e) 43 | } 44 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gjbae1212/hit-counter 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect 7 | github.com/alicebob/miniredis v2.5.0+incompatible 8 | github.com/blend/go-sdk v1.20210428.4 // indirect 9 | github.com/cespare/xxhash v1.1.0 10 | github.com/davecgh/go-spew v1.1.1 11 | github.com/getsentry/sentry-go v0.10.0 12 | github.com/gjbae1212/go-async-task v1.0.1 13 | github.com/gjbae1212/go-counter-badge v1.2.3 14 | github.com/gjbae1212/go-ws-broadcast v1.0.0 15 | github.com/go-redis/redis/v8 v8.8.2 16 | github.com/google/go-cmp v0.5.5 17 | github.com/goware/urlx v0.3.1 18 | github.com/labstack/echo/v4 v4.2.2 19 | github.com/labstack/gommon v0.3.0 20 | github.com/patrickmn/go-cache v2.1.0+incompatible 21 | github.com/stretchr/testify v1.7.0 22 | github.com/wcharczuk/go-chart v2.0.1+incompatible 23 | github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /handler/api/count.go: -------------------------------------------------------------------------------- 1 | package api_handler 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | "time" 10 | 11 | "github.com/cespare/xxhash" 12 | "github.com/gjbae1212/hit-counter/counter" 13 | "github.com/gjbae1212/hit-counter/handler" 14 | "github.com/gjbae1212/hit-counter/internal" 15 | "github.com/labstack/echo/v4" 16 | "github.com/wcharczuk/go-chart" 17 | ) 18 | 19 | var ( 20 | badgeFormat = " %d / %d " 21 | countIdFormat = "%s%s" 22 | ) 23 | 24 | // IncrCount is API, which it's to increase page count. 25 | func (h *Handler) IncrCount(c echo.Context) error { 26 | hctx := c.(*handler.HitCounterContext) 27 | if hctx.Get("ckid") == nil || hctx.Get("host") == nil || hctx.Get("path") == nil || 28 | hctx.Get("title") == nil || hctx.Get("title_bg") == nil || hctx.Get("count_bg") == nil || 29 | hctx.Get("edge_flat") == nil || hctx.Get("icon") == nil || hctx.Get("icon_color") == nil { 30 | return fmt.Errorf("[err] IncrCount empty params") 31 | } 32 | cookie := hctx.Get("ckid").(string) 33 | host := hctx.Get("host").(string) 34 | path := hctx.Get("path").(string) 35 | title := hctx.Get("title").(string) 36 | titleBg := hctx.Get("title_bg").(string) 37 | countBg := hctx.Get("count_bg").(string) 38 | edgeType := hctx.Get("edge_flat").(bool) 39 | icon := hctx.Get("icon").(string) 40 | iconColor := hctx.Get("icon_color").(string) 41 | 42 | _ = cookie 43 | id := fmt.Sprintf(countIdFormat, host, path) 44 | ip := c.RealIP() 45 | userAgent := c.Request().UserAgent() 46 | 47 | // If a ingress specified ip is exceeded more than 100 per 5 seconds, it might possibly abusing. 48 | // so it must be limited. 49 | v, ok := h.LocalCache.Get(ip) 50 | if v != nil && v.(int64) > 100 { 51 | daily, total, err := h.Counter.GetHitOfDailyAndTotal(c.Request().Context(), id, time.Now()) 52 | if err != nil { 53 | return err 54 | } 55 | return h.responseBadge(hctx, daily, total, title, titleBg, 56 | countBg, edgeType, icon, iconColor) 57 | } 58 | if !ok { 59 | h.LocalCache.Set(ip, int64(1), 5*time.Second) 60 | } else { 61 | if _, err := h.LocalCache.IncrementInt64(ip, 1); err != nil { 62 | return err 63 | } 64 | } 65 | 66 | // It would limit for count a hit when a user is accessing more than 1 per second. 67 | temporaryId := fmt.Sprintf("%d", xxhash.Sum64String(fmt.Sprintf("%s-%s", ip, userAgent))) 68 | if _, ok := h.LocalCache.Get(temporaryId); ok { 69 | daily, total, err := h.Counter.GetHitOfDailyAndTotal(c.Request().Context(), id, time.Now()) 70 | if err != nil { 71 | return err 72 | } 73 | return h.responseBadge(hctx, daily, total, title, titleBg, 74 | countBg, edgeType, icon, iconColor) 75 | } 76 | h.LocalCache.Set(temporaryId, int64(1), 1*time.Second) 77 | 78 | daily, err := h.Counter.IncreaseHitOfDaily(c.Request().Context(), id, time.Now()) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | total, err := h.Counter.IncreaseHitOfTotal(c.Request().Context(), id) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | // Calculating ranks of daily and total is asynchronously working. 89 | timectx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 90 | defer cancel() 91 | 92 | if err := h.AsyncTask.AddTask(timectx, &RankTask{ 93 | Counter: h.Counter, 94 | Domain: host, 95 | Path: path, 96 | CreatedAt: time.Now(), 97 | }); err != nil { // Possibly send an error to the sentry. And it is not returned a error. 98 | internal.SentryError(err) 99 | } 100 | 101 | // Broadcast message to users to which connected 102 | h.WebSocketBreaker.BroadCast(&WebSocketMessage{ 103 | Payload: []byte(fmt.Sprintf("[%s] %s", internal.TimeToString(time.Now()), id))}, 104 | ) 105 | return h.responseBadge(hctx, daily, total, title, titleBg, 106 | countBg, edgeType, icon, iconColor) 107 | } 108 | 109 | // KeepCount is API, which it is not to increase page count. 110 | func (h *Handler) KeepCount(c echo.Context) error { 111 | hctx := c.(*handler.HitCounterContext) 112 | if hctx.Get("ckid") == nil || hctx.Get("host") == nil || hctx.Get("path") == nil || 113 | hctx.Get("title") == nil || hctx.Get("title_bg") == nil || hctx.Get("count_bg") == nil || 114 | hctx.Get("edge_flat") == nil || hctx.Get("icon") == nil || hctx.Get("icon_color") == nil { 115 | return fmt.Errorf("[err] KeepCount empty params") 116 | } 117 | host := hctx.Get("host").(string) 118 | path := hctx.Get("path").(string) 119 | cookie := hctx.Get("ckid").(string) 120 | title := hctx.Get("title").(string) 121 | titleBg := hctx.Get("title_bg").(string) 122 | countBg := hctx.Get("count_bg").(string) 123 | edgeType := hctx.Get("edge_flat").(bool) 124 | icon := hctx.Get("icon").(string) 125 | iconColor := hctx.Get("icon_color").(string) 126 | 127 | _ = cookie 128 | id := fmt.Sprintf(countIdFormat, host, path) 129 | daily, total, err := h.Counter.GetHitOfDailyAndTotal(c.Request().Context(), id, time.Now()) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | return h.responseBadge(hctx, daily, total, title, titleBg, 135 | countBg, edgeType, icon, iconColor) 136 | } 137 | 138 | // DailyHitsInRecently is API, which shows a graph related daily page count. 139 | func (h *Handler) DailyHitsInRecently(c echo.Context) error { 140 | hctx := c.(*handler.HitCounterContext) 141 | if hctx.Get("ckid") == nil || hctx.Get("host") == nil || hctx.Get("path") == nil { 142 | return fmt.Errorf("[err] KeepCount empty params") 143 | } 144 | host := hctx.Get("host").(string) 145 | path := hctx.Get("path").(string) 146 | cookie := hctx.Get("ckid").(string) 147 | _ = cookie 148 | 149 | // show between 2 month. 150 | var dateRange []time.Time 151 | now := time.Now() 152 | prev := time.Now().Add(-60 * 24 * time.Hour) 153 | for now.Unix() >= prev.Unix() { 154 | dateRange = append(dateRange, prev) 155 | prev = prev.Add(24 * time.Hour) 156 | } 157 | 158 | id := fmt.Sprintf(countIdFormat, host, path) 159 | scores, err := h.Counter.GetHitOfDailyByRange(c.Request().Context(), id, dateRange) 160 | if err != nil { 161 | return err 162 | } 163 | 164 | var yValues []float64 165 | for _, score := range scores { 166 | if score == nil { 167 | yValues = append(yValues, 0) 168 | } else { 169 | yValues = append(yValues, float64(score.Value)) 170 | } 171 | } 172 | graph := chart.Chart{ 173 | Width: 650, 174 | Height: 300, 175 | Title: fmt.Sprintf("%s", id), 176 | TitleStyle: chart.StyleShow(), 177 | XAxis: chart.XAxis{ 178 | Name: "date", 179 | Style: chart.StyleShow(), 180 | }, 181 | YAxis: chart.YAxis{ 182 | Name: "count", 183 | Style: chart.StyleShow(), 184 | }, 185 | Series: []chart.Series{ 186 | chart.TimeSeries{ 187 | Style: chart.Style{ 188 | Show: true, 189 | StrokeColor: chart.GetDefaultColor(0).WithAlpha(64), 190 | FillColor: chart.GetDefaultColor(0).WithAlpha(64), 191 | }, 192 | XValues: dateRange, 193 | YValues: yValues, 194 | }, 195 | }, 196 | } 197 | 198 | buf := new(bytes.Buffer) 199 | hctx.Response().Header().Set("Content-Type", chart.ContentTypeSVG) 200 | graph.Render(chart.SVG, buf) 201 | return hctx.String(http.StatusOK, string(buf.Bytes())) 202 | } 203 | 204 | func (h *Handler) responseBadge(ctx *handler.HitCounterContext, 205 | daily, total *counter.Score, titleText, titleBgColor, countBgColor string, edgeFlat bool, 206 | icon, iconColor string) error { 207 | dailyCount := int64(0) 208 | totalCount := int64(0) 209 | if daily != nil { 210 | dailyCount = daily.Value 211 | } 212 | if total != nil { 213 | totalCount = total.Value 214 | } 215 | 216 | // create default title 217 | if strings.TrimSpace(titleText) == "" { 218 | titleText = "hits" 219 | } 220 | 221 | // create default title background color 222 | if strings.TrimSpace(titleBgColor) == "" { 223 | titleBgColor = "#555" 224 | } 225 | 226 | // create default count background color 227 | if strings.TrimSpace(countBgColor) == "" { 228 | countBgColor = "#79c83d" 229 | } 230 | 231 | // create count text 232 | countText := fmt.Sprintf(badgeFormat, dailyCount, totalCount) 233 | 234 | // create badge 235 | var svg []byte 236 | var err error 237 | badge := internal.GenerateBadge(titleText, titleBgColor, countText, countBgColor, edgeFlat) 238 | if _, ok := h.Icons[icon]; !ok { 239 | svg, err = h.Badge.RenderFlatBadge(badge) 240 | if err != nil { 241 | return fmt.Errorf("[err] responseBadge %w", err) 242 | } 243 | } else { 244 | svg, err = h.Badge.RenderIconBadge(badge, icon, iconColor) 245 | if err != nil { 246 | return fmt.Errorf("[err] responseBadge %w", err) 247 | } 248 | } 249 | 250 | ctx.Response().Header().Set("Content-Type", "image/svg+xml") 251 | ctx.Response().Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 252 | ctx.Response().Header().Set("Pragma", "no-cache") 253 | ctx.Response().Header().Set("Expires", "0") 254 | return ctx.String(http.StatusOK, string(svg)) 255 | } 256 | -------------------------------------------------------------------------------- /handler/api/count_test.go: -------------------------------------------------------------------------------- 1 | package api_handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | "github.com/gjbae1212/hit-counter/handler" 11 | "github.com/gjbae1212/hit-counter/internal" 12 | "github.com/labstack/echo/v4" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestHandler_KeepCount(t *testing.T) { 17 | assert := assert.New(t) 18 | defer mockRedis.FlushAll() 19 | 20 | h, err := handler.NewHandler(mockRedis.Addr()) 21 | assert.NoError(err) 22 | 23 | api, err := NewHandler(h) 24 | assert.NoError(err) 25 | e := echo.New() 26 | 27 | // err 28 | errR := httptest.NewRequest("GET", "http://localhost:8080", nil) 29 | errW := httptest.NewRecorder() 30 | errCtx := &handler.HitCounterContext{Context: e.NewContext(errR, errW)} 31 | 32 | // default 33 | defaultR := httptest.NewRequest("GET", "http://localhost:8080", nil) 34 | defaultW := httptest.NewRecorder() 35 | defaultCtx := &handler.HitCounterContext{Context: e.NewContext(defaultR, defaultW)} 36 | defaultCtx.Set("ckid", "test") 37 | defaultCtx.Set("host", "github.com") 38 | defaultCtx.Set("path", "gjbae1212/hit-counter") 39 | defaultCtx.Set("title", " ") 40 | defaultCtx.Set("title_bg", " ") 41 | defaultCtx.Set("count_bg", " ") 42 | defaultCtx.Set("edge_flat", true) 43 | defaultCtx.Set("icon", "") 44 | defaultCtx.Set("icon_color", "") 45 | 46 | defaultOutput, err := h.Badge.RenderFlatBadge(internal.GenerateBadge("hits", 47 | "#555", fmt.Sprintf(badgeFormat, 0, 0), "#79c83d", true)) 48 | assert.NoError(err) 49 | 50 | // title 51 | titleR := httptest.NewRequest("GET", "http://localhost:8080", nil) 52 | titleW := httptest.NewRecorder() 53 | titleCtx := &handler.HitCounterContext{Context: e.NewContext(titleR, titleW)} 54 | titleCtx.Set("ckid", "test") 55 | titleCtx.Set("host", "github.com") 56 | titleCtx.Set("path", "gjbae1212/hit-counter") 57 | titleCtx.Set("title", " hello ") 58 | titleCtx.Set("title_bg", "") 59 | titleCtx.Set("count_bg", "") 60 | titleCtx.Set("edge_flat", true) 61 | titleCtx.Set("icon", "") 62 | titleCtx.Set("icon_color", "") 63 | 64 | titleOutput, err := h.Badge.RenderFlatBadge(internal.GenerateBadge(" hello ", 65 | "#555", fmt.Sprintf(badgeFormat, 0, 0), "#79c83d", true)) 66 | assert.NoError(err) 67 | 68 | // bg-color 69 | bgColorR := httptest.NewRequest("GET", "http://localhost:8080", nil) 70 | bgColorW := httptest.NewRecorder() 71 | bgColorCtx := &handler.HitCounterContext{Context: e.NewContext(bgColorR, bgColorW)} 72 | bgColorCtx.Set("ckid", "test") 73 | bgColorCtx.Set("host", "github.com") 74 | bgColorCtx.Set("path", "gjbae1212/hit-counter") 75 | bgColorCtx.Set("title", "") 76 | bgColorCtx.Set("title_bg", "#111") 77 | bgColorCtx.Set("count_bg", "#222") 78 | bgColorCtx.Set("edge_flat", true) 79 | bgColorCtx.Set("icon", "") 80 | bgColorCtx.Set("icon_color", "") 81 | 82 | bgColorOutput, err := h.Badge.RenderFlatBadge(internal.GenerateBadge("hits", 83 | "#111", fmt.Sprintf(badgeFormat, 0, 0), "#222", true)) 84 | assert.NoError(err) 85 | 86 | // edge 87 | edgeR := httptest.NewRequest("GET", "http://localhost:8080", nil) 88 | edgeW := httptest.NewRecorder() 89 | edgeCtx := &handler.HitCounterContext{Context: e.NewContext(edgeR, edgeW)} 90 | edgeCtx.Set("ckid", "test") 91 | edgeCtx.Set("host", "github.com") 92 | edgeCtx.Set("path", "gjbae1212/hit-counter") 93 | edgeCtx.Set("title", "") 94 | edgeCtx.Set("title_bg", "") 95 | edgeCtx.Set("count_bg", "") 96 | edgeCtx.Set("edge_flat", false) 97 | edgeCtx.Set("icon", "") 98 | edgeCtx.Set("icon_color", "") 99 | 100 | edgeCtxOutput, err := h.Badge.RenderFlatBadge(internal.GenerateBadge("hits", 101 | "#555", fmt.Sprintf(badgeFormat, 0, 0), "#79c83d", false)) 102 | assert.NoError(err) 103 | 104 | // icon 105 | iconR := httptest.NewRequest("GET", "http://localhost:8080", nil) 106 | iconW := httptest.NewRecorder() 107 | iconCtx := &handler.HitCounterContext{Context: e.NewContext(iconR, iconW)} 108 | iconCtx.Set("ckid", "test") 109 | iconCtx.Set("host", "github.com") 110 | iconCtx.Set("path", "gjbae1212/hit-counter") 111 | iconCtx.Set("title", "") 112 | iconCtx.Set("title_bg", "") 113 | iconCtx.Set("count_bg", "") 114 | iconCtx.Set("edge_flat", false) 115 | iconCtx.Set("icon", "a-frame.svg") 116 | iconCtx.Set("icon_color", "") 117 | 118 | iconCtxOutput, err := h.Badge.RenderIconBadge(internal.GenerateBadge("hits", 119 | "#555", fmt.Sprintf(badgeFormat, 0, 0), "#79c83d", false), "a-frame.svg", "") 120 | assert.NoError(err) 121 | 122 | // icon with color 123 | iconWithColorR := httptest.NewRequest("GET", "http://localhost:8080", nil) 124 | iconWithColorW := httptest.NewRecorder() 125 | iconWithColorCtx := &handler.HitCounterContext{Context: e.NewContext(iconWithColorR, iconWithColorW)} 126 | iconWithColorCtx.Set("ckid", "test") 127 | iconWithColorCtx.Set("host", "github.com") 128 | iconWithColorCtx.Set("path", "gjbae1212/hit-counter") 129 | iconWithColorCtx.Set("title", "") 130 | iconWithColorCtx.Set("title_bg", "") 131 | iconWithColorCtx.Set("count_bg", "") 132 | iconWithColorCtx.Set("edge_flat", false) 133 | iconWithColorCtx.Set("icon", "a-frame.svg") 134 | iconWithColorCtx.Set("icon_color", "#aaaaaa") 135 | 136 | iconWithColorCtxOutput, err := h.Badge.RenderIconBadge(internal.GenerateBadge("hits", 137 | "#555", fmt.Sprintf(badgeFormat, 0, 0), "#79c83d", false), "a-frame.svg", "#aaaaaa") 138 | assert.NoError(err) 139 | 140 | tests := map[string]struct { 141 | input *handler.HitCounterContext 142 | w *httptest.ResponseRecorder 143 | output string 144 | isErr bool 145 | }{ 146 | "err": { 147 | input: errCtx, 148 | isErr: true, 149 | }, 150 | "default": { 151 | input: defaultCtx, 152 | w: defaultW, 153 | output: string(defaultOutput), 154 | }, 155 | "title": { 156 | input: titleCtx, 157 | w: titleW, 158 | output: string(titleOutput), 159 | }, 160 | "bg-color": { 161 | input: bgColorCtx, 162 | w: bgColorW, 163 | output: string(bgColorOutput), 164 | }, 165 | "edge": { 166 | input: edgeCtx, 167 | w: edgeW, 168 | output: string(edgeCtxOutput), 169 | }, 170 | "icon": { 171 | input: iconCtx, 172 | w: iconW, 173 | output: string(iconCtxOutput), 174 | }, 175 | "icon-with-color": { 176 | input: iconWithColorCtx, 177 | w: iconWithColorW, 178 | output: string(iconWithColorCtxOutput), 179 | }, 180 | } 181 | 182 | for _, t := range tests { 183 | err := api.KeepCount(t.input) 184 | assert.Equal(t.isErr, err != nil) 185 | if err == nil { 186 | assert.Equal(200, t.w.Code) 187 | assert.Equal(t.output, t.w.Body.String()) 188 | } 189 | } 190 | } 191 | 192 | func TestHandler_IncrCount(t *testing.T) { 193 | assert := assert.New(t) 194 | defer mockRedis.FlushAll() 195 | 196 | h, err := handler.NewHandler(mockRedis.Addr()) 197 | assert.NoError(err) 198 | 199 | api, err := NewHandler(h) 200 | assert.NoError(err) 201 | e := echo.New() 202 | 203 | // err 204 | errR := httptest.NewRequest("GET", "http://localhost:8080", nil) 205 | errW := httptest.NewRecorder() 206 | errCtx := &handler.HitCounterContext{Context: e.NewContext(errR, errW)} 207 | 208 | // default 209 | defaultR := httptest.NewRequest("GET", "http://localhost:8080", nil) 210 | defaultW := httptest.NewRecorder() 211 | defaultCtx := &handler.HitCounterContext{Context: e.NewContext(defaultR, defaultW)} 212 | defaultCtx.Set("ckid", "test") 213 | defaultCtx.Set("host", "github.com") 214 | defaultCtx.Set("path", "gjbae1212/hit-counter-default") 215 | defaultCtx.Set("title", " ") 216 | defaultCtx.Set("title_bg", " ") 217 | defaultCtx.Set("count_bg", " ") 218 | defaultCtx.Set("edge_flat", true) 219 | defaultCtx.Set("icon", "") 220 | defaultCtx.Set("icon_color", "") 221 | 222 | defaultOutput, err := h.Badge.RenderFlatBadge(internal.GenerateBadge("hits", 223 | "#555", fmt.Sprintf(badgeFormat, 1, 1), "#79c83d", true)) 224 | assert.NoError(err) 225 | 226 | // title 227 | titleR := httptest.NewRequest("GET", "http://localhost:8080", nil) 228 | titleW := httptest.NewRecorder() 229 | titleCtx := &handler.HitCounterContext{Context: e.NewContext(titleR, titleW)} 230 | titleCtx.Set("ckid", "test") 231 | titleCtx.Set("host", "github.com") 232 | titleCtx.Set("path", "gjbae1212/hit-counter-title") 233 | titleCtx.Set("title", " hello ") 234 | titleCtx.Set("title_bg", "") 235 | titleCtx.Set("count_bg", "") 236 | titleCtx.Set("edge_flat", true) 237 | titleCtx.Set("icon", "") 238 | titleCtx.Set("icon_color", "") 239 | 240 | titleOutput, err := h.Badge.RenderFlatBadge(internal.GenerateBadge(" hello ", 241 | "#555", fmt.Sprintf(badgeFormat, 1, 1), "#79c83d", true)) 242 | assert.NoError(err) 243 | 244 | // bg-color 245 | bgColorR := httptest.NewRequest("GET", "http://localhost:8080", nil) 246 | bgColorW := httptest.NewRecorder() 247 | bgColorCtx := &handler.HitCounterContext{Context: e.NewContext(bgColorR, bgColorW)} 248 | bgColorCtx.Set("ckid", "test") 249 | bgColorCtx.Set("host", "github.com") 250 | bgColorCtx.Set("path", "gjbae1212/hit-counter-bg-color") 251 | bgColorCtx.Set("title", "") 252 | bgColorCtx.Set("title_bg", "#111") 253 | bgColorCtx.Set("count_bg", "#222") 254 | bgColorCtx.Set("edge_flat", true) 255 | bgColorCtx.Set("icon", "") 256 | bgColorCtx.Set("icon_color", "") 257 | 258 | bgColorOutput, err := h.Badge.RenderFlatBadge(internal.GenerateBadge("hits", 259 | "#111", fmt.Sprintf(badgeFormat, 1, 1), "#222", true)) 260 | assert.NoError(err) 261 | 262 | // edge 263 | edgeR := httptest.NewRequest("GET", "http://localhost:8080", nil) 264 | edgeW := httptest.NewRecorder() 265 | edgeCtx := &handler.HitCounterContext{Context: e.NewContext(edgeR, edgeW)} 266 | edgeCtx.Set("ckid", "test") 267 | edgeCtx.Set("host", "github.com") 268 | edgeCtx.Set("path", "gjbae1212/hit-counter-edge") 269 | edgeCtx.Set("title", "") 270 | edgeCtx.Set("title_bg", "") 271 | edgeCtx.Set("count_bg", "") 272 | edgeCtx.Set("edge_flat", false) 273 | edgeCtx.Set("icon", "") 274 | edgeCtx.Set("icon_color", "") 275 | 276 | edgeCtxOutput, err := h.Badge.RenderFlatBadge(internal.GenerateBadge("hits", 277 | "#555", fmt.Sprintf(badgeFormat, 1, 1), "#79c83d", false)) 278 | assert.NoError(err) 279 | 280 | tests := map[string]struct { 281 | input *handler.HitCounterContext 282 | w *httptest.ResponseRecorder 283 | output string 284 | isErr bool 285 | }{ 286 | "err": { 287 | input: errCtx, 288 | isErr: true, 289 | }, 290 | "default": { 291 | input: defaultCtx, 292 | w: defaultW, 293 | output: string(defaultOutput), 294 | }, 295 | "title": { 296 | input: titleCtx, 297 | w: titleW, 298 | output: string(titleOutput), 299 | }, 300 | "bg-color": { 301 | input: bgColorCtx, 302 | w: bgColorW, 303 | output: string(bgColorOutput), 304 | }, 305 | "edge": { 306 | input: edgeCtx, 307 | w: edgeW, 308 | output: string(edgeCtxOutput), 309 | }, 310 | } 311 | 312 | for k, t := range tests { 313 | t.input.Request().Header.Set("User-Agent", k) 314 | err := api.IncrCount(t.input) 315 | assert.Equal(t.isErr, err != nil) 316 | if err == nil { 317 | assert.Equal(200, t.w.Code) 318 | assert.Equal(t.output, t.w.Body.String()) 319 | } 320 | } 321 | 322 | for i := 0; i < 10; i++ { 323 | r := httptest.NewRequest("GET", "http://localhost:8080", nil) 324 | r.Header.Set("User-Agent", fmt.Sprintf("%d", i)) 325 | w := httptest.NewRecorder() 326 | hctx := &handler.HitCounterContext{Context: e.NewContext(r, w)} 327 | hctx.Set("ckid", "test") 328 | hctx.Set("host", "github.com") 329 | hctx.Set("path", "gjbae1212/hit-counter") 330 | hctx.Set("title", "") 331 | hctx.Set("title_bg", "") 332 | hctx.Set("count_bg", "") 333 | hctx.Set("edge_flat", false) 334 | hctx.Set("icon", "") 335 | hctx.Set("icon_color", "") 336 | 337 | err = api.IncrCount(hctx) 338 | assert.NoError(err) 339 | assert.Equal(200, w.Code) 340 | } 341 | 342 | time.Sleep(3 * time.Second) 343 | scores, err := api.Counter.GetRankDailyByLimit(context.Background(), "github.com", 10, time.Now()) 344 | assert.NoError(err) 345 | assert.Len(scores, 5) 346 | assert.Equal(int64(10), scores[0].Value) 347 | scores, err = api.Counter.GetRankTotalByLimit(context.Background(), "github.com", 10) 348 | assert.NoError(err) 349 | assert.Len(scores, 5) 350 | assert.Equal(int64(10), scores[0].Value) 351 | scores, err = api.Counter.GetRankTotalByLimit(context.Background(), "domain", 10) 352 | assert.NoError(err) 353 | assert.Len(scores, 1) 354 | assert.Equal(int64(14), scores[0].Value) 355 | } 356 | -------------------------------------------------------------------------------- /handler/api/handler.go: -------------------------------------------------------------------------------- 1 | package api_handler 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gjbae1212/hit-counter/handler" 7 | "github.com/gjbae1212/hit-counter/internal" 8 | ) 9 | 10 | type Handler struct { 11 | *handler.Handler 12 | } 13 | 14 | // NewHandler creates api handler object. 15 | func NewHandler(h *handler.Handler) (*Handler, error) { 16 | if h == nil { 17 | return nil, fmt.Errorf("[err] api handler %w", internal.ErrorEmptyParams) 18 | } 19 | return &Handler{Handler: h}, nil 20 | } 21 | -------------------------------------------------------------------------------- /handler/api/handler_test.go: -------------------------------------------------------------------------------- 1 | package api_handler 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/alicebob/miniredis" 8 | "github.com/gjbae1212/hit-counter/handler" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | var ( 13 | mockRedis *miniredis.Miniredis 14 | ) 15 | 16 | func TestNewHandler(t *testing.T) { 17 | assert := assert.New(t) 18 | _, err := NewHandler(nil) 19 | assert.Error(err) 20 | 21 | h := &handler.Handler{} 22 | _, err = NewHandler(h) 23 | assert.NoError(err) 24 | } 25 | 26 | func TestMain(m *testing.M) { 27 | var err error 28 | mockRedis, err = miniredis.Run() 29 | if err != nil { 30 | panic(err) 31 | } 32 | code := m.Run() 33 | mockRedis.Close() 34 | os.Exit(code) 35 | } 36 | -------------------------------------------------------------------------------- /handler/api/rank_task.go: -------------------------------------------------------------------------------- 1 | package api_handler 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "time" 7 | 8 | "github.com/gjbae1212/hit-counter/counter" 9 | ) 10 | 11 | const ( 12 | domainGroup = "domain" 13 | githubGroup = "github.com" 14 | githubProfileSumGroup = "github.com-profile-sum" 15 | ) 16 | 17 | type RankTask struct { 18 | Counter counter.Counter 19 | Domain string 20 | Path string 21 | CreatedAt time.Time 22 | } 23 | 24 | // Process is a specific method implemented Task interface in async task. 25 | func (task *RankTask) Process(ctx context.Context) error { 26 | // If a domain is 'github.com', it is calculating ranks. 27 | if task.Domain == githubGroup && task.Path != "" { 28 | // Calculate visiting count based on github.com. 29 | if _, err := task.Counter.IncreaseRankOfDaily(ctx, githubGroup, task.Path, task.CreatedAt); err != nil { 30 | return err 31 | } 32 | if _, err := task.Counter.IncreaseRankOfTotal(ctx, githubGroup, task.Path); err != nil { 33 | return err 34 | } 35 | 36 | // Calculate sum of visiting count for github projects based on github profile. 37 | seps := strings.Split(task.Path, "/") 38 | if len(seps) >= 2 && seps[1] != "" { 39 | if _, err := task.Counter.IncreaseRankOfDaily(ctx, githubProfileSumGroup, seps[1], task.CreatedAt); err != nil { 40 | return err 41 | } 42 | if _, err := task.Counter.IncreaseRankOfTotal(ctx, githubProfileSumGroup, seps[1]); err != nil { 43 | return err 44 | } 45 | } 46 | } 47 | 48 | // Calculate visiting count for daily and total. 49 | if _, err := task.Counter.IncreaseRankOfDaily(ctx, domainGroup, task.Domain, task.CreatedAt); err != nil { 50 | return err 51 | } 52 | if _, err := task.Counter.IncreaseRankOfTotal(ctx, domainGroup, task.Domain); err != nil { 53 | return err 54 | } 55 | 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /handler/api/rank_task_test.go: -------------------------------------------------------------------------------- 1 | package api_handler 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/davecgh/go-spew/spew" 9 | "github.com/gjbae1212/hit-counter/counter" 10 | "github.com/gjbae1212/hit-counter/handler" 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestRankTask_Process(t *testing.T) { 16 | assert := assert.New(t) 17 | defer mockRedis.FlushAll() 18 | 19 | h, err := handler.NewHandler(mockRedis.Addr()) 20 | assert.NoError(err) 21 | 22 | api, err := NewHandler(h) 23 | assert.NoError(err) 24 | 25 | tests := map[string]struct { 26 | input *RankTask 27 | wants []*counter.Score 28 | }{ 29 | "not_github": {input: &RankTask{ 30 | Counter: h.Counter, 31 | Domain: "allan.com", 32 | Path: "/aa/bb", 33 | CreatedAt: time.Now(), 34 | }, wants: []*counter.Score{ 35 | nil, 36 | nil, 37 | &counter.Score{ 38 | Name: "allan.com", 39 | Value: 1, 40 | }, 41 | &counter.Score{ 42 | Name: "allan.com", 43 | Value: 1, 44 | }, 45 | }}, 46 | "github-1": {input: &RankTask{ 47 | Counter: h.Counter, 48 | Domain: "github.com", 49 | Path: "/gjbae1212/test", 50 | CreatedAt: time.Now(), 51 | }, wants: []*counter.Score{ 52 | &counter.Score{ 53 | Name: "/gjbae1212/test", 54 | Value: 1, 55 | }, 56 | &counter.Score{ 57 | Name: "/gjbae1212/test", 58 | Value: 1, 59 | }, 60 | nil, 61 | nil, 62 | }}, 63 | "github-2": {input: &RankTask{ 64 | Counter: h.Counter, 65 | Domain: "github.com", 66 | Path: "/gjbae1212/hoho", 67 | CreatedAt: time.Now(), 68 | }, wants: []*counter.Score{ 69 | &counter.Score{ 70 | Name: "/gjbae1212/hoho", 71 | Value: 1, 72 | }, 73 | &counter.Score{ 74 | Name: "/gjbae1212/hoho", 75 | Value: 1, 76 | }, 77 | nil, 78 | nil, 79 | }}, 80 | } 81 | 82 | ctx := context.Background() 83 | 84 | for k, t := range tests { 85 | switch k { 86 | case "not_github": 87 | err = api.AsyncTask.AddTask(ctx, t.input) 88 | assert.NoError(err) 89 | time.Sleep(1 * time.Second) 90 | 91 | scores, err := api.Counter.GetRankDailyByLimit(context.Background(), t.input.Domain, 10, time.Now()) 92 | assert.NoError(err) 93 | assert.Len(scores, 0) 94 | 95 | scores, err = api.Counter.GetRankTotalByLimit(context.Background(), t.input.Domain, 10) 96 | assert.NoError(err) 97 | assert.Len(scores, 0) 98 | case "github-1", "github-2": 99 | err = api.AsyncTask.AddTask(ctx, t.input) 100 | assert.NoError(err) 101 | time.Sleep(1 * time.Second) 102 | 103 | scores, err := api.Counter.GetRankDailyByLimit(context.Background(), githubGroup, 10, time.Now()) 104 | assert.NoError(err) 105 | 106 | if len(scores) == 1 { 107 | assert.True(cmp.Equal(t.wants[0], scores[0])) 108 | spew.Dump(scores) 109 | scores, err = api.Counter.GetRankTotalByLimit(context.Background(), githubGroup, 10) 110 | assert.NoError(err) 111 | assert.Len(scores, 1) 112 | assert.True(cmp.Equal(t.wants[1], scores[0])) 113 | spew.Dump(scores) 114 | } else { 115 | assert.Len(scores, 2) 116 | } 117 | } 118 | } 119 | 120 | // [TEST] github.com domain, profile 121 | scores, err := api.Counter.GetRankDailyByLimit(context.Background(), domainGroup, 10, time.Now()) 122 | assert.NoError(err) 123 | assert.Len(scores, 2) 124 | assert.True(cmp.Equal(&counter.Score{Name: githubGroup, Value: 2}, scores[0])) 125 | assert.True(cmp.Equal(&counter.Score{Name: "allan.com", Value: 1}, scores[1])) 126 | 127 | scores, err = api.Counter.GetRankTotalByLimit(context.Background(), domainGroup, 10) 128 | assert.NoError(err) 129 | assert.Len(scores, 2) 130 | assert.True(cmp.Equal(&counter.Score{Name: githubGroup, Value: 2}, scores[0])) 131 | assert.True(cmp.Equal(&counter.Score{Name: "allan.com", Value: 1}, scores[1])) 132 | 133 | scores, err = api.Counter.GetRankDailyByLimit(context.Background(), githubProfileSumGroup, 10, time.Now()) 134 | assert.NoError(err) 135 | assert.Len(scores, 1) 136 | assert.True(cmp.Equal(&counter.Score{Name: "gjbae1212", Value: 2}, scores[0])) 137 | spew.Dump(scores) 138 | 139 | } 140 | -------------------------------------------------------------------------------- /handler/api/websocket.go: -------------------------------------------------------------------------------- 1 | package api_handler 2 | 3 | type WebSocketMessage struct { 4 | Payload []byte 5 | } 6 | 7 | // GetMessage is a specific method implemented Message interface in websocket. 8 | func (wsm *WebSocketMessage) GetMessage() []byte { 9 | return wsm.Payload 10 | } 11 | -------------------------------------------------------------------------------- /handler/api/websocket_test.go: -------------------------------------------------------------------------------- 1 | package api_handler 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestWebSocketMessage_GetMessage(t *testing.T) { 10 | assert := assert.New(t) 11 | 12 | tests := map[string]struct { 13 | input string 14 | want string 15 | }{"step1": {input: "hi", want: "hi"}} 16 | 17 | for _, v := range tests { 18 | wsm := &WebSocketMessage{Payload: []byte(v.input)} 19 | assert.Equal(wsm.Payload, wsm.GetMessage()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /handler/context.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type ( 10 | // It's custom context. 11 | HitCounterContext struct { 12 | echo.Context 13 | } 14 | ) 15 | 16 | // GetContext returns a context in request. 17 | func (c *HitCounterContext) GetContext() context.Context { 18 | return c.Request().Context() 19 | } 20 | 21 | // SetContext sets a context to request. 22 | func (c *HitCounterContext) SetContext(ctx context.Context) { 23 | c.SetRequest(c.Request().WithContext(ctx)) 24 | } 25 | 26 | // WithContext set a context with new value to request. 27 | func (c *HitCounterContext) WithContext(key, val interface{}) { 28 | ctx := c.GetContext() 29 | c.SetContext(context.WithValue(ctx, key, val)) 30 | } 31 | 32 | // ValueContext returns values in request context. 33 | func (c *HitCounterContext) ValueContext(key interface{}) interface{} { 34 | return c.GetContext().Value(key) 35 | } 36 | 37 | // ExtraLog returns log struct. 38 | func (c *HitCounterContext) ExtraLog() map[string]interface{} { 39 | return map[string]interface{}{ 40 | "host": c.Request().Host, 41 | "ip": c.RealIP(), 42 | "uri": c.Request().RequestURI, 43 | "method": c.Request().Method, 44 | "referer": c.Request().Referer(), 45 | "user-agent": c.Request().UserAgent(), 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /handler/context_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/labstack/echo/v4" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestHitCounterContext_ExtraLog(t *testing.T) { 15 | assert := assert.New(t) 16 | e := echo.New() 17 | r := httptest.NewRequest("GET", "http://localhost", nil) 18 | nctx := &HitCounterContext{e.NewContext(r, nil)} 19 | extraLog := nctx.ExtraLog() 20 | log.Println(extraLog) 21 | assert.Equal("GET", extraLog["method"]) 22 | assert.Equal("localhost", extraLog["host"]) 23 | assert.Len(extraLog, 6) 24 | } 25 | 26 | func TestHitCounterContext_ValueContext(t *testing.T) { 27 | assert := assert.New(t) 28 | 29 | e := echo.New() 30 | r := httptest.NewRequest("GET", "http://localhost", nil) 31 | nctx := &HitCounterContext{e.NewContext(r, nil)} 32 | 33 | nctx.WithContext("allan", "hi") 34 | value := nctx.ValueContext("allan") 35 | assert.Equal("hi", value.(string)) 36 | } 37 | 38 | func TestHitCounterContext_WithContext(t *testing.T) { 39 | assert := assert.New(t) 40 | 41 | e := echo.New() 42 | r := httptest.NewRequest("GET", "http://localhost", nil) 43 | nctx := &HitCounterContext{e.NewContext(r, nil)} 44 | nctx.WithContext("allan", "hi") 45 | nctx.WithContext("test", "testhi") 46 | 47 | value := nctx.ValueContext("allan") 48 | assert.Equal("hi", value.(string)) 49 | } 50 | 51 | func TestHitCounterContext_SetContext(t *testing.T) { 52 | assert := assert.New(t) 53 | 54 | e := echo.New() 55 | r := httptest.NewRequest("GET", "http://localhost", nil) 56 | nctx := &HitCounterContext{e.NewContext(r, nil)} 57 | 58 | ctx := context.WithValue(context.Background(), "test", "allan") 59 | nctx.SetContext(ctx) 60 | 61 | value := nctx.ValueContext("test") 62 | assert.Equal("allan", value.(string)) 63 | } 64 | 65 | func TestHitCounterContext_GetContext(t *testing.T) { 66 | assert := assert.New(t) 67 | 68 | e := echo.New() 69 | r := httptest.NewRequest("GET", "http://localhost", nil) 70 | nctx := &HitCounterContext{e.NewContext(r, nil)} 71 | 72 | ctx := context.WithValue(context.Background(), "test", "allan") 73 | nctx.SetContext(ctx) 74 | 75 | vctx := nctx.GetContext() 76 | value := vctx.Value("test") 77 | assert.Equal("allan", value) 78 | } 79 | -------------------------------------------------------------------------------- /handler/error.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | // Error is API for error. 10 | func (h *Handler) Error(err error, c echo.Context) { 11 | code := http.StatusInternalServerError 12 | if he, ok := err.(*echo.HTTPError); ok { 13 | code = he.Code 14 | } 15 | c.NoContent(code) 16 | } 17 | -------------------------------------------------------------------------------- /handler/error_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/labstack/echo/v4" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestHandler_Error(t *testing.T) { 14 | assert := assert.New(t) 15 | defer mockRedis.FlushAll() 16 | 17 | e := echo.New() 18 | h, err := NewHandler(mockRedis.Addr()) 19 | assert.NoError(err) 20 | 21 | request := httptest.NewRequest("GET", "http://localhost", nil) 22 | w := httptest.NewRecorder() 23 | ectx := e.NewContext(request, w) 24 | 25 | h.Error(fmt.Errorf("[err] test"), ectx) 26 | 27 | resp := w.Result() 28 | assert.Equal(http.StatusInternalServerError, resp.StatusCode) 29 | } 30 | -------------------------------------------------------------------------------- /handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "path/filepath" 7 | "runtime" 8 | "time" 9 | 10 | task "github.com/gjbae1212/go-async-task" 11 | badge "github.com/gjbae1212/go-counter-badge/badge" 12 | websocket "github.com/gjbae1212/go-ws-broadcast" 13 | "github.com/gjbae1212/hit-counter/counter" 14 | "github.com/gjbae1212/hit-counter/env" 15 | "github.com/gjbae1212/hit-counter/internal" 16 | "github.com/go-redis/redis/v8" 17 | cache "github.com/patrickmn/go-cache" 18 | ) 19 | 20 | var ( 21 | iconsMap map[string]badge.Icon 22 | iconsList []map[string]string 23 | ) 24 | 25 | type Handler struct { 26 | Counter counter.Counter 27 | LocalCache *cache.Cache 28 | AsyncTask task.Keeper 29 | WebSocketBreaker websocket.Breaker 30 | IndexTemplate *template.Template 31 | Badge badge.Writer 32 | Icons map[string]badge.Icon 33 | IconsList []map[string]string 34 | } 35 | 36 | // NewHandler creates handler object. 37 | func NewHandler(redisAddr string) (*Handler, error) { 38 | if redisAddr == "" { 39 | return nil, fmt.Errorf("[err] NewHandler %w", internal.ErrorEmptyParams) 40 | } 41 | 42 | // create local cache 43 | localCache := cache.New(24*time.Hour, 10*time.Minute) 44 | 45 | redisClient := redis.NewClient(&redis.Options{ 46 | Addr: redisAddr, 47 | Password: "", 48 | DB: 0, 49 | MaxRetries: 1, 50 | MinIdleConns: runtime.NumCPU() * 3, 51 | PoolSize: runtime.NumCPU() * 10, 52 | }) 53 | ctr, err := counter.NewCounter(counter.WithRedisClient(redisClient)) 54 | if err != nil { 55 | return nil, fmt.Errorf("[err] NewHandler %w", err) 56 | } 57 | 58 | // create async task 59 | asyncTask, err := task.NewAsyncTask( 60 | task.WithQueueSizeOption(1000), 61 | task.WithWorkerSizeOption(5), 62 | task.WithTimeoutOption(20*time.Second), 63 | task.WithErrorHandlerOption(func(err error) { 64 | internal.SentryError(err) 65 | }), 66 | ) 67 | if err != nil { 68 | return nil, fmt.Errorf("[err] NewHandler %w", err) 69 | } 70 | 71 | // create websocket breaker 72 | breaker, err := websocket.NewBreaker(websocket.WithMaxReadLimit(1024), 73 | websocket.WithMaxMessagePoolLength(500), 74 | websocket.WithErrorHandlerOption(func(err error) { 75 | internal.SentryError(err) 76 | })) 77 | if err != nil { 78 | return nil, fmt.Errorf("[err] NewHandler %w", err) 79 | } 80 | 81 | // template 82 | indexName := "index.html" 83 | if env.GetPhase() == "local" { 84 | indexName = "local.html" 85 | } 86 | 87 | indexTemplate, err := template.ParseFiles(filepath.Join(internal.GetRoot(), "view", indexName)) 88 | if err != nil { 89 | return nil, fmt.Errorf("[err] NewHandler %w", err) 90 | } 91 | 92 | // badge generator 93 | badgeWriter, err := badge.NewWriter() 94 | if err != nil { 95 | return nil, fmt.Errorf("[err] NewHandler %w", err) 96 | } 97 | 98 | return &Handler{ 99 | LocalCache: localCache, 100 | Counter: ctr, 101 | AsyncTask: asyncTask, 102 | WebSocketBreaker: breaker, 103 | IndexTemplate: indexTemplate, 104 | Badge: badgeWriter, 105 | Icons: iconsMap, 106 | IconsList: iconsList, 107 | }, nil 108 | } 109 | 110 | func init() { 111 | iconsMap = badge.GetIconsMap() 112 | iconsList = make([]map[string]string, 0, len(iconsMap)) 113 | 114 | for k, _ := range iconsMap { 115 | j := make(map[string]string, 2) 116 | j["name"] = k 117 | j["url"] = fmt.Sprintf("/icon/%s", k) 118 | iconsList = append(iconsList, j) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /handler/handler_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/alicebob/miniredis" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var ( 12 | mockRedis *miniredis.Miniredis 13 | ) 14 | 15 | func TestNewHandler(t *testing.T) { 16 | assert := assert.New(t) 17 | defer mockRedis.FlushAll() 18 | 19 | _, err := NewHandler("") 20 | assert.Error(err) 21 | 22 | s, err := miniredis.Run() 23 | assert.NoError(err) 24 | defer s.Close() 25 | 26 | _, err = NewHandler(s.Addr()) 27 | assert.NoError(err) 28 | } 29 | 30 | func TestMain(m *testing.M) { 31 | var err error 32 | mockRedis, err = miniredis.Run() 33 | if err != nil { 34 | panic(err) 35 | } 36 | code := m.Run() 37 | mockRedis.Close() 38 | os.Exit(code) 39 | } 40 | -------------------------------------------------------------------------------- /handler/healthcheck.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "net/http" 6 | ) 7 | 8 | // HealthCheck is API for checking server status. 9 | func (h *Handler) HealthCheck(c echo.Context) error { 10 | return c.String(http.StatusOK, "health check!") 11 | } 12 | -------------------------------------------------------------------------------- /handler/healthcheck_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/labstack/echo/v4" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestHandler_HealthCheck(t *testing.T) { 14 | assert := assert.New(t) 15 | defer mockRedis.FlushAll() 16 | 17 | e := echo.New() 18 | h, err := NewHandler(mockRedis.Addr()) 19 | assert.NoError(err) 20 | 21 | r := httptest.NewRequest("GET", "http://localhost:8080", nil) 22 | w := httptest.NewRecorder() 23 | 24 | ectx := e.NewContext(r, w) 25 | err = h.HealthCheck(ectx) 26 | assert.NoError(err) 27 | 28 | resp := w.Result() 29 | body, _ := ioutil.ReadAll(resp.Body) 30 | assert.Equal(http.StatusOK, resp.StatusCode) 31 | assert.Equal("health check!", string(body)) 32 | } 33 | -------------------------------------------------------------------------------- /handler/icon.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | ) 6 | 7 | // IconAll returns icon list. 8 | func (h *Handler) IconAll(c echo.Context) error { 9 | c.Response().Header().Set("Cache-Control", "max-age=7200, public") 10 | return c.JSON(200, h.IconsList) 11 | } 12 | 13 | // Icon returns icon.svg 14 | func (h *Handler) Icon(c echo.Context) error { 15 | icon := c.Param("icon") 16 | svg, ok := h.Icons[icon] 17 | if !ok { 18 | return c.NoContent(404) 19 | } else { 20 | c.Response().Header().Set("Content-Type", "image/svg+xml") 21 | c.Response().Header().Set("Cache-Control", "max-age=7200, public") 22 | return c.String(200, string(svg.Origin)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /handler/icon_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/alicebob/miniredis" 12 | "github.com/labstack/echo/v4" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestHandler_IconAll(t *testing.T) { 17 | assert := assert.New(t) 18 | defer mockRedis.FlushAll() 19 | 20 | e := echo.New() 21 | s, err := miniredis.Run() 22 | assert.NoError(err) 23 | defer s.Close() 24 | 25 | h, err := NewHandler(s.Addr()) 26 | assert.NoError(err) 27 | 28 | tests := map[string]struct { 29 | }{ 30 | "success": {}, 31 | } 32 | 33 | for _, _ = range tests { 34 | r := httptest.NewRequest("GET", "http://localhost:8080/icon/all.json", nil) 35 | w := httptest.NewRecorder() 36 | ctx := &HitCounterContext{Context: e.NewContext(r, w)} 37 | err := h.IconAll(ctx) 38 | assert.NoError(err) 39 | 40 | resp := w.Result() 41 | assert.Equal(http.StatusOK, resp.StatusCode) 42 | raw, err := ioutil.ReadAll(resp.Body) 43 | assert.NoError(err) 44 | var rm []interface{} 45 | err = json.Unmarshal(raw, &rm) 46 | assert.NoError(err) 47 | assert.Len(rm, len(h.IconsList)) 48 | } 49 | 50 | } 51 | 52 | func TestHandler_Icon(t *testing.T) { 53 | assert := assert.New(t) 54 | defer mockRedis.FlushAll() 55 | 56 | e := echo.New() 57 | h, err := NewHandler(mockRedis.Addr()) 58 | assert.NoError(err) 59 | 60 | tests := map[string]struct { 61 | status int 62 | path string 63 | }{ 64 | "empty": { 65 | status: 404, 66 | path: "empty.svg", 67 | }, 68 | "success": { 69 | status: 200, 70 | path: "github.svg", 71 | }, 72 | } 73 | 74 | for _, t := range tests { 75 | r := httptest.NewRequest("GET", fmt.Sprintf("http://localhost:8080/icon/%s", t.path), nil) 76 | w := httptest.NewRecorder() 77 | ctx := &HitCounterContext{Context: e.NewContext(r, w)} 78 | ctx.SetParamNames("icon") 79 | ctx.SetParamValues(t.path) 80 | err := h.Icon(ctx) 81 | assert.NoError(err) 82 | 83 | resp := w.Result() 84 | 85 | assert.Equal(t.status, resp.StatusCode) 86 | if resp.StatusCode == 200 { 87 | raw, err := ioutil.ReadAll(resp.Body) 88 | assert.NoError(err) 89 | assert.Equal(string(h.Icons[t.path].Origin), string(raw)) 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /handler/index.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/labstack/echo/v4" 10 | ) 11 | 12 | // Index is API for main page. 13 | func (h *Handler) Index(c echo.Context) error { 14 | group := "github.com" 15 | scores, err := h.Counter.GetRankTotalByLimit(c.Request().Context(), group, 20) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | var ranks []string 21 | ranksMap := make(map[string]bool, 20) 22 | for _, score := range scores { 23 | if len(ranks) == 10 { 24 | break 25 | } 26 | 27 | path := strings.TrimSpace(score.Name) 28 | if strings.HasSuffix(path, "/") { 29 | path = path[:len(path)-1] 30 | } 31 | 32 | // add projects if score-name is /profile/project format 33 | seps := strings.Split(path, "/") 34 | if len(seps) == 3 && !ranksMap[path] { 35 | ranksMap[path] = true 36 | ranks = append(ranks, fmt.Sprintf("[%d] %s%s", len(ranks)+1, group, path)) 37 | } 38 | } 39 | 40 | buf := new(bytes.Buffer) 41 | h.IndexTemplate.Execute(buf, struct { 42 | Ranks []string 43 | }{Ranks: ranks}) 44 | return c.HTMLBlob(http.StatusOK, buf.Bytes()) 45 | } 46 | -------------------------------------------------------------------------------- /handler/index_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/labstack/echo/v4" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestHandler_Index(t *testing.T) { 16 | assert := assert.New(t) 17 | defer mockRedis.FlushAll() 18 | 19 | 20 | e := echo.New() 21 | h, err := NewHandler(mockRedis.Addr()) 22 | assert.NoError(err) 23 | 24 | ctx := context.Background() 25 | _, err = h.Counter.IncreaseRankOfTotal(ctx, "github.com", "/gjbae1212/hit-counter/") 26 | assert.NoError(err) 27 | _, err = h.Counter.IncreaseRankOfTotal(ctx, "github.com", "/gjbae1212/helloworld") 28 | assert.NoError(err) 29 | _, err = h.Counter.IncreaseRankOfTotal(ctx, "github.com", "/gjbae1212/power/dfdsfhtp(s///sdfsdf)") 30 | assert.NoError(err) 31 | 32 | tests := map[string]struct { 33 | included []string 34 | excluded []string 35 | }{ 36 | "sample": { 37 | included: []string{"github.com/gjbae1212/hit-counter", "github.com/gjbae1212/helloworld"}, 38 | excluded: []string{"github.com/gjbae1212/power"}, 39 | }, 40 | } 41 | 42 | for _, t := range tests { 43 | r := httptest.NewRequest("GET", "http://localhost:8080", nil) 44 | w := httptest.NewRecorder() 45 | hctx := &HitCounterContext{Context: e.NewContext(r, w)} 46 | 47 | err = h.Index(hctx) 48 | assert.NoError(err) 49 | 50 | resp := w.Result() 51 | assert.Equal(http.StatusOK, resp.StatusCode) 52 | raw, err := ioutil.ReadAll(resp.Body) 53 | assert.NoError(err) 54 | body := string(raw) 55 | 56 | for _, match := range t.included { 57 | assert.True(strings.Contains(body, match)) 58 | } 59 | 60 | for _, match := range t.excluded { 61 | assert.False(strings.Contains(body, match)) 62 | } 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /handler/wasm.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/gjbae1212/hit-counter/internal" 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | // Wasm is API for serving wasm file. 11 | func (h *Handler) Wasm(c echo.Context) error { 12 | hctx := c.(*HitCounterContext) 13 | hctx.Response().Header().Set("Content-Encoding", "gzip") 14 | return c.File(filepath.Join(internal.GetRoot(), "view", "hits.wasm")) 15 | } 16 | -------------------------------------------------------------------------------- /handler/wasm_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/labstack/echo/v4" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestHandler_Wasm(t *testing.T) { 13 | assert := assert.New(t) 14 | 15 | e := echo.New() 16 | h, err := NewHandler(mockRedis.Addr()) 17 | assert.NoError(err) 18 | 19 | r := httptest.NewRequest("GET", "http://localhost:8080", nil) 20 | w := httptest.NewRecorder() 21 | 22 | hctx := &HitCounterContext{Context: e.NewContext(r, w)} 23 | err = h.Wasm(hctx) 24 | assert.NoError(err) 25 | 26 | resp := w.Result() 27 | assert.Equal(http.StatusOK, resp.StatusCode) 28 | assert.Equal("application/wasm", resp.Header.Get("Content-Type")) 29 | assert.Equal("gzip", resp.Header.Get("Content-Encoding")) 30 | } 31 | -------------------------------------------------------------------------------- /handler/websocket.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | 6 | websocket "github.com/gjbae1212/go-ws-broadcast" 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | // WebSocket is API for websocket. 11 | func (h *Handler) WebSocket(c echo.Context) error { 12 | ws, err := websocket.Upgrader.Upgrade(c.Response(), c.Request(), nil) 13 | if err != nil { 14 | return fmt.Errorf("[err] WebSocket API %w", err) 15 | } 16 | 17 | // register websocket to breaker. 18 | if _, err := h.WebSocketBreaker.Register(ws); err != nil { 19 | return fmt.Errorf("[err] WebSocket API %w", err) 20 | } 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /handler/websocket_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | echo "github.com/labstack/echo/v4" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestHandler_WebSocket(t *testing.T) { 12 | assert := assert.New(t) 13 | defer mockRedis.FlushAll() 14 | 15 | e := echo.New() 16 | h, err := NewHandler(mockRedis.Addr()) 17 | assert.NoError(err) 18 | 19 | r := httptest.NewRequest("GET", "http://localhost:8080", nil) 20 | r.Header.Set("Connection", "upgrade") 21 | r.Header.Set("Upgrade", "websocket") 22 | r.Header.Set("Sec-Websocket-Version", "13") 23 | r.Header.Set("Sec-WebSocket-Key", "allan") 24 | w := httptest.NewRecorder() 25 | hctx := &HitCounterContext{Context: e.NewContext(r, w)} 26 | //err = h.WebSocket(hctx) 27 | _ = hctx 28 | _ = h 29 | } 30 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gjbae1212/hit-counter/31a6bf9ca90c0c52ffba70283b1de85bb111ed10/icon.png -------------------------------------------------------------------------------- /internal/badge.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "github.com/gjbae1212/go-counter-badge/badge" 4 | 5 | // GenerateBadge makes Flat-Badge struct which is used go-counter-badge/badge. 6 | func GenerateBadge(leftText, leftBgColor, rightText, rightBgColor string, edgeFlat bool) badge.Badge { 7 | flatBadge := badge.Badge{ 8 | FontType: badge.Verdana, 9 | LeftText: leftText, 10 | LeftTextColor: "#fff", 11 | LeftBackgroundColor: leftBgColor, 12 | RightText: rightText, 13 | RightTextColor: "#fff", 14 | RightBackgroundColor: rightBgColor, 15 | } 16 | if edgeFlat { 17 | flatBadge.XRadius = "0" 18 | flatBadge.YRadius = "0" 19 | } else { 20 | flatBadge.XRadius = "3" 21 | flatBadge.YRadius = "3" 22 | } 23 | return flatBadge 24 | } 25 | -------------------------------------------------------------------------------- /internal/badge_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/gjbae1212/go-counter-badge/badge" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestGenerateBadge(t *testing.T) { 13 | assert := assert.New(t) 14 | 15 | tests := map[string]struct { 16 | leftText string 17 | leftBgColor string 18 | rightText string 19 | rightBgColor string 20 | edgeFlat bool 21 | output badge.Badge 22 | }{ 23 | "not-edge": { 24 | leftText: "allan", 25 | leftBgColor: "#555", 26 | rightText: " 0 / 10 ", 27 | rightBgColor: "#79c83d", 28 | edgeFlat: false, 29 | output: badge.Badge{ 30 | FontType: badge.Verdana, 31 | LeftText: "allan", 32 | LeftTextColor: "#fff", 33 | LeftBackgroundColor: "#555", 34 | RightText: " 0 / 10 ", 35 | RightTextColor: "#fff", 36 | RightBackgroundColor: "#79c83d", 37 | XRadius: "3", 38 | YRadius: "3", 39 | }, 40 | }, 41 | "edge": { 42 | leftText: "allan", 43 | leftBgColor: "#555", 44 | rightText: " 0 / 10 ", 45 | rightBgColor: "#79c83d", 46 | edgeFlat: true, 47 | output: badge.Badge{ 48 | FontType: badge.Verdana, 49 | LeftText: "allan", 50 | LeftTextColor: "#fff", 51 | LeftBackgroundColor: "#555", 52 | RightText: " 0 / 10 ", 53 | RightTextColor: "#fff", 54 | RightBackgroundColor: "#79c83d", 55 | XRadius: "0", 56 | YRadius: "0", 57 | }, 58 | }, 59 | } 60 | 61 | for _, t := range tests { 62 | bg := GenerateBadge(t.leftText, t.leftBgColor, t.rightText, t.rightBgColor, t.edgeFlat) 63 | assert.True(reflect.DeepEqual(t.output, bg)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /internal/error.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrorEmptyParams = errors.New("[err] empty params") 7 | ) 8 | -------------------------------------------------------------------------------- /internal/logger.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/labstack/gommon/log" 8 | ) 9 | 10 | // NewLogger is to create a logger object for Echo platform. 11 | func NewLogger(dir, filename string) (*log.Logger, error) { 12 | logger := log.New("") 13 | logger.SetHeader("{\"time\":\"${time_rfc3339}\", \"level\":\"${level}\"}") 14 | fpath := filepath.Join(dir, filename) 15 | if fpath == "" { 16 | logger.SetOutput(os.Stdout) 17 | } else { 18 | f, err := os.OpenFile(fpath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 19 | if err != nil { 20 | return nil, err 21 | } 22 | logger.SetOutput(f) 23 | } 24 | return logger, nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/logger_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "path/filepath" 7 | "runtime" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestNewLogger(t *testing.T) { 14 | assert := assert.New(t) 15 | 16 | // if log-path don't exist, creating it. 17 | _, filename, _, ok := runtime.Caller(0) 18 | assert.True(ok) 19 | logPath := filepath.Join(path.Dir(filename), "../", "logs") 20 | if _, err := os.Stat(logPath); os.IsNotExist(err) { 21 | os.Mkdir(logPath, os.ModePerm) 22 | } 23 | 24 | tests := map[string]struct { 25 | inputs map[string]string 26 | isErr bool 27 | }{ 28 | "stdout": {inputs: map[string]string{"dir": "", "filename": ""}}, 29 | "file": {inputs: map[string]string{"dir": filepath.Join(path.Dir("test.log"), "../", "logs"), "filename": "test.log"}}, 30 | "error": {inputs: map[string]string{"dir": "../empty-folder", "filename": "test.log"}, isErr: true}, 31 | } 32 | 33 | for _, t := range tests { 34 | logger, err := NewLogger(t.inputs["dir"], t.inputs["filename"]) 35 | assert.Equal(t.isErr, err != nil) 36 | if err == nil { 37 | logger.Info("hello world") 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/sentry.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/getsentry/sentry-go" 7 | sentryecho "github.com/getsentry/sentry-go/echo" 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | var ( 12 | ErrSentryEmptyParam = errors.New("[err] sentry empty params") 13 | ) 14 | 15 | // InitSentry is to initialize Sentry setting. 16 | func InitSentry(sentryDSN, environment, release, hostname string, stack, debug bool) error { 17 | if sentryDSN == "" || environment == "" || release == "" || hostname == "" { 18 | return ErrSentryEmptyParam 19 | } 20 | 21 | // if debug is true, it could show detail stack-log. 22 | if err := sentry.Init(sentry.ClientOptions{ 23 | Dsn: sentryDSN, 24 | Environment: environment, 25 | Release: release, 26 | ServerName: hostname, 27 | AttachStacktrace: stack, 28 | Debug: debug, 29 | }); err != nil { 30 | return err 31 | } 32 | 33 | return nil 34 | } 35 | 36 | // Error sends an error to Sentry. 37 | func SentryError(err error) { 38 | if err == nil { 39 | return 40 | } 41 | sentry.CaptureException(err) 42 | } 43 | 44 | // ErrorWithEcho sends an error with the Echo of context information to the Sentry. 45 | func SentryErrorWithContext(err error, ctx echo.Context, info map[string]string) { 46 | if err == nil || ctx == nil { 47 | return 48 | } 49 | var id string 50 | if info != nil { 51 | id = info["id"] 52 | } 53 | if hub := sentryecho.GetHubFromContext(ctx); hub != nil { 54 | hub.WithScope(func(scope *sentry.Scope) { 55 | scope.SetUser(sentry.User{ID: id, IPAddress: ctx.RealIP()}) 56 | scope.SetFingerprint([]string{err.Error()}) 57 | hub.CaptureException(err) 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /internal/sentry_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/getsentry/sentry-go" 8 | "github.com/gjbae1212/hit-counter/env" 9 | "github.com/labstack/echo/v4" 10 | 11 | "fmt" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestInitSentry(t *testing.T) { 17 | assert := assert.New(t) 18 | 19 | tests := map[string]struct { 20 | inputSentryDSN string 21 | inputEnvironment string 22 | inputRelease string 23 | inputHostname string 24 | inputStack bool 25 | inputDebug bool 26 | outputError bool 27 | }{ 28 | "empty": {outputError: true}, 29 | "success": {inputSentryDSN: env.GetSentryDSN(), inputEnvironment: "local", inputRelease: "test", inputHostname: "localhost"}, 30 | } 31 | 32 | for _, t := range tests { 33 | //err := InitSentry(t.inputSentryDSN, t.inputEnvironment, t.inputRelease, t.inputHostname, false, false) 34 | //assert.Equal(t.outputError, err != nil) 35 | _ = t 36 | _ = assert 37 | } 38 | } 39 | 40 | func TestSentryError(t *testing.T) { 41 | assert := assert.New(t) 42 | 43 | InitSentry(env.GetSentryDSN(), "local", "test", "localhost", true, true) 44 | 45 | tests := map[string]struct { 46 | inputErr error 47 | }{ 48 | "success": {inputErr: fmt.Errorf("[err] test default")}, 49 | } 50 | 51 | for _, t := range tests { 52 | SentryError(t.inputErr) 53 | sentry.Flush(5 * time.Second) 54 | } 55 | time.Sleep(2 * time.Second) 56 | _ = assert 57 | } 58 | 59 | func TestSentryErrorWithContext(t *testing.T) { 60 | assert := assert.New(t) 61 | 62 | InitSentry(env.GetSentryDSN(), "local", "test", "localhost", true, true) 63 | 64 | e := echo.New() 65 | 66 | tests := map[string]struct { 67 | inputErr error 68 | inputContext echo.Context 69 | }{ 70 | "success": {inputErr: fmt.Errorf("[err] test context"), inputContext: e.NewContext(nil, nil)}, 71 | } 72 | 73 | for _, t := range tests { 74 | SentryErrorWithContext(t.inputErr, t.inputContext, nil) 75 | sentry.Flush(5 * time.Second) 76 | } 77 | time.Sleep(2 * time.Second) 78 | _ = assert 79 | } 80 | -------------------------------------------------------------------------------- /internal/time.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "time" 4 | 5 | var ( 6 | maxTime = time.Unix(0, (1<<63)-1) 7 | utcLayout = "2006-01-02 15:04:05" 8 | yearlyLayout = "2006" 9 | monthlyLayout = "200601" 10 | dailyLayout = "20060102" 11 | hourlyLayout = "2006010215" 12 | ) 13 | 14 | // TimestampByMaxTime returns max unix nano. 15 | func TimestampByMaxTime() int64 { 16 | return maxTime.UnixNano() 17 | } 18 | 19 | // StringToTime converts string to time. 20 | func StringToTime(s string) time.Time { 21 | t, err := time.Parse(utcLayout, s) 22 | if err != nil { 23 | return time.Time{} 24 | } 25 | return t 26 | } 27 | 28 | // TimeToString converts time to string. 29 | func TimeToString(t time.Time) string { 30 | return t.Format(utcLayout) 31 | } 32 | 33 | // YearlyStringToTime converts string formatted yearly layout to time. 34 | func YearlyStringToTime(s string) time.Time { 35 | t, err := time.Parse(yearlyLayout, s) 36 | if err != nil { 37 | return time.Time{} 38 | } 39 | return t 40 | } 41 | 42 | 43 | // TimeToYearlyStringFormat converts time to string formatted yearly layout. 44 | func TimeToYearlyStringFormat(t time.Time) string { 45 | return t.Format(yearlyLayout) 46 | } 47 | 48 | // MonthlyStringToTime converts string formatted monthly layout to time. 49 | func MonthlyStringToTime(s string) time.Time { 50 | t, err := time.Parse(monthlyLayout, s) 51 | if err != nil { 52 | return time.Time{} 53 | } 54 | return t 55 | } 56 | 57 | // TimeToMonthlyStringFormat converts time to string formatted monthly layout. 58 | func TimeToMonthlyStringFormat(t time.Time) string { 59 | return t.Format(monthlyLayout) 60 | } 61 | 62 | // DailyStringToTime converts string formatted daily layout to time. 63 | func DailyStringToTime(s string) time.Time { 64 | t, err := time.Parse(dailyLayout, s) 65 | if err != nil { 66 | return time.Time{} 67 | } 68 | return t 69 | } 70 | 71 | // TimeToDailyStringFormat converts time to string formatted daily layout 72 | func TimeToDailyStringFormat(t time.Time) string { 73 | return t.Format(dailyLayout) 74 | } 75 | 76 | 77 | // HourlyStringToTime converts string formatted hourly layout to time. 78 | func HourlyStringToTime(s string) time.Time { 79 | t, err := time.Parse(hourlyLayout, s) 80 | if err != nil { 81 | return time.Time{} 82 | } 83 | return t 84 | } 85 | 86 | // TimeToHourlyStringFormat converts time to string formatted hourly layout 87 | func TimeToHourlyStringFormat(t time.Time) string { 88 | return t.Format(hourlyLayout) 89 | } 90 | -------------------------------------------------------------------------------- /internal/time_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestTime(t *testing.T) { 12 | assert := assert.New(t) 13 | 14 | tt := time.Now() 15 | ss := TimeToString(tt) 16 | compare1 := fmt.Sprintf("%d-%02d-%02d %02d:%02d:%02d", tt.Year(), tt.Month(), tt.Day(), tt.Hour(), tt.Minute(), tt.Second()) 17 | assert.Equal(compare1, ss) 18 | 19 | tt2 := StringToTime(ss) 20 | assert.Equal(tt.Year(), tt2.Year()) 21 | assert.Equal(tt.Month(), tt2.Month()) 22 | assert.Equal(tt.Day(), tt2.Day()) 23 | assert.Equal(tt.Hour(), tt2.Hour()) 24 | assert.Equal(tt.Minute(), tt2.Minute()) 25 | assert.Equal(tt.Second(), tt2.Second()) 26 | 27 | ss2 := TimeToDailyStringFormat(tt) 28 | compare2 := fmt.Sprintf("%d%02d%02d", tt.Year(), tt.Month(), tt.Day()) 29 | assert.Equal(compare2, ss2) 30 | 31 | tt3 := DailyStringToTime(ss2) 32 | assert.Equal(tt.Year(), tt3.Year()) 33 | assert.Equal(tt.Month(), tt3.Month()) 34 | assert.Equal(tt.Day(), tt3.Day()) 35 | assert.Equal(0, tt3.Hour()) 36 | assert.Equal(0, tt3.Minute()) 37 | assert.Equal(0, tt3.Second()) 38 | 39 | ss3 := TimeToHourlyStringFormat(tt) 40 | compare3 := fmt.Sprintf("%d%02d%02d%02d", tt.Year(), tt.Month(), tt.Day(), tt.Hour()) 41 | assert.Equal(compare3, ss3) 42 | 43 | tt4 := HourlyStringToTime(ss3) 44 | assert.Equal(tt.Year(), tt4.Year()) 45 | assert.Equal(tt.Month(), tt4.Month()) 46 | assert.Equal(tt.Day(), tt4.Day()) 47 | assert.Equal(tt.Hour(), tt4.Hour()) 48 | assert.Equal(0, tt4.Minute()) 49 | assert.Equal(0, tt4.Second()) 50 | 51 | ss4 := TimeToMonthlyStringFormat(tt) 52 | compare4 := fmt.Sprintf("%d%02d", tt.Year(), tt.Month()) 53 | assert.Equal(compare4, ss4) 54 | 55 | tt5 := MonthlyStringToTime(ss4) 56 | assert.Equal(tt.Year(), tt5.Year()) 57 | assert.Equal(tt.Month(), tt5.Month()) 58 | assert.Equal(1, tt5.Day()) 59 | assert.Equal(0, tt5.Hour()) 60 | assert.Equal(0, tt5.Minute()) 61 | assert.Equal(0, tt5.Second()) 62 | 63 | ss5 := TimeToYearlyStringFormat(time.Now()) 64 | compare5 := fmt.Sprintf("%d", time.Now().Year()) 65 | assert.Equal(compare5, ss5) 66 | 67 | tt6 := YearlyStringToTime(ss5) 68 | assert.Equal(time.Now().Year(), tt6.Year()) 69 | assert.Equal(time.January, tt6.Month()) 70 | assert.Equal(1, tt6.Day()) 71 | assert.Equal(0, tt6.Hour()) 72 | assert.Equal(0, tt6.Minute()) 73 | assert.Equal(0, tt6.Second()) 74 | } 75 | -------------------------------------------------------------------------------- /internal/util.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "path" 7 | "path/filepath" 8 | "runtime" 9 | 10 | "github.com/goware/urlx" 11 | ) 12 | 13 | var ( 14 | root string 15 | ) 16 | 17 | func init() { 18 | _, filename, _, ok := runtime.Caller(0) 19 | if !ok { 20 | log.Panic("No caller information") 21 | } 22 | root = filepath.Join(path.Dir(filename), "../") 23 | } 24 | 25 | // GetRoot returns root path. 26 | func GetRoot() string { 27 | return root 28 | } 29 | 30 | // StringInSlice checks a string element is included to string array. 31 | func StringInSlice(str string, list []string) bool { 32 | for _, v := range list { 33 | if v == str { 34 | return true 35 | } 36 | } 37 | return false 38 | } 39 | 40 | // ParseURL parses url. 41 | func ParseURL(s string) (schema, host, port, path, query, fragment string, err error) { 42 | if s == "" { 43 | err = fmt.Errorf("[err] ParseURL %w", ErrorEmptyParams) 44 | } 45 | 46 | url, suberr := urlx.Parse(s) 47 | if suberr != nil { 48 | err = suberr 49 | return 50 | } 51 | 52 | schema = url.Scheme 53 | 54 | host, port, err = urlx.SplitHostPort(url) 55 | if err != nil { 56 | return 57 | } 58 | if schema == "http" && port == "" { 59 | port = "80" 60 | } else if schema == "https" && port == "" { 61 | port = "443" 62 | } 63 | 64 | path = url.Path 65 | query = url.RawQuery 66 | fragment = url.Fragment 67 | return 68 | } 69 | -------------------------------------------------------------------------------- /internal/util_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetRoot(t *testing.T) { 10 | assert := assert.New(t) 11 | assert.NotEmpty(GetRoot()) 12 | } 13 | 14 | func TestStringInSlice(t *testing.T) { 15 | assert := assert.New(t) 16 | 17 | tests := map[string]struct { 18 | str string 19 | list []string 20 | ok bool 21 | }{ 22 | "false": {str: "d", list: []string{"a", "b", "c"}, ok: false}, 23 | "true": {str: "b", list: []string{"a", "b", "c"}, ok: true}, 24 | } 25 | 26 | for _, t := range tests { 27 | assert.Equal(StringInSlice(t.str, t.list), t.ok) 28 | } 29 | } 30 | 31 | func TestParseURL(t *testing.T) { 32 | assert := assert.New(t) 33 | 34 | tests := map[string]struct { 35 | input string 36 | schema string 37 | domain string 38 | port string 39 | path string 40 | query string 41 | fragment string 42 | }{ 43 | "sample1": { 44 | input: "http://naver.com/aa/bb?cc=dd&ee=ff#fragment", schema: "http", domain: "naver.com", port: "80", 45 | path: "/aa/bb", query: "cc=dd&ee=ff", fragment: "fragment", 46 | }, 47 | "sample2": { 48 | input: "cc.com:8080/aa/bb", schema: "http", domain: "cc.com", port: "8080", path: "/aa/bb", query: "", fragment: "", 49 | }, 50 | "sample3": { 51 | input: "https://naver.com/aa/bb?cc=dd&ee=ff#fragment", schema: "https", domain: "naver.com", port: "443", 52 | path: "/aa/bb", query: "cc=dd&ee=ff", fragment: "fragment", 53 | }, 54 | } 55 | 56 | for _, t := range tests { 57 | schema, domain, port, path, query, fragment, err := ParseURL(t.input) 58 | assert.NoError(err) 59 | assert.Equal(t.schema, schema) 60 | assert.Equal(t.domain, domain) 61 | assert.Equal(t.port, port) 62 | assert.Equal(t.path, path) 63 | assert.Equal(t.query, query) 64 | assert.Equal(t.fragment, fragment) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main // import "github.com/gjbae1212/hit-counter" 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "runtime" 8 | 9 | "github.com/gjbae1212/hit-counter/internal" 10 | 11 | "path/filepath" 12 | 13 | "github.com/gjbae1212/hit-counter/env" 14 | "github.com/labstack/echo/v4" 15 | ) 16 | 17 | var ( 18 | address = flag.String("addr", ":8080", "address") 19 | tls = flag.Bool("tls", false, "tls") 20 | ) 21 | 22 | func main() { 23 | flag.Parse() 24 | 25 | runtime.GOMAXPROCS(runtime.NumCPU()) 26 | 27 | // initialize sentry 28 | name, _ := os.Hostname() 29 | if err := internal.InitSentry(env.GetSentryDSN(), env.GetPhase(), env.GetPhase(), 30 | name, true, env.GetDebug()); err != nil { 31 | log.Println(err) 32 | } 33 | 34 | e := echo.New() 35 | 36 | // make options for echo server. 37 | var opts []Option 38 | 39 | // debug option 40 | opts = append(opts, WithDebugOption(env.GetDebug())) 41 | 42 | var dir string 43 | var file string 44 | if env.GetLogPath() != "" { 45 | dir, file = filepath.Split(env.GetLogPath()) 46 | } 47 | 48 | // logger option 49 | logger, err := internal.NewLogger(dir, file) 50 | if err != nil { 51 | log.Panic(err) 52 | } 53 | opts = append(opts, WithLogger(logger)) 54 | 55 | // add middleware 56 | if err := AddMiddleware(e, opts...); err != nil { 57 | log.Panic(err) 58 | } 59 | 60 | // add route 61 | if err := AddRoute(e, env.GetRedisAddrs()[0]); err != nil { 62 | log.Panic(err) 63 | } 64 | 65 | if *tls { 66 | // start TLS server with let's encrypt certification. 67 | e.StartAutoTLS(*address) 68 | } else { 69 | e.Start(*address) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | "time" 10 | 11 | sentryecho "github.com/getsentry/sentry-go/echo" 12 | "github.com/gjbae1212/hit-counter/env" 13 | "github.com/gjbae1212/hit-counter/handler" 14 | "github.com/gjbae1212/hit-counter/internal" 15 | "github.com/labstack/echo/v4" 16 | "github.com/labstack/echo/v4/middleware" 17 | ) 18 | 19 | // AddMiddleware adds middlewares to echo server. 20 | func AddMiddleware(e *echo.Echo, opts ...Option) error { 21 | if e == nil { 22 | return fmt.Errorf("[err] AddMiddleware %w", internal.ErrorEmptyParams) 23 | } 24 | 25 | o := []Option{WithDebugOption(true)} 26 | o = append(o, opts...) 27 | for _, opt := range o { 28 | opt.apply(e) 29 | } 30 | 31 | e.HideBanner = true 32 | e.HidePort = true 33 | 34 | // read timeout will wait until read to request body 35 | e.Server.ReadTimeout = 10 * time.Second 36 | 37 | // write timeout will wait until from read request body to write response 38 | e.Server.WriteTimeout = 10 * time.Second 39 | 40 | // pre chain middleware 41 | prechain, err := middlewarePreChain() 42 | if err != nil { 43 | return err 44 | } 45 | e.Use(prechain...) 46 | 47 | // main chain middleware 48 | mainchain, err := middlewareChain() 49 | if err != nil { 50 | return err 51 | } 52 | e.Use(mainchain...) 53 | return nil 54 | } 55 | 56 | func middlewarePreChain() ([]echo.MiddlewareFunc, error) { 57 | var chain []echo.MiddlewareFunc 58 | 59 | // set sentry middleware. if this middleware will catch a panic error, delivering it to upper middleware. 60 | chain = append(chain, sentryecho.New(sentryecho.Options{Repanic: true})) 61 | 62 | // custom context 63 | if env.GetForceHTTPS() { 64 | // Apply HSTS 65 | chain = append(chain, func(h echo.HandlerFunc) echo.HandlerFunc { 66 | return func(c echo.Context) error { 67 | c.Response().Header().Set("Strict-Transport-Security", 68 | "max-age=2592000; includeSubdomains; preload") 69 | return h(c) 70 | } 71 | }) 72 | // Redirect Https 73 | chain = append(chain, middleware.HTTPSRedirect()) 74 | } 75 | chain = append(chain, middleware.RemoveTrailingSlash()) 76 | chain = append(chain, middleware.NonWWWRedirect()) 77 | chain = append(chain, middleware.Rewrite(map[string]string{ 78 | "/static/*": "/public/$1", 79 | })) 80 | 81 | // Add custom context 82 | chain = append(chain, func(h echo.HandlerFunc) echo.HandlerFunc { 83 | return func(c echo.Context) error { 84 | hitctx := &handler.HitCounterContext{c} 85 | 86 | // set start time 87 | hitctx.WithContext("start_time", time.Now()) 88 | 89 | // set deadline 90 | timeout := 15 * time.Second 91 | 92 | ctx, cancel := context.WithTimeout(hitctx.GetContext(), timeout) 93 | defer cancel() 94 | hitctx.SetContext(ctx) 95 | 96 | // set log 97 | extraLog := hitctx.ExtraLog() 98 | hitctx.WithContext("extra_log", extraLog) 99 | return h(hitctx) 100 | } 101 | }) 102 | 103 | // Add cookie duration 24 hour. 104 | chain = append(chain, func(h echo.HandlerFunc) echo.HandlerFunc { 105 | return func(c echo.Context) error { 106 | hitctx := c.(*handler.HitCounterContext) 107 | var err error 108 | cookie := &http.Cookie{} 109 | if cookie, err = c.Cookie("ckid"); err != nil { 110 | v := fmt.Sprintf("%s-%d", c.RealIP(), time.Now().UnixNano()) 111 | b64 := base64.StdEncoding.EncodeToString([]byte(v)) 112 | cookie = &http.Cookie{ 113 | Name: "ckid", 114 | Value: b64, 115 | Expires: time.Now().Add(24 * time.Hour), 116 | Path: "/", 117 | HttpOnly: true, 118 | Secure: true, 119 | SameSite: http.SameSiteNoneMode, 120 | } 121 | hitctx.SetCookie(cookie) 122 | } 123 | hitctx.Set(cookie.Name, cookie.Value) 124 | return h(hitctx) 125 | } 126 | }) 127 | return chain, nil 128 | } 129 | 130 | func middlewareChain() ([]echo.MiddlewareFunc, error) { 131 | var chain []echo.MiddlewareFunc 132 | 133 | // main middleware 134 | m := func(h echo.HandlerFunc) echo.HandlerFunc { 135 | return func(c echo.Context) error { 136 | // recover 137 | defer func() { 138 | if r := recover(); r != nil { 139 | // send sentry 140 | internal.SentryErrorWithContext(r.(error), c, nil) 141 | 142 | extraLog := c.(*handler.HitCounterContext).ValueContext("extra_log").(map[string]interface{}) 143 | extraLog["status"] = http.StatusInternalServerError 144 | extraLog["error"] = fmt.Sprintf("%v\n", r) 145 | c.Logger().Errorj(extraLog) 146 | // send 500 error 147 | c.NoContent(http.StatusInternalServerError) 148 | } 149 | }() 150 | 151 | hitctx := c.(*handler.HitCounterContext) 152 | start := hitctx.ValueContext("start_time").(time.Time) 153 | 154 | // main handler process 155 | err := h(hitctx) 156 | stop := time.Now() 157 | if err != nil { 158 | code := http.StatusInternalServerError 159 | if he, ok := err.(*echo.HTTPError); ok { 160 | code = he.Code 161 | } else if hitctx.Response().Status >= 400 { 162 | code = hitctx.Response().Status 163 | } 164 | 165 | extraLog := hitctx.ValueContext("extra_log").(map[string]interface{}) 166 | extraLog["status"] = code 167 | extraLog["error"] = fmt.Sprintf("%v\n", err) 168 | if code >= http.StatusInternalServerError { 169 | // send sentry 170 | internal.SentryErrorWithContext(err, c, nil) 171 | 172 | rest := stop.Sub(start) 173 | extraLog["latency"] = strconv.FormatInt(int64(rest), 10) 174 | extraLog["latency_human"] = rest.String() 175 | } 176 | hitctx.Logger().Errorj(extraLog) 177 | return err 178 | } 179 | extraLog := hitctx.ValueContext("extra_log").(map[string]interface{}) 180 | if extraLog["uri"] != "/healthcheck" { 181 | extraLog["status"] = hitctx.Response().Status 182 | rest := stop.Sub(start) 183 | extraLog["latency"] = strconv.FormatInt(int64(rest), 10) 184 | extraLog["latency_human"] = rest.String() 185 | if env.GetPhase() == "local" { 186 | hitctx.Logger().Infoj(extraLog) 187 | } 188 | } 189 | return nil 190 | } 191 | } 192 | chain = append(chain, m) 193 | return chain, nil 194 | } 195 | -------------------------------------------------------------------------------- /middleware_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gjbae1212/hit-counter/internal" 7 | "github.com/labstack/echo/v4" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestAddMiddleware(t *testing.T) { 12 | assert := assert.New(t) 13 | 14 | err := AddMiddleware(nil) 15 | assert.Error(err) 16 | 17 | e := echo.New() 18 | err = AddMiddleware(e, WithDebugOption(false)) 19 | assert.NoError(err) 20 | assert.False(e.Debug) 21 | 22 | clogger, err := internal.NewLogger("", "") 23 | assert.NoError(err) 24 | 25 | err = AddMiddleware(e, WithLogger(clogger)) 26 | assert.NoError(err) 27 | assert.True(e.Debug) 28 | assert.Equal(clogger, e.Logger) 29 | } 30 | -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | glog "github.com/labstack/gommon/log" 6 | ) 7 | 8 | // Option is an interface for dependency injection in Echo. 9 | type Option interface { 10 | apply(e *echo.Echo) 11 | } 12 | 13 | type OptionFunc func(e *echo.Echo) 14 | 15 | func (f OptionFunc) apply(e *echo.Echo) { f(e) } 16 | 17 | // WithDebugOption returns a function which sets debug variable in echo server. 18 | func WithDebugOption(debug bool) OptionFunc { 19 | return func(e *echo.Echo) { 20 | e.Debug = debug 21 | } 22 | } 23 | 24 | // WithLogger returns a function which sets logger variable in echo server. 25 | func WithLogger(logger *glog.Logger) OptionFunc { 26 | return func(e *echo.Echo) { 27 | e.Logger = logger 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /option_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | glog "github.com/labstack/gommon/log" 7 | 8 | "github.com/labstack/echo/v4" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestWithDebugOption(t *testing.T) { 13 | assert := assert.New(t) 14 | e := echo.New() 15 | 16 | tests := map[string]struct { 17 | input bool 18 | output bool 19 | }{ 20 | "false": {}, 21 | "true": {}, 22 | } 23 | 24 | for _, t := range tests { 25 | opt := WithDebugOption(t.input) 26 | opt.apply(e) 27 | assert.Equal(e.Debug, t.output) 28 | } 29 | } 30 | 31 | func TestWithLogger(t *testing.T) { 32 | assert := assert.New(t) 33 | e := echo.New() 34 | 35 | tests := map[string]struct { 36 | input *glog.Logger 37 | output *glog.Logger 38 | }{ 39 | "nil": {input: nil, output: nil}, 40 | "exist": {}, 41 | } 42 | 43 | for _, t := range tests { 44 | opt := WithLogger(t.input) 45 | opt.apply(e) 46 | assert.Equal(e.Logger, t.input) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /public/background.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /public/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gjbae1212/hit-counter/31a6bf9ca90c0c52ffba70283b1de85bb111ed10/public/favicon.ico -------------------------------------------------------------------------------- /public/hits-logo-primary.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/hits-logo-secondary.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gjbae1212/hit-counter/31a6bf9ca90c0c52ffba70283b1de85bb111ed10/public/icon.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | Disallow: /api/ -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | /* Common Start */ 2 | .text-align-right{ 3 | text-align: right; 4 | } 5 | 6 | .hidden{ 7 | display: none !important; 8 | } 9 | 10 | .text-secondary-light{ 11 | color: #6f7d8c; 12 | } 13 | 14 | .section{ 15 | padding: 6rem 0 9rem; 16 | } 17 | /* Common End */ 18 | 19 | .flex-cetner{ 20 | display: flex; 21 | flex-wrap: wrap; 22 | align-items: center; 23 | justify-content: center; 24 | } 25 | 26 | .navbar{ 27 | background: #0b1219; 28 | } 29 | 30 | .img_background{ 31 | background-repeat: no-repeat; 32 | background-size: contain; 33 | background-position: center; 34 | } 35 | 36 | .main-logo{ 37 | width: 200px; 38 | height: 200px; 39 | background: white; 40 | margin: 0 auto 30px; 41 | background-image: url(/hits-logo-primary.svg); 42 | } 43 | 44 | .navbar-brand{ 45 | width: 60px; 46 | height: 60px; 47 | background-image: url(/hits-logo-secondary.svg); 48 | } 49 | 50 | .main-title-logo{ 51 | vertical-align: baseline; 52 | height: 150px; 53 | margin-bottom: -30px; 54 | } 55 | 56 | .title-icon { 57 | font-size: 60px; 58 | margin-left: 10px; 59 | } 60 | 61 | .subtitle { 62 | color: #ddd; 63 | line-height: 1.6; 64 | } 65 | 66 | @media (max-width: 768px) { 67 | .container.container-mobile-fill{ 68 | max-width: 100%; 69 | } 70 | } 71 | 72 | @media (min-width: 576px){ 73 | .show-rank-content, .stream-view-content{ 74 | border-radius: 0.4rem; 75 | box-shadow: 0px 11px 25px 0px rgba(0, 0, 0, 0.3); 76 | } 77 | } 78 | 79 | .url-label{ 80 | color: var(--blue); 81 | font-size: 1.2rem; 82 | } 83 | 84 | .url-input{ 85 | font-size: 1.5rem; 86 | } 87 | 88 | .url-small{ 89 | font-size: 100%; 90 | } 91 | 92 | .copy-button{ 93 | position: absolute; 94 | right: 1rem; 95 | bottom: 1rem; 96 | background-color: var(--blue); 97 | color: #fff; 98 | font-size: 0.7rem; 99 | } 100 | 101 | .text-box{ 102 | padding: 20px 40px 40px 20px; 103 | color: #fff; 104 | word-break: break-all; 105 | } 106 | 107 | .text-box-container{ 108 | position: relative; 109 | background-color: #222; 110 | box-shadow: 12px 12px 2px 1px rgba(0, 0, 0, .2); 111 | } 112 | 113 | #stream_view{ 114 | max-height: 500px; 115 | overflow: auto; 116 | } 117 | 118 | #history_view{ 119 | min-height: 350px; 120 | position: relative; 121 | padding: 35px 10px; 122 | display: flex; 123 | justify-content: center; 124 | align-items: center; 125 | border: 1px solid #bbb; 126 | border-radius: 5px; 127 | } 128 | 129 | .gradient-bg-primary{ 130 | background: url(./background.svg) center/cover no-repeat ; 131 | } 132 | 133 | .secondary-bg{ 134 | background: #E8ECF7; 135 | } 136 | 137 | .wating-graph{ 138 | opacity: 0; 139 | position: absolute; 140 | transition: opacity 300ms; 141 | display:flex; 142 | justify-content: center; 143 | align-items: center; 144 | height: 100%; 145 | width: 100%; 146 | color: #888; 147 | } 148 | 149 | .wating-graph b{ 150 | margin-left: 0.5rem; 151 | } 152 | 153 | .wating-graph:only-child{ 154 | opacity: 1; 155 | } 156 | 157 | #rank_view{ 158 | color: black; 159 | word-break: break-all; 160 | } 161 | 162 | .rank-subtitle{ 163 | font-weight: normal; 164 | color: #777; 165 | } 166 | 167 | .rank-subtitle b{ 168 | letter-spacing: 0.15rem; 169 | } 170 | 171 | .show-rank-content, .stream-view-content{ 172 | padding: 2rem; 173 | background-color: rgba(255, 255, 255, .9); 174 | min-height: 6rem; 175 | color: black; 176 | } 177 | 178 | /* Badge Start */ 179 | 180 | .badge-options-title{ 181 | line-height: 100%; 182 | } 183 | 184 | .icon-selector{ 185 | position: absolute; 186 | display: flex; 187 | flex-direction: column; 188 | top: calc(100% + 5px); 189 | width: 260px; 190 | height: 400px; 191 | z-index: 1; 192 | background: #fff; 193 | border-radius: 5px; 194 | } 195 | 196 | .icon-selector .icon-search-input-container{ 197 | padding: 10px; 198 | } 199 | 200 | .icon-search-list{ 201 | flex-grow: 1; 202 | overflow-y: auto; 203 | padding: 0; 204 | margin: 0; 205 | } 206 | 207 | .icon-search-item{ 208 | display:inline-block; 209 | width: 25%; 210 | height: 62px; 211 | padding: 5px; 212 | cursor: pointer; 213 | } 214 | 215 | .icon-search-item:hover{ 216 | background: #eee; 217 | } 218 | 219 | @media (max-width: 576px){ 220 | .icon-selector-container { 221 | order: -1; 222 | } 223 | } 224 | 225 | /* Badge End */ 226 | 227 | /* History Start */ 228 | 229 | .graph_img{ 230 | width: 100%; 231 | max-width: 800px; 232 | } 233 | 234 | /* History End */ 235 | 236 | /* Notice Start */ 237 | .notice{ 238 | position: fixed; 239 | bottom: 10px; 240 | left: 50%; 241 | transform: translate3D(-50%, 0, 0); 242 | width: 80%; 243 | max-width: 400px; 244 | background-color: white; 245 | z-index: 1; 246 | border-radius: 20px; 247 | box-shadow: 0px 11px 25px 0px rgba(0, 0, 0, 0.7); 248 | padding: 15px 25px; 249 | transition: transform 300ms; 250 | } 251 | 252 | .notice.notice-slide-down{ 253 | transform: translate3d(-50%, calc(100% + 10px), 0); 254 | } 255 | 256 | .notice .title{ 257 | font-size: 23px; 258 | color: #ff5e5e; 259 | font-weight: bold; 260 | } 261 | 262 | .notice p{ 263 | white-space: pre-line; 264 | font-size: 17px; 265 | margin: 10px 0 0; 266 | } 267 | 268 | .close_btn{ 269 | position:absolute; 270 | width: 28px; 271 | height: 28px; 272 | top: 14px; 273 | right: 10px; 274 | background: url(./close.svg); 275 | background-repeat: no-repeat; 276 | background-position: center; 277 | background-size: 100% 100%; 278 | border: none; 279 | } 280 | 281 | @media (min-width: 767px) { 282 | .notice{ 283 | left: unset; 284 | right: 10px; 285 | transform: none; 286 | } 287 | 288 | .notice.notice-slide-down{ 289 | transform: translate3d(0, calc(100% + 10px), 0); 290 | } 291 | } 292 | 293 | a.notice_btn{ 294 | display:inline-flex; 295 | border: none; 296 | background: none; 297 | color: #047bfe !important; 298 | font-size: 15px; 299 | padding: 0; 300 | margin-top: 9px; 301 | cursor: pointer; 302 | align-items: center; 303 | } 304 | 305 | a.notice_btn:hover{ 306 | text-decoration: none; 307 | color: #00356f !important; 308 | } 309 | 310 | .notice .fa-github{ 311 | margin-left: 5px; 312 | font-size: 24px; 313 | } 314 | 315 | /* Notice End */ -------------------------------------------------------------------------------- /public/wasm_exec.js: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | (() => { 6 | // Map multiple JavaScript environments to a single common API, 7 | // preferring web standards over Node.js API. 8 | // 9 | // Environments considered: 10 | // - Browsers 11 | // - Node.js 12 | // - Electron 13 | // - Parcel 14 | // - Webpack 15 | 16 | if (typeof global !== "undefined") { 17 | // global already exists 18 | } else if (typeof window !== "undefined") { 19 | window.global = window; 20 | } else if (typeof self !== "undefined") { 21 | self.global = self; 22 | } else { 23 | throw new Error("cannot export Go (neither global, window nor self is defined)"); 24 | } 25 | 26 | if (!global.require && typeof require !== "undefined") { 27 | global.require = require; 28 | } 29 | 30 | if (!global.fs && global.require) { 31 | const fs = require("fs"); 32 | if (typeof fs === "object" && fs !== null && Object.keys(fs).length !== 0) { 33 | global.fs = fs; 34 | } 35 | } 36 | 37 | const enosys = () => { 38 | const err = new Error("not implemented"); 39 | err.code = "ENOSYS"; 40 | return err; 41 | }; 42 | 43 | if (!global.fs) { 44 | let outputBuf = ""; 45 | global.fs = { 46 | constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused 47 | writeSync(fd, buf) { 48 | outputBuf += decoder.decode(buf); 49 | const nl = outputBuf.lastIndexOf("\n"); 50 | if (nl != -1) { 51 | console.log(outputBuf.substr(0, nl)); 52 | outputBuf = outputBuf.substr(nl + 1); 53 | } 54 | return buf.length; 55 | }, 56 | write(fd, buf, offset, length, position, callback) { 57 | if (offset !== 0 || length !== buf.length || position !== null) { 58 | callback(enosys()); 59 | return; 60 | } 61 | const n = this.writeSync(fd, buf); 62 | callback(null, n); 63 | }, 64 | chmod(path, mode, callback) { callback(enosys()); }, 65 | chown(path, uid, gid, callback) { callback(enosys()); }, 66 | close(fd, callback) { callback(enosys()); }, 67 | fchmod(fd, mode, callback) { callback(enosys()); }, 68 | fchown(fd, uid, gid, callback) { callback(enosys()); }, 69 | fstat(fd, callback) { callback(enosys()); }, 70 | fsync(fd, callback) { callback(null); }, 71 | ftruncate(fd, length, callback) { callback(enosys()); }, 72 | lchown(path, uid, gid, callback) { callback(enosys()); }, 73 | link(path, link, callback) { callback(enosys()); }, 74 | lstat(path, callback) { callback(enosys()); }, 75 | mkdir(path, perm, callback) { callback(enosys()); }, 76 | open(path, flags, mode, callback) { callback(enosys()); }, 77 | read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, 78 | readdir(path, callback) { callback(enosys()); }, 79 | readlink(path, callback) { callback(enosys()); }, 80 | rename(from, to, callback) { callback(enosys()); }, 81 | rmdir(path, callback) { callback(enosys()); }, 82 | stat(path, callback) { callback(enosys()); }, 83 | symlink(path, link, callback) { callback(enosys()); }, 84 | truncate(path, length, callback) { callback(enosys()); }, 85 | unlink(path, callback) { callback(enosys()); }, 86 | utimes(path, atime, mtime, callback) { callback(enosys()); }, 87 | }; 88 | } 89 | 90 | if (!global.process) { 91 | global.process = { 92 | getuid() { return -1; }, 93 | getgid() { return -1; }, 94 | geteuid() { return -1; }, 95 | getegid() { return -1; }, 96 | getgroups() { throw enosys(); }, 97 | pid: -1, 98 | ppid: -1, 99 | umask() { throw enosys(); }, 100 | cwd() { throw enosys(); }, 101 | chdir() { throw enosys(); }, 102 | } 103 | } 104 | 105 | if (!global.crypto && global.require) { 106 | const nodeCrypto = require("crypto"); 107 | global.crypto = { 108 | getRandomValues(b) { 109 | nodeCrypto.randomFillSync(b); 110 | }, 111 | }; 112 | } 113 | if (!global.crypto) { 114 | throw new Error("global.crypto is not available, polyfill required (getRandomValues only)"); 115 | } 116 | 117 | if (!global.performance) { 118 | global.performance = { 119 | now() { 120 | const [sec, nsec] = process.hrtime(); 121 | return sec * 1000 + nsec / 1000000; 122 | }, 123 | }; 124 | } 125 | 126 | if (!global.TextEncoder && global.require) { 127 | global.TextEncoder = require("util").TextEncoder; 128 | } 129 | if (!global.TextEncoder) { 130 | throw new Error("global.TextEncoder is not available, polyfill required"); 131 | } 132 | 133 | if (!global.TextDecoder && global.require) { 134 | global.TextDecoder = require("util").TextDecoder; 135 | } 136 | if (!global.TextDecoder) { 137 | throw new Error("global.TextDecoder is not available, polyfill required"); 138 | } 139 | 140 | // End of polyfills for common API. 141 | 142 | const encoder = new TextEncoder("utf-8"); 143 | const decoder = new TextDecoder("utf-8"); 144 | 145 | global.Go = class { 146 | constructor() { 147 | this.argv = ["js"]; 148 | this.env = {}; 149 | this.exit = (code) => { 150 | if (code !== 0) { 151 | console.warn("exit code:", code); 152 | } 153 | }; 154 | this._exitPromise = new Promise((resolve) => { 155 | this._resolveExitPromise = resolve; 156 | }); 157 | this._pendingEvent = null; 158 | this._scheduledTimeouts = new Map(); 159 | this._nextCallbackTimeoutID = 1; 160 | 161 | const setInt64 = (addr, v) => { 162 | this.mem.setUint32(addr + 0, v, true); 163 | this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); 164 | } 165 | 166 | const getInt64 = (addr) => { 167 | const low = this.mem.getUint32(addr + 0, true); 168 | const high = this.mem.getInt32(addr + 4, true); 169 | return low + high * 4294967296; 170 | } 171 | 172 | const loadValue = (addr) => { 173 | const f = this.mem.getFloat64(addr, true); 174 | if (f === 0) { 175 | return undefined; 176 | } 177 | if (!isNaN(f)) { 178 | return f; 179 | } 180 | 181 | const id = this.mem.getUint32(addr, true); 182 | return this._values[id]; 183 | } 184 | 185 | const storeValue = (addr, v) => { 186 | const nanHead = 0x7FF80000; 187 | 188 | if (typeof v === "number" && v !== 0) { 189 | if (isNaN(v)) { 190 | this.mem.setUint32(addr + 4, nanHead, true); 191 | this.mem.setUint32(addr, 0, true); 192 | return; 193 | } 194 | this.mem.setFloat64(addr, v, true); 195 | return; 196 | } 197 | 198 | if (v === undefined) { 199 | this.mem.setFloat64(addr, 0, true); 200 | return; 201 | } 202 | 203 | let id = this._ids.get(v); 204 | if (id === undefined) { 205 | id = this._idPool.pop(); 206 | if (id === undefined) { 207 | id = this._values.length; 208 | } 209 | this._values[id] = v; 210 | this._goRefCounts[id] = 0; 211 | this._ids.set(v, id); 212 | } 213 | this._goRefCounts[id]++; 214 | let typeFlag = 0; 215 | switch (typeof v) { 216 | case "object": 217 | if (v !== null) { 218 | typeFlag = 1; 219 | } 220 | break; 221 | case "string": 222 | typeFlag = 2; 223 | break; 224 | case "symbol": 225 | typeFlag = 3; 226 | break; 227 | case "function": 228 | typeFlag = 4; 229 | break; 230 | } 231 | this.mem.setUint32(addr + 4, nanHead | typeFlag, true); 232 | this.mem.setUint32(addr, id, true); 233 | } 234 | 235 | const loadSlice = (addr) => { 236 | const array = getInt64(addr + 0); 237 | const len = getInt64(addr + 8); 238 | return new Uint8Array(this._inst.exports.mem.buffer, array, len); 239 | } 240 | 241 | const loadSliceOfValues = (addr) => { 242 | const array = getInt64(addr + 0); 243 | const len = getInt64(addr + 8); 244 | const a = new Array(len); 245 | for (let i = 0; i < len; i++) { 246 | a[i] = loadValue(array + i * 8); 247 | } 248 | return a; 249 | } 250 | 251 | const loadString = (addr) => { 252 | const saddr = getInt64(addr + 0); 253 | const len = getInt64(addr + 8); 254 | return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); 255 | } 256 | 257 | const timeOrigin = Date.now() - performance.now(); 258 | this.importObject = { 259 | go: { 260 | // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) 261 | // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported 262 | // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). 263 | // This changes the SP, thus we have to update the SP used by the imported function. 264 | 265 | // func wasmExit(code int32) 266 | "runtime.wasmExit": (sp) => { 267 | sp >>>= 0; 268 | const code = this.mem.getInt32(sp + 8, true); 269 | this.exited = true; 270 | delete this._inst; 271 | delete this._values; 272 | delete this._goRefCounts; 273 | delete this._ids; 274 | delete this._idPool; 275 | this.exit(code); 276 | }, 277 | 278 | // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) 279 | "runtime.wasmWrite": (sp) => { 280 | sp >>>= 0; 281 | const fd = getInt64(sp + 8); 282 | const p = getInt64(sp + 16); 283 | const n = this.mem.getInt32(sp + 24, true); 284 | fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); 285 | }, 286 | 287 | // func resetMemoryDataView() 288 | "runtime.resetMemoryDataView": (sp) => { 289 | sp >>>= 0; 290 | this.mem = new DataView(this._inst.exports.mem.buffer); 291 | }, 292 | 293 | // func nanotime1() int64 294 | "runtime.nanotime1": (sp) => { 295 | sp >>>= 0; 296 | setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); 297 | }, 298 | 299 | // func walltime1() (sec int64, nsec int32) 300 | "runtime.walltime1": (sp) => { 301 | sp >>>= 0; 302 | const msec = (new Date).getTime(); 303 | setInt64(sp + 8, msec / 1000); 304 | this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); 305 | }, 306 | 307 | // func scheduleTimeoutEvent(delay int64) int32 308 | "runtime.scheduleTimeoutEvent": (sp) => { 309 | sp >>>= 0; 310 | const id = this._nextCallbackTimeoutID; 311 | this._nextCallbackTimeoutID++; 312 | this._scheduledTimeouts.set(id, setTimeout( 313 | () => { 314 | this._resume(); 315 | while (this._scheduledTimeouts.has(id)) { 316 | // for some reason Go failed to register the timeout event, log and try again 317 | // (temporary workaround for https://github.com/golang/go/issues/28975) 318 | console.warn("scheduleTimeoutEvent: missed timeout event"); 319 | this._resume(); 320 | } 321 | }, 322 | getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early 323 | )); 324 | this.mem.setInt32(sp + 16, id, true); 325 | }, 326 | 327 | // func clearTimeoutEvent(id int32) 328 | "runtime.clearTimeoutEvent": (sp) => { 329 | sp >>>= 0; 330 | const id = this.mem.getInt32(sp + 8, true); 331 | clearTimeout(this._scheduledTimeouts.get(id)); 332 | this._scheduledTimeouts.delete(id); 333 | }, 334 | 335 | // func getRandomData(r []byte) 336 | "runtime.getRandomData": (sp) => { 337 | sp >>>= 0; 338 | crypto.getRandomValues(loadSlice(sp + 8)); 339 | }, 340 | 341 | // func finalizeRef(v ref) 342 | "syscall/js.finalizeRef": (sp) => { 343 | sp >>>= 0; 344 | const id = this.mem.getUint32(sp + 8, true); 345 | this._goRefCounts[id]--; 346 | if (this._goRefCounts[id] === 0) { 347 | const v = this._values[id]; 348 | this._values[id] = null; 349 | this._ids.delete(v); 350 | this._idPool.push(id); 351 | } 352 | }, 353 | 354 | // func stringVal(value string) ref 355 | "syscall/js.stringVal": (sp) => { 356 | sp >>>= 0; 357 | storeValue(sp + 24, loadString(sp + 8)); 358 | }, 359 | 360 | // func valueGet(v ref, p string) ref 361 | "syscall/js.valueGet": (sp) => { 362 | sp >>>= 0; 363 | const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); 364 | sp = this._inst.exports.getsp() >>> 0; // see comment above 365 | storeValue(sp + 32, result); 366 | }, 367 | 368 | // func valueSet(v ref, p string, x ref) 369 | "syscall/js.valueSet": (sp) => { 370 | sp >>>= 0; 371 | Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); 372 | }, 373 | 374 | // func valueDelete(v ref, p string) 375 | "syscall/js.valueDelete": (sp) => { 376 | sp >>>= 0; 377 | Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); 378 | }, 379 | 380 | // func valueIndex(v ref, i int) ref 381 | "syscall/js.valueIndex": (sp) => { 382 | sp >>>= 0; 383 | storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); 384 | }, 385 | 386 | // valueSetIndex(v ref, i int, x ref) 387 | "syscall/js.valueSetIndex": (sp) => { 388 | sp >>>= 0; 389 | Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); 390 | }, 391 | 392 | // func valueCall(v ref, m string, args []ref) (ref, bool) 393 | "syscall/js.valueCall": (sp) => { 394 | sp >>>= 0; 395 | try { 396 | const v = loadValue(sp + 8); 397 | const m = Reflect.get(v, loadString(sp + 16)); 398 | const args = loadSliceOfValues(sp + 32); 399 | const result = Reflect.apply(m, v, args); 400 | sp = this._inst.exports.getsp() >>> 0; // see comment above 401 | storeValue(sp + 56, result); 402 | this.mem.setUint8(sp + 64, 1); 403 | } catch (err) { 404 | storeValue(sp + 56, err); 405 | this.mem.setUint8(sp + 64, 0); 406 | } 407 | }, 408 | 409 | // func valueInvoke(v ref, args []ref) (ref, bool) 410 | "syscall/js.valueInvoke": (sp) => { 411 | sp >>>= 0; 412 | try { 413 | const v = loadValue(sp + 8); 414 | const args = loadSliceOfValues(sp + 16); 415 | const result = Reflect.apply(v, undefined, args); 416 | sp = this._inst.exports.getsp() >>> 0; // see comment above 417 | storeValue(sp + 40, result); 418 | this.mem.setUint8(sp + 48, 1); 419 | } catch (err) { 420 | storeValue(sp + 40, err); 421 | this.mem.setUint8(sp + 48, 0); 422 | } 423 | }, 424 | 425 | // func valueNew(v ref, args []ref) (ref, bool) 426 | "syscall/js.valueNew": (sp) => { 427 | sp >>>= 0; 428 | try { 429 | const v = loadValue(sp + 8); 430 | const args = loadSliceOfValues(sp + 16); 431 | const result = Reflect.construct(v, args); 432 | sp = this._inst.exports.getsp() >>> 0; // see comment above 433 | storeValue(sp + 40, result); 434 | this.mem.setUint8(sp + 48, 1); 435 | } catch (err) { 436 | storeValue(sp + 40, err); 437 | this.mem.setUint8(sp + 48, 0); 438 | } 439 | }, 440 | 441 | // func valueLength(v ref) int 442 | "syscall/js.valueLength": (sp) => { 443 | sp >>>= 0; 444 | setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); 445 | }, 446 | 447 | // valuePrepareString(v ref) (ref, int) 448 | "syscall/js.valuePrepareString": (sp) => { 449 | sp >>>= 0; 450 | const str = encoder.encode(String(loadValue(sp + 8))); 451 | storeValue(sp + 16, str); 452 | setInt64(sp + 24, str.length); 453 | }, 454 | 455 | // valueLoadString(v ref, b []byte) 456 | "syscall/js.valueLoadString": (sp) => { 457 | sp >>>= 0; 458 | const str = loadValue(sp + 8); 459 | loadSlice(sp + 16).set(str); 460 | }, 461 | 462 | // func valueInstanceOf(v ref, t ref) bool 463 | "syscall/js.valueInstanceOf": (sp) => { 464 | sp >>>= 0; 465 | this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); 466 | }, 467 | 468 | // func copyBytesToGo(dst []byte, src ref) (int, bool) 469 | "syscall/js.copyBytesToGo": (sp) => { 470 | sp >>>= 0; 471 | const dst = loadSlice(sp + 8); 472 | const src = loadValue(sp + 32); 473 | if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { 474 | this.mem.setUint8(sp + 48, 0); 475 | return; 476 | } 477 | const toCopy = src.subarray(0, dst.length); 478 | dst.set(toCopy); 479 | setInt64(sp + 40, toCopy.length); 480 | this.mem.setUint8(sp + 48, 1); 481 | }, 482 | 483 | // func copyBytesToJS(dst ref, src []byte) (int, bool) 484 | "syscall/js.copyBytesToJS": (sp) => { 485 | sp >>>= 0; 486 | const dst = loadValue(sp + 8); 487 | const src = loadSlice(sp + 16); 488 | if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { 489 | this.mem.setUint8(sp + 48, 0); 490 | return; 491 | } 492 | const toCopy = src.subarray(0, dst.length); 493 | dst.set(toCopy); 494 | setInt64(sp + 40, toCopy.length); 495 | this.mem.setUint8(sp + 48, 1); 496 | }, 497 | 498 | "debug": (value) => { 499 | console.log(value); 500 | }, 501 | } 502 | }; 503 | } 504 | 505 | async run(instance) { 506 | if (!(instance instanceof WebAssembly.Instance)) { 507 | throw new Error("Go.run: WebAssembly.Instance expected"); 508 | } 509 | this._inst = instance; 510 | this.mem = new DataView(this._inst.exports.mem.buffer); 511 | this._values = [ // JS values that Go currently has references to, indexed by reference id 512 | NaN, 513 | 0, 514 | null, 515 | true, 516 | false, 517 | global, 518 | this, 519 | ]; 520 | this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id 521 | this._ids = new Map([ // mapping from JS values to reference ids 522 | [0, 1], 523 | [null, 2], 524 | [true, 3], 525 | [false, 4], 526 | [global, 5], 527 | [this, 6], 528 | ]); 529 | this._idPool = []; // unused ids that have been garbage collected 530 | this.exited = false; // whether the Go program has exited 531 | 532 | // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. 533 | let offset = 4096; 534 | 535 | const strPtr = (str) => { 536 | const ptr = offset; 537 | const bytes = encoder.encode(str + "\0"); 538 | new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); 539 | offset += bytes.length; 540 | if (offset % 8 !== 0) { 541 | offset += 8 - (offset % 8); 542 | } 543 | return ptr; 544 | }; 545 | 546 | const argc = this.argv.length; 547 | 548 | const argvPtrs = []; 549 | this.argv.forEach((arg) => { 550 | argvPtrs.push(strPtr(arg)); 551 | }); 552 | argvPtrs.push(0); 553 | 554 | const keys = Object.keys(this.env).sort(); 555 | keys.forEach((key) => { 556 | argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); 557 | }); 558 | argvPtrs.push(0); 559 | 560 | const argv = offset; 561 | argvPtrs.forEach((ptr) => { 562 | this.mem.setUint32(offset, ptr, true); 563 | this.mem.setUint32(offset + 4, 0, true); 564 | offset += 8; 565 | }); 566 | 567 | this._inst.exports.run(argc, argv); 568 | if (this.exited) { 569 | this._resolveExitPromise(); 570 | } 571 | await this._exitPromise; 572 | } 573 | 574 | _resume() { 575 | if (this.exited) { 576 | throw new Error("Go program has already exited"); 577 | } 578 | this._inst.exports.resume(); 579 | if (this.exited) { 580 | this._resolveExitPromise(); 581 | } 582 | } 583 | 584 | _makeFuncWrapper(id) { 585 | const go = this; 586 | return function () { 587 | const event = { id: id, this: this, args: arguments }; 588 | go._pendingEvent = event; 589 | go._resume(); 590 | return event.result; 591 | }; 592 | } 593 | } 594 | 595 | if ( 596 | typeof module !== "undefined" && 597 | global.require && 598 | global.require.main === module && 599 | global.process && 600 | global.process.versions && 601 | !global.process.versions.electron 602 | ) { 603 | if (process.argv.length < 3) { 604 | console.error("usage: go_js_wasm_exec [wasm binary] [arguments]"); 605 | process.exit(1); 606 | } 607 | 608 | const go = new Go(); 609 | go.argv = process.argv.slice(2); 610 | go.env = Object.assign({ TMPDIR: require("os").tmpdir() }, process.env); 611 | go.exit = process.exit; 612 | WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then((result) => { 613 | process.on("exit", (code) => { // Node.js exits if no event handler is pending 614 | if (code === 0 && !go.exited) { 615 | // deadlock, make Go print error and stack traces 616 | go._pendingEvent = { id: 0 }; 617 | go._resume(); 618 | } 619 | }); 620 | return go.run(result.instance); 621 | }).catch((err) => { 622 | console.error(err); 623 | process.exit(1); 624 | }); 625 | } 626 | })(); 627 | -------------------------------------------------------------------------------- /route.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gjbae1212/hit-counter/handler" 9 | api_handler "github.com/gjbae1212/hit-counter/handler/api" 10 | "github.com/gjbae1212/hit-counter/internal" 11 | "github.com/labstack/echo/v4" 12 | ) 13 | 14 | func AddRoute(e *echo.Echo, redisAddr string) error { 15 | if e == nil { 16 | return fmt.Errorf("[Err] AddRoute empty params") 17 | } 18 | 19 | h, err := handler.NewHandler(redisAddr) 20 | if err != nil { 21 | return fmt.Errorf("[err] AddRoute %w", err) 22 | } 23 | 24 | api, err := api_handler.NewHandler(h) 25 | if err != nil { 26 | return fmt.Errorf("[err] AddRoute %w", err) 27 | } 28 | 29 | // error handler 30 | e.HTTPErrorHandler = h.Error 31 | // static 32 | e.Static("/", "public") 33 | 34 | // wasm 35 | e.GET("/hits.wasm", h.Wasm) 36 | 37 | // websocket 38 | e.GET("/ws", h.WebSocket) 39 | 40 | // main 41 | e.GET("/", h.Index) 42 | 43 | // icon 44 | e.GET("/icon/all.json", h.IconAll) 45 | e.GET("/icon/:icon", h.Icon) 46 | 47 | // health check 48 | e.GET("/healthcheck", h.HealthCheck) 49 | 50 | // group /api/count 51 | g1, err := groupApiCount() 52 | if err != nil { 53 | return fmt.Errorf("[err] AddRoute %w", err) 54 | } 55 | count := e.Group("/api/count", g1...) 56 | // badge 57 | count.GET("/keep/badge.svg", api.KeepCount) 58 | count.GET("/incr/badge.svg", api.IncrCount) 59 | 60 | // graph 61 | count.GET("/graph/dailyhits.svg", api.DailyHitsInRecently) 62 | 63 | // group /api/rank 64 | g2, err := groupApiRank() 65 | if err != nil { 66 | return fmt.Errorf("[err] AddRoute %w", err) 67 | } 68 | rank := e.Group("/api/rank", g2...) 69 | _ = rank 70 | 71 | return nil 72 | } 73 | 74 | func groupApiCount() ([]echo.MiddlewareFunc, error) { 75 | var chain []echo.MiddlewareFunc 76 | // Add param 77 | paramFunc := func(h echo.HandlerFunc) echo.HandlerFunc { 78 | return func(c echo.Context) error { 79 | hitctx := c.(*handler.HitCounterContext) 80 | 81 | // check a url is invalid or not. 82 | url := hitctx.QueryParam("url") 83 | if url == "" { 84 | return echo.NewHTTPError(http.StatusBadRequest, "Not Found URL Query String") 85 | } 86 | 87 | schema, host, _, path, _, _, err := internal.ParseURL(url) 88 | if err != nil { 89 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid URL Query String %s", url)) 90 | } 91 | 92 | if !internal.StringInSlice(schema, []string{"http", "https"}) { 93 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Not Support Schema %s", schema)) 94 | } 95 | 96 | // extract required parameters 97 | title := hitctx.QueryParam("title") 98 | titleBg := hitctx.QueryParam("title_bg") 99 | countBg := hitctx.QueryParam("count_bg") 100 | edgeFlat, _ := strconv.ParseBool(hitctx.QueryParam("edge_flat")) 101 | icon := hitctx.QueryParam("icon") 102 | iconColor := hitctx.QueryParam("icon_color") 103 | 104 | // insert params to context. 105 | hitctx.Set("host", host) 106 | hitctx.Set("path", path) 107 | hitctx.Set("title", title) 108 | hitctx.Set("title_bg", titleBg) 109 | hitctx.Set("count_bg", countBg) 110 | hitctx.Set("edge_flat", edgeFlat) 111 | hitctx.Set("icon", icon) 112 | hitctx.Set("icon_color", iconColor) 113 | 114 | return h(hitctx) 115 | } 116 | } 117 | chain = append(chain, paramFunc) 118 | return chain, nil 119 | } 120 | 121 | func groupApiRank() ([]echo.MiddlewareFunc, error) { 122 | var chain []echo.MiddlewareFunc 123 | return chain, nil 124 | } 125 | -------------------------------------------------------------------------------- /route_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gjbae1212/hit-counter/handler" 9 | 10 | "github.com/alicebob/miniredis" 11 | echo "github.com/labstack/echo/v4" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestAddRoute(t *testing.T) { 16 | assert := assert.New(t) 17 | err := AddRoute(nil, "") 18 | assert.Error(err) 19 | 20 | s, err := miniredis.Run() 21 | assert.NoError(err) 22 | defer s.Close() 23 | 24 | err = AddRoute(echo.New(), s.Addr()) 25 | assert.NoError(err) 26 | } 27 | 28 | func TestGroup(t *testing.T) { 29 | assert := assert.New(t) 30 | e := echo.New() 31 | 32 | mockHandler := func(c echo.Context) error { 33 | log.Println("call????") 34 | return nil 35 | } 36 | 37 | r := httptest.NewRequest("GET", "http://localhost?url=github.com", nil) 38 | r.Header.Set(echo.HeaderXForwardedFor, "127.0.0.1") 39 | w := httptest.NewRecorder() 40 | hctx := &handler.HitCounterContext{Context: e.NewContext(r, w)} 41 | 42 | // group api 43 | funcs, err := groupApiCount() 44 | assert.NoError(err) 45 | 46 | f := funcs[0] 47 | err = f(mockHandler)(hctx) 48 | assert.NoError(err) 49 | assert.NotNil(hctx.Get("host")) 50 | assert.NotNil(hctx.Get("path")) 51 | assert.NotNil(hctx.Get("title")) 52 | } 53 | -------------------------------------------------------------------------------- /script/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -o pipefail 3 | trap '[ "$?" -eq 0 ] || echo "Error Line:<$LINENO> Error Function:<${FUNCNAME}>"' EXIT 4 | cd `dirname $0` && cd .. 5 | CURRENT=`pwd` 6 | 7 | function deploy 8 | { 9 | test 10 | make_wasm "production" 11 | upload_deployment_config 12 | gsutil cp $GCS_CONFIG_BUCKET/production.yaml $CURRENT/production.yaml 13 | rm $CURRENT/cloudbuild.yaml || true 14 | echo "yes" | gcloud app deploy production.yaml --promote 15 | git checkout cloudbuild.yaml 16 | rm $CURRENT/production.yaml || true 17 | } 18 | 19 | function make_wasm 20 | { 21 | local phase=$1 22 | echo "WASM PHASE=$phase" 23 | 24 | # copy wasm_exec.js 25 | cp $(go env GOROOT)/misc/wasm/wasm_exec.js $CURRENT/public/ 26 | 27 | GOOS=js GOARCH=wasm go build -ldflags="-s -w -X main.phase=$phase" -o $CURRENT/view/hits.wasm $CURRENT/wasm/main.go 28 | gzip $CURRENT/view/hits.wasm 29 | mv $CURRENT/view/hits.wasm.gz $CURRENT/view/hits.wasm 30 | } 31 | 32 | 33 | function upload_deployment_config 34 | { 35 | set_env 36 | gsutil cp $CURRENT/script/production.yaml $GCS_CONFIG_BUCKET/production.yaml 37 | } 38 | 39 | function run 40 | { 41 | # make_wasm "local" 42 | set_env 43 | local redis=`docker ps | grep redis | wc -l` 44 | if [ ${redis} -eq 0 ] 45 | then 46 | docker run --rm -d -p 6379:6379 --name redis redis:latest 47 | fi 48 | go build && ./hit-counter -tls=0 -addr=:8080 49 | } 50 | 51 | function test 52 | { 53 | set_env 54 | go test -v $(go list ./... | grep -v vendor | grep -v wasm) --count 1 55 | } 56 | 57 | function set_env 58 | { 59 | ulimit -n 1000 60 | if [ -e $CURRENT/script/build_env.sh ]; then 61 | source $CURRENT/script/build_env.sh 62 | fi 63 | } 64 | 65 | CMD=$1 66 | shift 67 | $CMD $* 68 | -------------------------------------------------------------------------------- /script/cloudbuild.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e -o pipefail 4 | trap '[ "$?" -eq 0 ] || echo "Error Line:<$LINENO> Error Function:<${FUNCNAME}>"' EXIT 5 | cd `dirname $0` && cd .. 6 | CURRENT=`pwd` 7 | 8 | function deploy 9 | { 10 | echo "yes" | gcloud app deploy production.yaml --promote 11 | } 12 | 13 | function test 14 | { 15 | echo $SENTRY_DSN 16 | go test -v $(go list ./... | grep -v vendor) --count 1 17 | } 18 | 19 | function download_config 20 | { 21 | BUCKET_PATH=`cat /root/config/bucket_path` 22 | gsutil cp $BUCKET_PATH $CURRENT/production.yaml 23 | } 24 | 25 | CMD=$1 26 | shift 27 | $CMD $* 28 | -------------------------------------------------------------------------------- /script/dev_server_restarter.js: -------------------------------------------------------------------------------- 1 | const { spawnSync, spawn } = require("child_process"); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | 5 | const env = { 6 | PHASE: "local", 7 | REDIS_ADDRS: "localhost:6379", 8 | }; 9 | 10 | function throttle(fn, duration) { 11 | return (function () { 12 | let timeout; 13 | return function (...args) { 14 | if (!timeout) { 15 | fn(...args); 16 | timeout = setTimeout(() => { 17 | timeout = null; 18 | }, duration); 19 | } 20 | }; 21 | })(); 22 | } 23 | 24 | function runServer() { 25 | spawnSync("bash", [path.join(__dirname, "./build.sh"), "make_wasm", "local"]); 26 | spawnSync("go build", { stdio: "inherit", env: { ...process.env, ...env } }); 27 | return spawn(path.join(__dirname, "..","hit-counter"), ["-tls=0", "-addr=:8080"], { 28 | stdio: "inherit", 29 | env: { ...process.env, ...env }, 30 | }); 31 | } 32 | 33 | function main() { 34 | let devServer; 35 | devServer = runServer(); 36 | fs.watch( 37 | path.join(__dirname, "../view"), 38 | throttle(() => { 39 | if (devServer) { 40 | devServer.kill(); 41 | } 42 | devServer = runServer(); 43 | }, 1000) 44 | ); 45 | } 46 | 47 | main(); -------------------------------------------------------------------------------- /script/encrypt_bucket_path: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gjbae1212/hit-counter/31a6bf9ca90c0c52ffba70283b1de85bb111ed10/script/encrypt_bucket_path -------------------------------------------------------------------------------- /view/hits.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gjbae1212/hit-counter/31a6bf9ca90c0c52ffba70283b1de85bb111ed10/view/hits.wasm -------------------------------------------------------------------------------- /view/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | HITS 16 | 17 | 18 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 94 | 95 | 96 |
97 | 98 |

If you find HITS helpful,

99 |

please give it a star on GitHub!

100 |
101 | GITHUB 102 |
103 |
104 | 105 | 106 |
107 |
108 |

START

109 |

Easy way to know how many visitors are viewing your Github, Website, Notion.

110 |
111 |
112 | 113 | 114 |
115 |
116 |

GENERATE BADGElink

117 |
118 | 119 |
120 |
121 |
122 |
123 | 124 | 127 | 128 | Type your URL. Then you will get generated MARKDOWN and HTML LINK. 129 | 130 |
131 |
132 |
133 |
134 |
135 | 136 |
137 |
138 | 145 |
146 |
147 | 148 |
149 | 150 |
151 | 152 |
153 |
154 | 155 | 162 |
163 |
164 |
165 | ICON COLOR 166 |
167 | 168 |
169 |
170 |
171 |
172 |
173 | 174 |
175 | 179 |
180 |
181 |
182 | TITLE 183 |
184 | 185 |
186 |
187 | 188 |
189 |
190 |
191 | TITLE BG COLOR 192 |
193 | 194 |
195 |
196 |
197 | COUNT BG COLOR 198 |
199 | 200 |
201 |
202 | 203 |
204 |
205 | MARKDOWN
206 |
207 |

208 | 209 | [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fgjbae1212%2Fhit-counter)](https://hits.seeyoufarm.com) 210 |

211 | 212 |
213 |
214 | 215 |
216 | HTML LINK
217 |
218 | 222 | 223 |
224 |
225 |
226 |
227 | EMBED URL (NOTION)
228 |
229 | 233 | 234 |
235 |
236 | 237 |
238 |
239 |
240 |
241 |
242 | 243 | 244 |
245 |
246 |

SHOW RANKmilitary_tech

247 |
248 |
249 |
250 |

TOP 10 github projects using HITS.

251 |
252 |
253 | {{range .Ranks}} 254 |

{{.}}

255 | {{end}} 256 |
257 |
258 |
259 |
260 |
261 | 262 | 263 |
264 |
265 |

SHOW HISTORYshow_chart

266 |
267 |

History of daily hits in recently 2 monthly

268 |
269 |
270 |
271 |
272 |
273 |
274 | 275 | 276 | 277 | Type URL what you want to get the history of 278 | 279 |
280 |

281 |
282 | 283 |
284 |

285 | 286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 | Press SHOW GRAPH 294 |
295 |
296 |
297 |
298 | 299 | 300 |
301 |
302 |

SHOW STREAMhistory

303 |
304 |

It is showing projects tracked by the HITS at this time.

305 |
306 |
307 |
308 |
309 |
310 |
311 |
312 | 313 | 314 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 352 | 353 | 354 | 530 | 531 | 532 | 533 | -------------------------------------------------------------------------------- /view/local.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | HITS 16 | 17 | 18 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 94 | 95 | 96 |
97 | 98 |

If you find HITS helpful,

99 |

please give it a star on GitHub!

100 |
101 | GITHUB 102 |
103 |
104 | 105 | 106 |
107 |
108 |

START

109 |

Easy way to know how many visitors are viewing your Github, Website, Notion.

110 |
111 |
112 | 113 | 114 |
115 |
116 |

GENERATE BADGElink

117 |
118 | 119 |
120 |
121 |
122 |
123 | 124 | 127 | 128 | Type your URL. Then you will get generated MARKDOWN and HTML LINK. 129 | 130 |
131 |
132 |
133 |
134 |
135 | 136 |
137 |
138 | 145 |
146 |
147 | 148 |
149 | 150 |
151 | 152 |
153 |
154 | 155 | 162 |
163 |
164 |
165 | ICON COLOR 166 |
167 | 168 |
169 |
170 |
171 |
172 |
173 | 174 |
175 | 179 |
180 |
181 |
182 | TITLE 183 |
184 | 185 |
186 |
187 | 188 |
189 |
190 |
191 | TITLE BG COLOR 192 |
193 | 194 |
195 |
196 |
197 | COUNT BG COLOR 198 |
199 | 200 |
201 |
202 | 203 |
204 |
205 | MARKDOWN
206 |
207 |

208 | 209 | [![Hits](http://localhost:8080/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fgjbae1212%2Fhit-counter)](http://localhost:8080) 210 |

211 | 212 |
213 |
214 | 215 |
216 | HTML LINK
217 |
218 | 222 | 223 |
224 |
225 |
226 |
227 | EMBED URL (NOTION)
228 |
229 | 233 | 234 |
235 |
236 | 237 |
238 |
239 |
240 |
241 |
242 | 243 | 244 |
245 |
246 |

SHOW RANKmilitary_tech

247 |
248 |
249 |
250 |

TOP 10 github projects using HITS.

251 |
252 |
253 | {{range .Ranks}} 254 |

{{.}}

255 | {{end}} 256 |
257 |
258 |
259 |
260 |
261 | 262 | 263 |
264 |
265 |

SHOW HISTORYshow_chart

266 |
267 |

History of daily hits in recently 6 monthly

268 |
269 |
270 |
271 |
272 |
273 |
274 | 275 | 276 | 277 | Type URL what you want to get the history of 278 | 279 |
280 |

281 |
282 | 283 |
284 |

285 | 286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 | Press SHOW GRAPH 294 |
295 |
296 |
297 |
298 | 299 | 300 |
301 |
302 |

SHOW STREAMhistory

303 |
304 |

It is showing projects tracked by the HITS at this time.

305 |
306 |
307 |
308 |
309 |
310 |
311 |
312 | 313 | 314 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 352 | 353 | 354 | 530 | 531 | 532 | 533 | -------------------------------------------------------------------------------- /wasm/README.md: -------------------------------------------------------------------------------- 1 | # WASM 파일 2 | -------------------------------------------------------------------------------- /wasm/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gjbae1212/hit-counter/wasm 2 | 3 | go 1.13 4 | 5 | require github.com/goware/urlx v0.3.1 6 | -------------------------------------------------------------------------------- /wasm/go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= 2 | github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= 3 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= 4 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= 5 | github.com/goware/urlx v0.3.1 h1:BbvKl8oiXtJAzOzMqAQ0GfIhf96fKeNEZfm9ocNSUBI= 6 | github.com/goware/urlx v0.3.1/go.mod h1:h8uwbJy68o+tQXCGZNa9D73WN8n0r9OBae5bUnLcgjw= 7 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd h1:HuTn7WObtcDo9uEEU7rEqL0jYthdXAmZ6PP+meazmaU= 8 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 9 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 10 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 11 | -------------------------------------------------------------------------------- /wasm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | "syscall/js" 11 | "time" 12 | 13 | "github.com/goware/urlx" 14 | ) 15 | 16 | var ( 17 | markdownFormat = "[![Hits](%s)](%s)" 18 | showFormat = "" 19 | linkFormat = "<a href=\"%s\"/><img src=\"%s\"/></a>" 20 | incrPath = "api/count/incr/badge.svg" 21 | keepPath = "api/count/keep/badge.svg" 22 | defaultDomain = "" 23 | defaultURL = "" 24 | defaultWS = "" 25 | ) 26 | 27 | var ( 28 | phase string 29 | ) 30 | 31 | func parseURL(s string) (schema, host, port, path, query, fragment string, err error) { 32 | if s == "" { 33 | err = fmt.Errorf("[err] ParseURI empty uri") 34 | } 35 | 36 | url, suberr := urlx.Parse(s) 37 | if suberr != nil { 38 | err = suberr 39 | return 40 | } 41 | 42 | schema = url.Scheme 43 | 44 | host, port, err = urlx.SplitHostPort(url) 45 | if err != nil { 46 | return 47 | } 48 | if schema == "http" && port == "" { 49 | port = "80" 50 | } else if schema == "https" && port == "" { 51 | port = "443" 52 | } 53 | 54 | path = url.Path 55 | query = url.RawQuery 56 | fragment = url.Fragment 57 | return 58 | } 59 | 60 | func onClick() { 61 | value := js.Global().Get("document").Call("getElementById", "history_url").Get("value").String() 62 | value = strings.TrimSpace(value) 63 | showGraph(value) 64 | } 65 | 66 | // DEPRECATED 67 | func onKeyUp() { 68 | value := js.Global().Get("document").Call("getElementById", "badge_url").Get("value").String() 69 | value = strings.TrimSpace(value) 70 | generateBadge(value) 71 | } 72 | 73 | // DEPRECATED 74 | func generateBadge(value string) { 75 | schema, host, _, path, _, _, err := parseURL(value) 76 | markdown := "" 77 | link := "" 78 | show := "" 79 | incrURL := "" 80 | keepURL := "" 81 | if err != nil || (schema != "http" && schema != "https") { 82 | markdown = "INVALID URL" 83 | link = "INVALID URL" 84 | } else { 85 | normalizeURL := "" 86 | if path == "" || path == "/" { 87 | normalizeURL = fmt.Sprintf("%s://%s", schema, host) 88 | } else { 89 | normalizeURL = fmt.Sprintf("%s://%s%s", schema, host, path) 90 | } 91 | incrURL = fmt.Sprintf("%s/%s?url=%s", defaultURL, incrPath, url.QueryEscape(normalizeURL)) 92 | keepURL = fmt.Sprintf("%s/%s?url=%s", defaultURL, keepPath, url.QueryEscape(normalizeURL)) 93 | markdown = fmt.Sprintf(markdownFormat, incrURL, defaultURL) 94 | link = fmt.Sprintf(linkFormat, defaultURL, incrURL) 95 | show = keepURL 96 | } 97 | js.Global().Get("document").Call("getElementById", "badge_markdown").Set("innerHTML", markdown) 98 | js.Global().Get("document").Call("getElementById", "badge_link").Set("innerHTML", link) 99 | js.Global().Get("document").Call("getElementById", "embed_link").Set("innerHTML", incrURL) 100 | js.Global().Get("document").Call("getElementById", "badge_show").Set("src", show) 101 | } 102 | 103 | func showGraph(value string) { 104 | schema, _, _, _, _, _, err := parseURL(value) 105 | if err != nil || (schema != "http" && schema != "https") { 106 | js.Global().Get("document").Call("getElementById", "history_view").Set("innerHTML", "Not Found") 107 | } else { 108 | go func(v string) { 109 | res, err := http.Get(fmt.Sprintf("%s/api/count/graph/dailyhits.svg?url=%s", defaultURL, v)) 110 | if err != nil { 111 | js.Global().Get("document").Call("getElementById", "history_view").Set("innerHTML", "Error") 112 | return 113 | } 114 | defer res.Body.Close() 115 | body, err := ioutil.ReadAll(res.Body) 116 | encodedBody := base64.StdEncoding.EncodeToString(body) 117 | if err != nil { 118 | js.Global().Get("document").Call("getElementById", "history_view").Set("innerHTML", "Error") 119 | return 120 | } 121 | js.Global().Get("document").Call("getElementById", "history_view").Set("innerHTML", "") 122 | }(value) 123 | } 124 | } 125 | 126 | func registerCallbacks() { 127 | // It will be processing when a url input field will be received a event of keyboard up. 128 | // DEPRECATED 129 | js.Global().Set("generateBadge", js.FuncOf(func(this js.Value, args []js.Value) interface{} { 130 | onKeyUp() 131 | return nil 132 | })) 133 | 134 | js.Global().Set("showGraph", js.FuncOf(func(this js.Value, args []js.Value) interface{} { 135 | js.Global().Get("document").Call("getElementById", "history_button").Set("disabled", true) 136 | js.Global().Get("document").Call("getElementById", "history_view").Set("innerHTML", `
137 | Loading... 138 |
`) 139 | onClick() 140 | js.Global().Get("document").Call("getElementById", "history_button").Set("disabled", false) 141 | return nil 142 | })) 143 | 144 | // connect websocket 145 | connectWebsocket() 146 | } 147 | 148 | func connectWebsocket() { 149 | ws := js.Global().Get("WebSocket").New(defaultWS) 150 | ws.Call("addEventListener", "open", js.FuncOf(func(this js.Value, args []js.Value) interface{} { 151 | println("websocket connection") 152 | return nil 153 | })) 154 | ws.Call("addEventListener", "close", js.FuncOf(func(this js.Value, args []js.Value) interface{} { 155 | code := args[0].Get("code").Int() 156 | println(fmt.Sprintf("websocket close %d\n", code)) 157 | if code == 1000 { 158 | println("websocket bye!") 159 | } else { 160 | go func() { 161 | select { 162 | case <-time.After(time.Second * 10): 163 | connectWebsocket() 164 | } 165 | }() 166 | } 167 | return nil 168 | })) 169 | ws.Call("addEventListener", "message", js.FuncOf(func(this js.Value, args []js.Value) interface{} { 170 | p := js.Global().Get("document").Call("createElement", "p") 171 | p.Set("innerHTML", args[0].Get("data")) 172 | js.Global().Get("document").Call("getElementById", "stream_view").Call("prepend", p) 173 | return nil 174 | })) 175 | ws.Call("addEventListener", "error", js.FuncOf(func(this js.Value, args []js.Value) interface{} { 176 | code := args[0].Get("code").String() 177 | println(fmt.Sprintf("websocket error %s\n", code)) 178 | if "ECONNREFUSED" == code { 179 | go func() { 180 | select { 181 | case <-time.After(time.Second * 10): 182 | connectWebsocket() 183 | } 184 | }() 185 | } else { 186 | println("websocket bye!") 187 | } 188 | return nil 189 | })) 190 | } 191 | 192 | func main() { 193 | println("START GO WASM ", phase) 194 | if phase == "local" { 195 | defaultDomain = "localhost:8080" 196 | defaultURL = fmt.Sprintf("http://%s", defaultDomain) 197 | defaultWS = fmt.Sprintf("ws://%s/ws", defaultDomain) 198 | } else { 199 | defaultDomain = "hits.seeyoufarm.com" 200 | defaultURL = fmt.Sprintf("https://%s", defaultDomain) 201 | defaultWS = fmt.Sprintf("wss://%s/ws", defaultDomain) 202 | } 203 | registerCallbacks() 204 | c := make(chan struct{}, 0) 205 | <-c 206 | } 207 | --------------------------------------------------------------------------------