├── .github
├── CODEOWNERS
├── dependabot.yml
├── release-drafter.yml
└── workflows
│ ├── integration.yaml
│ ├── mkdocs.yml
│ ├── quality.yaml
│ ├── release-drafter.yaml
│ └── release.yml
├── .gitignore
├── .gitpod.yml
├── .golangci.yml
├── .goreleaser.yml
├── .markdownlint.yml
├── Dockerfile
├── LICENSE
├── README.md
├── SECURITY.md
├── cmd
├── root.go
├── run.go
├── user.go
└── version.go
├── config.yml.dist
├── docs
├── api.md
├── assets
│ ├── logo.png
│ └── ticker-api.service
├── configuration.md
├── index.md
├── quick-install-all.md
├── quick-install.md
└── swagger.yaml
├── go.mod
├── go.sum
├── internal
├── api
│ ├── api.go
│ ├── api_test.go
│ ├── features.go
│ ├── features_test.go
│ ├── feed.go
│ ├── feed_test.go
│ ├── helper
│ │ ├── util.go
│ │ └── util_test.go
│ ├── init.go
│ ├── init_test.go
│ ├── media.go
│ ├── media_test.go
│ ├── messages.go
│ ├── messages_test.go
│ ├── middleware
│ │ ├── auth
│ │ │ ├── auth.go
│ │ │ └── auth_test.go
│ │ ├── cors
│ │ │ └── cors.go
│ │ ├── logger
│ │ │ └── logger.go
│ │ ├── me
│ │ │ ├── me.go
│ │ │ └── me_test.go
│ │ ├── message
│ │ │ ├── message.go
│ │ │ └── message_test.go
│ │ ├── prometheus
│ │ │ ├── prometheus.go
│ │ │ └── prometheus_test.go
│ │ ├── response_cache
│ │ │ ├── response_cache.go
│ │ │ └── response_cache_test.go
│ │ ├── ticker
│ │ │ ├── ticker.go
│ │ │ └── ticker_test.go
│ │ └── user
│ │ │ ├── admin.go
│ │ │ ├── admin_test.go
│ │ │ ├── user.go
│ │ │ └── user_test.go
│ ├── pagination
│ │ ├── pagination.go
│ │ └── pagination_test.go
│ ├── renderer
│ │ ├── renderer.go
│ │ ├── rss.go
│ │ └── rss_test.go
│ ├── response
│ │ ├── init.go
│ │ ├── init_test.go
│ │ ├── message.go
│ │ ├── message_test.go
│ │ ├── response.go
│ │ ├── response_test.go
│ │ ├── settings.go
│ │ ├── settings_test.go
│ │ ├── ticker.go
│ │ ├── ticker_test.go
│ │ ├── timeline.go
│ │ ├── timeline_test.go
│ │ ├── upload.go
│ │ ├── upload_test.go
│ │ ├── user.go
│ │ └── user_test.go
│ ├── settings.go
│ ├── settings_test.go
│ ├── tickers.go
│ ├── tickers_test.go
│ ├── timeline.go
│ ├── timeline_test.go
│ ├── upload.go
│ ├── upload_test.go
│ ├── user.go
│ └── user_test.go
├── bluesky
│ ├── bluesky.go
│ └── bluesky_test.go
├── bridge
│ ├── bluesky.go
│ ├── bluesky_test.go
│ ├── bridge.go
│ ├── bridge_test.go
│ ├── mastodon.go
│ ├── mastodon_test.go
│ ├── mock_Bridge.go
│ ├── signal_group.go
│ ├── signal_group_test.go
│ ├── telegram.go
│ └── telegram_test.go
├── cache
│ ├── cache.go
│ └── cache_test.go
├── config
│ ├── config.go
│ └── config_test.go
├── logger
│ ├── gorm.go
│ └── logrus.go
├── signal
│ └── signal.go
├── storage
│ ├── message.go
│ ├── message_test.go
│ ├── migrations.go
│ ├── migrations_test.go
│ ├── mock_Storage.go
│ ├── setting.go
│ ├── sql_storage.go
│ ├── sql_storage_test.go
│ ├── storage.go
│ ├── ticker.go
│ ├── ticker_test.go
│ ├── upload.go
│ ├── upload_test.go
│ ├── user.go
│ ├── user_test.go
│ ├── utils.go
│ └── utils_test.go
└── util
│ ├── file.go
│ ├── file_test.go
│ ├── hashtag.go
│ ├── hashtag_test.go
│ ├── image.go
│ ├── image_test.go
│ ├── slice.go
│ ├── slice_test.go
│ ├── url.go
│ └── url_test.go
├── main.go
├── mkdocs.yml
├── requirements.txt
├── sonar-project.properties
└── testdata
├── config_invalid.txt
├── config_valid.yml
├── gopher-dance.gif
└── gopher.jpg
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @0x46616c6b
2 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | ---
2 | version: 2
3 | updates:
4 | - package-ecosystem: "gomod"
5 | directory: "/"
6 | schedule:
7 | interval: "monthly"
8 | groups:
9 | ticker:
10 | patterns:
11 | - "*"
12 |
13 | - package-ecosystem: "docker"
14 | directory: "/"
15 | schedule:
16 | interval: "monthly"
17 |
18 | - package-ecosystem: "github-actions"
19 | directory: "/"
20 | schedule:
21 | interval: "monthly"
22 |
--------------------------------------------------------------------------------
/.github/release-drafter.yml:
--------------------------------------------------------------------------------
1 | name-template: '$RESOLVED_VERSION'
2 | tag-template: '$RESOLVED_VERSION'
3 | categories:
4 | - title: '🚀 Features'
5 | labels:
6 | - 'feature'
7 | - 'enhancement'
8 | - title: '🐛 Bug Fixes'
9 | labels:
10 | - 'fix'
11 | - 'bugfix'
12 | - 'bug'
13 | - title: '🧹 Maintenance'
14 | labels:
15 | - 'chore'
16 | - 'dependencies'
17 | version-resolver:
18 | major:
19 | labels:
20 | - 'feature'
21 | minor:
22 | labels:
23 | - 'enhancement'
24 | patch:
25 | labels:
26 | - 'fix'
27 | - 'bugfix'
28 | - 'bug'
29 | - 'chore'
30 | - 'dependencies'
31 | default: patch
32 | template: |
33 | ## Changes
34 |
35 | $CHANGES
36 |
37 | **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...$RESOLVED_VERSION
38 |
39 | ## Docker
40 |
41 | - `docker pull systemli/ticker:$RESOLVED_VERSION`
42 |
--------------------------------------------------------------------------------
/.github/workflows/integration.yaml:
--------------------------------------------------------------------------------
1 | name: Integration
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | test:
11 | name: Test
12 | runs-on: ubuntu-24.04
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v4
16 |
17 | - name: Setup go
18 | uses: actions/setup-go@v5
19 | with:
20 | go-version-file: "go.mod"
21 |
22 | - name: Test
23 | run: go test -coverprofile=coverage.txt -covermode=atomic ./...
24 |
25 | - name: SonarCloud Scan
26 | uses: sonarsource/sonarcloud-github-action@v5.0.0
27 | env:
28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
30 |
31 | build:
32 | name: Build
33 | runs-on: ubuntu-24.04
34 | needs: [test]
35 | steps:
36 | - name: Checkout
37 | uses: actions/checkout@v4
38 |
39 | - name: Setup go
40 | uses: actions/setup-go@v5
41 | with:
42 | go-version-file: "go.mod"
43 |
44 | - name: Build
45 | run: go build
46 |
47 | - name: Docker
48 | run: docker build .
49 |
50 | automerge:
51 | name: Merge Automatically
52 | needs: [test, build]
53 | runs-on: ubuntu-22.04
54 |
55 | permissions:
56 | pull-requests: write
57 | contents: write
58 |
59 | steps:
60 | - name: Obtain Access Token
61 | id: acces_token
62 | run: |
63 | TOKEN="$(npx obtain-github-app-installation-access-token ci ${{ secrets.SYSTEMLI_APP_CREDENTIALS_TOKEN }})"
64 | echo "::add-mask::$TOKEN"
65 | echo "::set-output name=token::$TOKEN"
66 |
67 | - name: Merge
68 | uses: fastify/github-action-merge-dependabot@v3
69 | with:
70 | github-token: ${{ steps.acces_token.outputs.token }}
71 |
--------------------------------------------------------------------------------
/.github/workflows/mkdocs.yml:
--------------------------------------------------------------------------------
1 | name: Documentation
2 |
3 | on:
4 | push:
5 | branches:
6 | - "main"
7 |
8 | jobs:
9 | mkdocs:
10 | name: Build & Deploy Documentation
11 | runs-on: ubuntu-24.04
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v4
15 |
16 | - name: Deploy Documentation
17 | uses: mhausenblas/mkdocs-deploy-gh-pages@master
18 | env:
19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
20 | CONFIG_FILE: mkdocs.yml
21 | REQUIREMENTS: requirements.txt
22 |
--------------------------------------------------------------------------------
/.github/workflows/quality.yaml:
--------------------------------------------------------------------------------
1 | name: Quality
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | golangci:
11 | name: GolangCI
12 | runs-on: ubuntu-24.04
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v4
16 |
17 | - name: GolangCI
18 | uses: golangci/golangci-lint-action@v6
19 | with:
20 | args: --timeout 10m
21 |
--------------------------------------------------------------------------------
/.github/workflows/release-drafter.yaml:
--------------------------------------------------------------------------------
1 | name: Release Drafter
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | workflow_dispatch:
8 |
9 | jobs:
10 | release:
11 | name: Update Release
12 | runs-on: ubuntu-24.04
13 | steps:
14 | - name: Publish Release
15 | uses: release-drafter/release-drafter@v6
16 | with:
17 | publish: false
18 | env:
19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
20 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | release:
9 | name: Release
10 | runs-on: ubuntu-24.04
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v4
14 | with:
15 | ref: ${{ github.ref }}
16 |
17 | - name: Set up Go
18 | uses: actions/setup-go@v5
19 | with:
20 | go-version-file: "go.mod"
21 |
22 | - name: Login to Docker Hub
23 | uses: docker/login-action@v3.4.0
24 | with:
25 | username: ${{ secrets.DOCKERHUB_USERNAME }}
26 | password: ${{ secrets.DOCKERHUB_TOKEN }}
27 |
28 | - name: Build Releases
29 | uses: goreleaser/goreleaser-action@v6.2.1
30 | with:
31 | version: '~> v2'
32 | args: release --clean
33 | env:
34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | config.yml
2 | dist/
3 | *.db
4 | site/
5 | uploads/
6 | ticker
--------------------------------------------------------------------------------
/.gitpod.yml:
--------------------------------------------------------------------------------
1 | ---
2 | tasks:
3 | - before: cp config.yml.dist config.yml
4 | init: go mod download
5 | command: go run . run
6 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | ---
2 | run:
3 | tests: false
4 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | builds:
4 | - env:
5 | - CGO_ENABLED=0
6 | goarch:
7 | - amd64
8 | - arm
9 | - arm64
10 | goos:
11 | - linux
12 | - darwin
13 | goarm:
14 | - "6"
15 | - "7"
16 | ldflags:
17 | - -s -w
18 | - -X github.com/systemli/ticker/cmd.version={{.Version}}
19 | - -X github.com/systemli/ticker/cmd.commit={{.Commit}}
20 | dockers:
21 | - goos: linux
22 | goarch: amd64
23 | image_templates:
24 | - "systemli/ticker:{{ .Tag }}"
25 | - "systemli/ticker:{{ .Major }}"
26 | - "systemli/ticker:{{ .Major }}.{{ .Minor }}"
27 | - "systemli/ticker:latest"
28 | checksum:
29 | name_template: "checksums.txt"
30 | snapshot:
31 | name_template: "{{ .Tag }}-next"
32 | changelog:
33 | disable: true
34 |
--------------------------------------------------------------------------------
/.markdownlint.yml:
--------------------------------------------------------------------------------
1 | ---
2 | default: true
3 | line-length:
4 | line_length: 160
5 | code-block-style: false
6 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM alpine:3.22.0 as build
2 |
3 | ENV USER=ticker
4 | ENV UID=10001
5 |
6 | RUN adduser \
7 | --disabled-password \
8 | --gecos "" \
9 | --home "/nonexistent" \
10 | --shell "/sbin/nologin" \
11 | --no-create-home \
12 | --uid "${UID}" \
13 | "${USER}"
14 |
15 |
16 | FROM scratch as runtime
17 |
18 | COPY --from=build /etc/passwd /etc/passwd
19 | COPY --from=build /etc/group /etc/group
20 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
21 |
22 | COPY ticker /ticker
23 |
24 | USER ticker:ticker
25 |
26 | ENTRYPOINT ["/ticker"]
27 | CMD [ "run" ]
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ticker [](https://github.com/systemli/ticker/actions) [](https://sonarcloud.io/summary/new_code?id=systemli_ticker) [](https://sonarcloud.io/summary/new_code?id=systemli_ticker) [](https://sonarcloud.io/summary/new_code?id=systemli_ticker) [](https://hub.docker.com/r/systemli/ticker/)
2 |
3 | This repository contains the API for the [Systemli Ticker Project](https://www.systemli.org/en/service/ticker.html).
4 |
5 | ## Documentation
6 |
7 | See the rendered documentation under [https://systemli.github.io/ticker/](https://systemli.github.io/ticker/)
8 |
9 | You can find and adjust the documentation under [docs/](/docs)
10 |
11 | ## Contribution
12 |
13 | We welcome contributions to this project. Feel free to fork the repository and commit your changes and submit a Pull Request. To ease the process you can easily use Gitpod to create a virtual environment.
14 |
15 | [](https://gitpod.io/#https://github.com/systemli/ticker)
16 |
17 | ## Licence
18 |
19 | GPL-3.0
20 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | | Version | Supported |
6 | | ------- | ------------------ |
7 | | > 1.9.0 | :white_check_mark: |
8 | | < 1.9.0 | :x: |
9 |
10 |
11 | ## Reporting a Vulnerability
12 |
13 | File a issue in this repository or report them via mail to support@systemli.org. We will take care as soon as possible to fix the issue.
14 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/sirupsen/logrus"
8 | "github.com/spf13/cobra"
9 | "github.com/systemli/ticker/internal/bridge"
10 | "github.com/systemli/ticker/internal/config"
11 | "github.com/systemli/ticker/internal/logger"
12 | "github.com/systemli/ticker/internal/storage"
13 | "gorm.io/gorm"
14 | )
15 |
16 | var (
17 | configPath string
18 | cfg config.Config
19 | db *gorm.DB
20 | store *storage.SqlStorage
21 |
22 | log = logrus.New()
23 |
24 | rootCmd = &cobra.Command{
25 | Use: "ticker",
26 | Short: "Service to distribute short messages",
27 | Long: "Service to distribute short messages in support of events, demonstrations, or other time-sensitive events.",
28 | }
29 | )
30 |
31 | func init() {
32 | cobra.OnInitialize(initConfig)
33 | rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to config.yml")
34 | }
35 |
36 | func initConfig() {
37 | cfg = config.LoadConfig(configPath)
38 | //TODO: Improve startup routine
39 | if cfg.Telegram.Enabled() {
40 | user, err := bridge.BotUser(cfg.Telegram.Token)
41 | if err != nil {
42 | log.WithError(err).Error("Unable to retrieve the user information for the Telegram Bot")
43 | } else {
44 | cfg.Telegram.User = user
45 | }
46 | }
47 |
48 | log = logger.NewLogrus(cfg.LogLevel, cfg.LogFormat)
49 |
50 | var err error
51 | db, err = storage.OpenGormDB(cfg.Database.Type, cfg.Database.DSN, log)
52 | if err != nil {
53 | log.WithError(err).Fatal("could not connect to database")
54 | }
55 | store = storage.NewSqlStorage(db, cfg.Upload.Path)
56 | if err := storage.MigrateDB(db); err != nil {
57 | log.WithError(err).Fatal("could not migrate database")
58 | }
59 | }
60 |
61 | func Execute() {
62 | rootCmd.AddCommand(runCmd)
63 | rootCmd.AddCommand(userCmd)
64 | rootCmd.AddCommand(versionCmd)
65 |
66 | if err := rootCmd.Execute(); err != nil {
67 | fmt.Println(err)
68 | os.Exit(1)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/cmd/run.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "os"
7 | "os/signal"
8 | "time"
9 |
10 | "github.com/prometheus/client_golang/prometheus/promhttp"
11 | "github.com/spf13/cobra"
12 | "github.com/systemli/ticker/internal/api"
13 | )
14 |
15 | var (
16 | runCmd = &cobra.Command{
17 | Use: "run",
18 | Short: "Run the ticker",
19 | Run: func(cmd *cobra.Command, args []string) {
20 | log.Infof("starting ticker (version: %s, commit: %s) on %s", version, commit, cfg.Listen)
21 |
22 | go func() {
23 | http.Handle("/metrics", promhttp.Handler())
24 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
25 | _, _ = w.Write([]byte(`
26 |
Ticker Metrics Exporter
27 |
28 | Ticker Metrics Exporter
29 | Metrics
30 |
31 | `))
32 | })
33 | log.Fatal(http.ListenAndServe(cfg.MetricsListen, nil))
34 | }()
35 |
36 | router := api.API(cfg, store, log)
37 | server := &http.Server{
38 | Addr: cfg.Listen,
39 | Handler: router,
40 | }
41 |
42 | go func() {
43 | if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
44 | log.Fatal(err)
45 | }
46 | }()
47 |
48 | // Wait for interrupt signal to gracefully shutdown the server with a timeout of 5 seconds.
49 | quit := make(chan os.Signal, 1)
50 | signal.Notify(quit, os.Interrupt)
51 | <-quit
52 |
53 | log.Infoln("Shutdown Ticker")
54 |
55 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
56 | defer cancel()
57 |
58 | if err := server.Shutdown(ctx); err != nil {
59 | log.Fatal(err)
60 | }
61 | },
62 | }
63 | )
64 |
--------------------------------------------------------------------------------
/cmd/user.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/spf13/cobra"
7 | "github.com/systemli/ticker/internal/storage"
8 |
9 | pwd "github.com/sethvargo/go-password/password"
10 | )
11 |
12 | var (
13 | email string
14 | password string
15 | isSuperAdmin bool
16 |
17 | userCmd = &cobra.Command{
18 | Use: "user",
19 | Short: "Manage users",
20 | Long: "Commands for managing users.",
21 | Args: cobra.ExactArgs(1),
22 | }
23 |
24 | userCreateCmd = &cobra.Command{
25 | Use: "create",
26 | Short: "Create a new user",
27 | Run: func(cmd *cobra.Command, args []string) {
28 | var err error
29 | if email == "" {
30 | log.Fatal("email is required")
31 | }
32 | if password == "" {
33 | password, err = pwd.Generate(24, 3, 3, false, false)
34 | if err != nil {
35 | log.WithError(err).Fatal("could not generate password")
36 | }
37 | }
38 |
39 | user, err := storage.NewUser(email, password)
40 | if err != nil {
41 | log.WithError(err).Fatal("could not create user")
42 | }
43 | user.IsSuperAdmin = isSuperAdmin
44 |
45 | if err := store.SaveUser(&user); err != nil {
46 | log.WithError(err).Fatal("could not save user")
47 | }
48 |
49 | fmt.Printf("Created user %d\n", user.ID)
50 | fmt.Printf("Password: %s\n", password)
51 | },
52 | }
53 |
54 | userDeleteCmd = &cobra.Command{
55 | Use: "delete",
56 | Short: "Delete a user",
57 | Run: func(cmd *cobra.Command, args []string) {
58 | if email == "" {
59 | log.Fatal("email is required")
60 | }
61 |
62 | user, err := store.FindUserByEmail(email)
63 | if err != nil {
64 | log.WithError(err).Fatal("could not find user")
65 | }
66 |
67 | if err := store.DeleteUser(user); err != nil {
68 | log.WithError(err).Fatal("could not delete user")
69 | }
70 |
71 | fmt.Printf("Deleted user %s\n", email)
72 | },
73 | }
74 |
75 | userChangePasswordCmd = &cobra.Command{
76 | Use: "password",
77 | Short: "Change a user's password",
78 | Run: func(cmd *cobra.Command, args []string) {
79 | if email == "" {
80 | log.Fatal("email is required")
81 | }
82 | if password == "" {
83 | log.Fatal("password is required")
84 | }
85 |
86 | user, err := store.FindUserByEmail(email)
87 | if err != nil {
88 | log.WithError(err).Fatal("could not find user")
89 | }
90 |
91 | user.UpdatePassword(password)
92 | if err := store.SaveUser(&user); err != nil {
93 | log.WithError(err).Fatal("could not save user")
94 | }
95 |
96 | fmt.Printf("Changed password for user %s\n", email)
97 | },
98 | }
99 | )
100 |
101 | func init() {
102 | userCmd.AddCommand(userCreateCmd)
103 | userCreateCmd.Flags().StringVar(&email, "email", "", "email address of the user")
104 | userCreateCmd.Flags().StringVar(&password, "password", "", "password of the user")
105 | userCreateCmd.Flags().BoolVar(&isSuperAdmin, "super-admin", false, "make the user a super admin")
106 |
107 | userCmd.AddCommand(userDeleteCmd)
108 | userDeleteCmd.Flags().StringVar(&email, "email", "", "email address of the user")
109 |
110 | userCmd.AddCommand(userChangePasswordCmd)
111 | userChangePasswordCmd.Flags().StringVar(&email, "email", "", "email address of the user")
112 | userChangePasswordCmd.Flags().StringVar(&password, "password", "", "new password of the user")
113 | }
114 |
--------------------------------------------------------------------------------
/cmd/version.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/spf13/cobra"
7 | )
8 |
9 | var (
10 | version = "dev"
11 | commit = "HEAD"
12 |
13 | versionCmd = &cobra.Command{
14 | Use: "version",
15 | Short: "Print the version",
16 | Run: func(cmd *cobra.Command, args []string) {
17 | fmt.Printf("Ticker version: %s (commit: %s)\n", version, commit)
18 | },
19 | }
20 | )
21 |
--------------------------------------------------------------------------------
/config.yml.dist:
--------------------------------------------------------------------------------
1 | # listen binds ticker to specific address and port
2 | listen: "localhost:8080"
3 | # log_level sets log level for logrus
4 | log_level: "error"
5 | # log_format sets log format for logrus (default: json)
6 | log_format: "json"
7 | # configuration for the database
8 | database:
9 | type: "sqlite" # postgres, mysql, sqlite
10 | dsn: "ticker.db" # postgres: "host=localhost port=5432 user=ticker dbname=ticker password=ticker sslmode=disable"
11 | # secret used for JSON Web Tokens
12 | secret: "slorp-panfil-becall-dorp-hashab-incus-biter-lyra-pelage-sarraf-drunk"
13 | # telegram configuration
14 | telegram:
15 | token: ""
16 | # signal group configuration
17 | signal_group:
18 | api_url: ""
19 | avatar: ""
20 | account: ""
21 | # listen port for prometheus metrics exporter
22 | metrics_listen: ":8181"
23 | upload:
24 | # path where to store the uploaded files
25 | path: "uploads"
26 | # base url for uploaded assets
27 | url: "http://localhost:8080"
28 |
--------------------------------------------------------------------------------
/docs/api.md:
--------------------------------------------------------------------------------
1 | # API Specification
2 |
3 | !!! Information
4 |
5 | The API Specification only contains the public endpoints at the moment.
6 |
7 | !!swagger swagger.yaml!!
8 |
--------------------------------------------------------------------------------
/docs/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/systemli/ticker/e99bb9693759e805bd80364878a10d58e4f329ec/docs/assets/logo.png
--------------------------------------------------------------------------------
/docs/assets/ticker-api.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=ticker-api
3 | After=network.target
4 | StartLimitIntervalSec=0
5 | [Service]
6 | Type=simple
7 | Restart=always
8 | RestartSec=1
9 | User=ticker
10 | # If you build ticker on this machine, include the first "ExecStart"
11 | # If you downloaded ticker from github, include the second "ExecStart"
12 | # If you put ticker in a different location, you can probably figure out what to change. ;)
13 | #ExecStart=/var/www/ticker/build/ticker run --config /var/www/ticker/config.yml
14 | #ExecStart=/var/www/ticker/ticker run --config /var/www/ticker/config.yml
15 | WorkingDirectory=/var/www/ticker
16 | [Install]
17 | WantedBy=multi-user.target
18 |
--------------------------------------------------------------------------------
/docs/configuration.md:
--------------------------------------------------------------------------------
1 | # Configuration
2 |
3 | ```yaml
4 | # listen binds ticker to specific address and port
5 | listen: "localhost:8080"
6 | # log_level sets log level for logrus
7 | log_level: "error"
8 | # log_format sets log format for logrus (default: json)
9 | log_format: "json"
10 | # configuration for the database
11 | database:
12 | type: "sqlite" # postgres, mysql, sqlite
13 | dsn: "ticker.db" # postgres: "host=localhost port=5432 user=ticker dbname=ticker password=ticker sslmode=disable"
14 | # secret used for JSON Web Tokens
15 | secret: "slorp-panfil-becall-dorp-hashab-incus-biter-lyra-pelage-sarraf-drunk"
16 | # telegram configuration
17 | telegram:
18 | token: "" # telegram bot token
19 | # signal group configuration
20 | signal_group:
21 | api_url: "" # URL to your signal cli (https://github.com/AsamK/signal-cli)
22 | avatar: "" # URL to the avatar for the signal group
23 | account: "" # phone number for the signal account
24 | # listen port for prometheus metrics exporter
25 | metrics_listen: ":8181"
26 | upload:
27 | # path where to store the uploaded files
28 | path: "uploads"
29 | # base url for uploaded assets
30 | url: "http://localhost:8080"
31 | ```
32 |
33 | !!! note
34 | All configuration options can be set via environment variables.
35 |
36 | The following env vars can be used:
37 |
38 | * `TICKER_LISTEN`
39 | * `TICKER_LOG_FORMAT`
40 | * `TICKER_LOG_LEVEL`
41 | * `TICKER_DATABASE_TYPE`
42 | * `TICKER_DATABASE_DSN`
43 | * `TICKER_LOG_LEVEL`
44 | * `TICKER_INITIATOR`
45 | * `TICKER_SECRET`
46 | * `TICKER_TELEGRAM_TOKEN`
47 | * `TICKER_SIGNAL_GROUP_API_URL`
48 | * `TICKER_SIGNAL_GROUP_AVATAR`
49 | * `TICKER_SIGNAL_GROUP_ACCOUNT`
50 | * `TICKER_METRICS_LISTEN`
51 | * `TICKER_UPLOAD_PATH`
52 | * `TICKER_UPLOAD_URL`
53 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # Systemli Ticker
2 |
3 | **Service to distribute short messages in support of events, demonstrations, or other time-sensitive events.**
4 |
5 | This repository contains the API for the [Systemli Ticker Project](https://www.systemli.org/en/service/ticker.html).
6 |
7 | !!! note "Requirements"
8 |
9 | The project is written in Go. You should be familiar with the structure and organisation of the code. If not, there are
10 | some [good guides](https://golang.org/doc/code.html).
11 |
12 | ## First run
13 |
14 | 1. Clone the project
15 |
16 | git clone https://github.com/systemli/ticker.git
17 |
18 | 2. Start the project
19 |
20 | cd ticker
21 | go run . run
22 |
23 | 3. Check the API
24 |
25 | curl http://localhost:8080/healthz
26 |
27 | 4. Create a user
28 |
29 | go run . user create --email --password --super-admin
30 |
31 | ## Testing
32 |
33 | ```shell
34 | go test ./...
35 | ```
36 |
--------------------------------------------------------------------------------
/internal/api/api_test.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "io"
7 | "net/http"
8 | "net/http/httptest"
9 | "strings"
10 | "testing"
11 | "time"
12 |
13 | "github.com/gin-gonic/gin"
14 | "github.com/sirupsen/logrus"
15 | "github.com/stretchr/testify/mock"
16 | "github.com/stretchr/testify/suite"
17 | "github.com/systemli/ticker/internal/api/response"
18 | "github.com/systemli/ticker/internal/config"
19 | "github.com/systemli/ticker/internal/logger"
20 | "github.com/systemli/ticker/internal/storage"
21 | )
22 |
23 | type APITestSuite struct {
24 | cfg config.Config
25 | store *storage.MockStorage
26 | logger *logrus.Logger
27 | suite.Suite
28 | }
29 |
30 | func (s *APITestSuite) SetupTest() {
31 | gin.SetMode(gin.TestMode)
32 | logrus.SetOutput(io.Discard)
33 |
34 | s.cfg = config.LoadConfig("")
35 | s.store = &storage.MockStorage{}
36 |
37 | logger := logger.NewLogrus("debug", "text")
38 | logger.SetOutput(io.Discard)
39 | s.logger = logger
40 | }
41 |
42 | func (s *APITestSuite) TestHealthz() {
43 | r := API(s.cfg, s.store, s.logger)
44 | req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
45 | w := httptest.NewRecorder()
46 | r.ServeHTTP(w, req)
47 |
48 | s.Equal(http.StatusOK, w.Code)
49 | s.store.AssertExpectations(s.T())
50 | }
51 |
52 | func (s *APITestSuite) TestLogin() {
53 | s.Run("when password is wrong", func() {
54 | user, err := storage.NewUser("user@systemli.org", "password")
55 | s.NoError(err)
56 | s.store.On("FindUserByEmail", mock.Anything, mock.Anything).Return(user, nil)
57 | r := API(s.cfg, s.store, s.logger)
58 |
59 | body := `{"username":"louis@systemli.org","password":"WRONG"}`
60 | req := httptest.NewRequest(http.MethodPost, "/v1/admin/login", strings.NewReader(body))
61 | req.Header.Add("Content-Type", "application/json")
62 | w := httptest.NewRecorder()
63 | r.ServeHTTP(w, req)
64 |
65 | var res response.Response
66 | err = json.Unmarshal(w.Body.Bytes(), &res)
67 | s.NoError(err)
68 | s.Equal(http.StatusUnauthorized, w.Code)
69 | s.Nil(res.Data)
70 | s.Equal(res.Error.Code, response.CodeBadCredentials)
71 | s.Equal(res.Error.Message, response.Unauthorized)
72 | s.store.AssertExpectations(s.T())
73 | })
74 |
75 | s.Run("when login is successful", func() {
76 | user, err := storage.NewUser("user@systemli.org", "password")
77 | s.NoError(err)
78 | s.store.On("FindUserByEmail", mock.Anything, mock.Anything).Return(user, nil)
79 | s.store.On("SaveUser", mock.Anything).Return(nil)
80 | r := API(s.cfg, s.store, s.logger)
81 |
82 | body := `{"username":"louis@systemli.org","password":"password"}`
83 | req := httptest.NewRequest(http.MethodPost, "/v1/admin/login", strings.NewReader(body))
84 | req.Header.Add("Content-Type", "application/json")
85 | w := httptest.NewRecorder()
86 | r.ServeHTTP(w, req)
87 |
88 | var res struct {
89 | Code int `json:"code"`
90 | Expire time.Time `json:"expire"`
91 | Token string `json:"token"`
92 | }
93 | err = json.Unmarshal(w.Body.Bytes(), &res)
94 | s.NoError(err)
95 | s.Equal(http.StatusOK, w.Code)
96 | s.Equal(http.StatusOK, res.Code)
97 | s.NotNil(res.Expire)
98 | s.NotNil(res.Token)
99 | s.store.AssertExpectations(s.T())
100 | })
101 |
102 | s.Run("when save user fails", func() {
103 | user, err := storage.NewUser("user@systemli.org", "password")
104 | s.NoError(err)
105 | s.store.On("FindUserByEmail", mock.Anything, mock.Anything).Return(user, nil)
106 | s.store.On("SaveUser", mock.Anything).Return(errors.New("failed to save user"))
107 |
108 | r := API(s.cfg, s.store, s.logger)
109 |
110 | body := `{"username":"louis@systemli.org","password":"password"}`
111 | req := httptest.NewRequest(http.MethodPost, "/v1/admin/login", strings.NewReader(body))
112 | req.Header.Add("Content-Type", "application/json")
113 | w := httptest.NewRecorder()
114 | r.ServeHTTP(w, req)
115 |
116 | s.Equal(http.StatusOK, w.Code)
117 | })
118 | }
119 |
120 | func TestAPITestSuite(t *testing.T) {
121 | suite.Run(t, new(APITestSuite))
122 | }
123 |
--------------------------------------------------------------------------------
/internal/api/features.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/systemli/ticker/internal/api/response"
8 | "github.com/systemli/ticker/internal/config"
9 | )
10 |
11 | type FeaturesResponse map[string]bool
12 |
13 | func NewFeaturesResponse(config config.Config) FeaturesResponse {
14 | return FeaturesResponse{
15 | "telegramEnabled": config.Telegram.Enabled(),
16 | "signalGroupEnabled": config.SignalGroup.Enabled(),
17 | }
18 | }
19 |
20 | func (h *handler) GetFeatures(c *gin.Context) {
21 | features := NewFeaturesResponse(h.config)
22 | c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"features": features}))
23 | }
24 |
--------------------------------------------------------------------------------
/internal/api/features_test.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "testing"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/stretchr/testify/suite"
10 | "github.com/systemli/ticker/internal/config"
11 | "github.com/systemli/ticker/internal/storage"
12 | )
13 |
14 | type FeaturesTestSuite struct {
15 | suite.Suite
16 | }
17 |
18 | func (s *FeaturesTestSuite) SetupTest() {
19 | gin.SetMode(gin.TestMode)
20 | }
21 |
22 | func (s *FeaturesTestSuite) TestGetFeatures() {
23 | w := httptest.NewRecorder()
24 | c, _ := gin.CreateTestContext(w)
25 | store := &storage.MockStorage{}
26 |
27 | h := handler{
28 | storage: store,
29 | config: config.LoadConfig(""),
30 | }
31 |
32 | h.GetFeatures(c)
33 |
34 | s.Equal(http.StatusOK, w.Code)
35 | s.Equal(`{"data":{"features":{"signalGroupEnabled":false,"telegramEnabled":false}},"status":"success","error":{}}`, w.Body.String())
36 | }
37 |
38 | func TestFeaturesTestSuite(t *testing.T) {
39 | suite.Run(t, new(FeaturesTestSuite))
40 | }
41 |
--------------------------------------------------------------------------------
/internal/api/feed.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 | "time"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/gorilla/feeds"
10 | "github.com/systemli/ticker/internal/api/helper"
11 | "github.com/systemli/ticker/internal/api/pagination"
12 | "github.com/systemli/ticker/internal/api/renderer"
13 | "github.com/systemli/ticker/internal/api/response"
14 | "github.com/systemli/ticker/internal/storage"
15 | )
16 |
17 | func (h *handler) GetFeed(c *gin.Context) {
18 | ticker, err := helper.Ticker(c)
19 | if err != nil {
20 | c.JSON(http.StatusOK, response.ErrorResponse(response.CodeDefault, response.TickerNotFound))
21 | return
22 | }
23 |
24 | pagination := pagination.NewPagination(c)
25 | messages, err := h.storage.FindMessagesByTickerAndPagination(ticker, *pagination)
26 | if err != nil {
27 | c.JSON(http.StatusOK, response.ErrorResponse(response.CodeDefault, response.MessageFetchError))
28 | return
29 | }
30 |
31 | format := renderer.FormatFromString(c.Query("format"))
32 | feed := buildFeed(ticker, messages)
33 |
34 | c.Render(http.StatusOK, renderer.Feed{Data: feed, Format: format})
35 | }
36 |
37 | func buildFeed(ticker storage.Ticker, messages []storage.Message) *feeds.Feed {
38 | feed := &feeds.Feed{
39 | Title: ticker.Title,
40 | Description: ticker.Description,
41 | Author: &feeds.Author{
42 | Name: ticker.Information.Author,
43 | Email: ticker.Information.Email,
44 | },
45 | Link: &feeds.Link{
46 | Href: ticker.Information.URL,
47 | },
48 | Created: time.Now(),
49 | }
50 |
51 | items := make([]*feeds.Item, 0)
52 | for _, message := range messages {
53 | item := &feeds.Item{
54 | Id: strconv.Itoa(message.ID),
55 | Created: message.CreatedAt,
56 | Description: message.Text,
57 | Title: message.Text,
58 | Link: &feeds.Link{},
59 | }
60 | items = append(items, item)
61 | }
62 | feed.Items = items
63 |
64 | return feed
65 | }
66 |
--------------------------------------------------------------------------------
/internal/api/feed_test.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/stretchr/testify/mock"
11 | "github.com/stretchr/testify/suite"
12 | "github.com/systemli/ticker/internal/api/response"
13 | "github.com/systemli/ticker/internal/config"
14 | "github.com/systemli/ticker/internal/storage"
15 | )
16 |
17 | type FeedTestSuite struct {
18 | w *httptest.ResponseRecorder
19 | ctx *gin.Context
20 | store *storage.MockStorage
21 | cfg config.Config
22 | suite.Suite
23 | }
24 |
25 | func (s *FeedTestSuite) SetupTest() {
26 | gin.SetMode(gin.TestMode)
27 |
28 | s.w = httptest.NewRecorder()
29 | s.ctx, _ = gin.CreateTestContext(s.w)
30 | s.store = &storage.MockStorage{}
31 | s.cfg = config.LoadConfig("")
32 | }
33 |
34 | func (s *FeedTestSuite) TestGetFeed() {
35 | s.Run("when ticker not found", func() {
36 | h := s.handler()
37 | h.GetFeed(s.ctx)
38 |
39 | s.Equal(http.StatusOK, s.w.Code)
40 | s.Contains(s.w.Body.String(), response.TickerNotFound)
41 | s.store.AssertExpectations(s.T())
42 | })
43 |
44 | s.Run("when fetching messages fails", func() {
45 | s.ctx.Set("ticker", storage.Ticker{})
46 | s.store.On("FindMessagesByTickerAndPagination", mock.Anything, mock.Anything).Return([]storage.Message{}, errors.New("storage error")).Once()
47 |
48 | h := s.handler()
49 | h.GetFeed(s.ctx)
50 |
51 | s.Equal(http.StatusOK, s.w.Code)
52 | s.Contains(s.w.Body.String(), response.MessageFetchError)
53 | s.store.AssertExpectations(s.T())
54 | })
55 |
56 | s.Run("when fetching messages succeeds", func() {
57 | ticker := storage.Ticker{
58 | ID: 1,
59 | Title: "Title",
60 | Information: storage.TickerInformation{
61 | URL: "https://demoticker.org",
62 | Author: "Author",
63 | Email: "author@demoticker.org",
64 | },
65 | }
66 | s.ctx.Set("ticker", ticker)
67 | s.ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/feed?format=atom", nil)
68 | message := storage.Message{
69 | TickerID: ticker.ID,
70 | Text: "Text",
71 | }
72 | s.store.On("FindMessagesByTickerAndPagination", mock.Anything, mock.Anything).Return([]storage.Message{message}, nil).Once()
73 |
74 | h := s.handler()
75 | h.GetFeed(s.ctx)
76 |
77 | s.Equal(http.StatusOK, s.w.Code)
78 | s.store.AssertExpectations(s.T())
79 | })
80 | }
81 |
82 | func (s *FeedTestSuite) handler() handler {
83 | return handler{
84 | storage: s.store,
85 | config: s.cfg,
86 | }
87 | }
88 |
89 | func TestFeedTestSuite(t *testing.T) {
90 | suite.Run(t, new(FeedTestSuite))
91 | }
92 |
--------------------------------------------------------------------------------
/internal/api/helper/util.go:
--------------------------------------------------------------------------------
1 | package helper
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "github.com/gin-gonic/gin"
7 | "github.com/systemli/ticker/internal/storage"
8 | "net/url"
9 | )
10 |
11 | func GetOrigin(c *gin.Context) (string, error) {
12 | var origin string
13 | if c.Request.URL.Query().Has("origin") {
14 | origin = c.Request.URL.Query().Get("origin")
15 | } else {
16 | origin = c.Request.Header.Get("Origin")
17 | }
18 |
19 | u, err := url.Parse(origin)
20 | if err != nil {
21 | return "", err
22 | }
23 |
24 | if u.Scheme == "" || u.Host == "" {
25 | return "", errors.New("invalid origin")
26 | }
27 |
28 | return fmt.Sprintf("%s://%s", u.Scheme, u.Host), nil
29 | }
30 |
31 | func Me(c *gin.Context) (storage.User, error) {
32 | var user storage.User
33 | u, exists := c.Get("me")
34 | if !exists {
35 | return user, errors.New("me not found")
36 | }
37 |
38 | return u.(storage.User), nil
39 | }
40 |
41 | func IsAdmin(c *gin.Context) bool {
42 | u, err := Me(c)
43 | if err != nil {
44 | return false
45 | }
46 |
47 | return u.IsSuperAdmin
48 | }
49 |
50 | func Ticker(c *gin.Context) (storage.Ticker, error) {
51 | ticker, exists := c.Get("ticker")
52 | if !exists {
53 | return storage.Ticker{}, errors.New("ticker not found")
54 | }
55 |
56 | return ticker.(storage.Ticker), nil
57 | }
58 |
59 | func Message(c *gin.Context) (storage.Message, error) {
60 | message, exists := c.Get("message")
61 | if !exists {
62 | return storage.Message{}, errors.New("message not found")
63 | }
64 |
65 | return message.(storage.Message), nil
66 | }
67 |
68 | func User(c *gin.Context) (storage.User, error) {
69 | user, exists := c.Get("user")
70 | if !exists {
71 | return storage.User{}, errors.New("user not found")
72 | }
73 |
74 | return user.(storage.User), nil
75 | }
76 |
--------------------------------------------------------------------------------
/internal/api/helper/util_test.go:
--------------------------------------------------------------------------------
1 | package helper
2 |
3 | import (
4 | "net/http"
5 | "net/url"
6 | "testing"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/stretchr/testify/suite"
10 | "github.com/systemli/ticker/internal/storage"
11 | )
12 |
13 | type UtilTestSuite struct {
14 | suite.Suite
15 | }
16 |
17 | func (s *UtilTestSuite) TestGetOrigin() {
18 | s.Run("when origin is empty", func() {
19 | c := s.buildContext(url.URL{}, http.Header{})
20 | origin, err := GetOrigin(c)
21 | s.Equal("", origin)
22 | s.Equal("invalid origin", err.Error())
23 | })
24 |
25 | s.Run("when origin is not a valid URL", func() {
26 | c := s.buildContext(url.URL{}, http.Header{
27 | "Origin": []string{"localhost"},
28 | })
29 | origin, err := GetOrigin(c)
30 | s.Equal("", origin)
31 | s.Error(err)
32 | })
33 |
34 | s.Run("when origin is localhost", func() {
35 | c := s.buildContext(url.URL{}, http.Header{
36 | "Origin": []string{"http://localhost"},
37 | })
38 | origin, err := GetOrigin(c)
39 | s.Equal("http://localhost", origin)
40 | s.NoError(err)
41 | })
42 |
43 | s.Run("when origin is localhost with port", func() {
44 | c := s.buildContext(url.URL{}, http.Header{
45 | "Origin": []string{"http://localhost:3000"},
46 | })
47 | origin, err := GetOrigin(c)
48 | s.Equal("http://localhost:3000", origin)
49 | s.NoError(err)
50 | })
51 |
52 | s.Run("when origin has subdomain", func() {
53 | c := s.buildContext(url.URL{}, http.Header{
54 | "Origin": []string{"http://www.demoticker.org/"},
55 | })
56 | origin, err := GetOrigin(c)
57 | s.Equal("http://www.demoticker.org", origin)
58 | s.NoError(err)
59 | })
60 |
61 | s.Run("when query param is set", func() {
62 | c := s.buildContext(url.URL{RawQuery: "origin=http://another.demoticker.org"}, http.Header{
63 | "Origin": []string{"http://www.demoticker.org/"},
64 | })
65 | domain, err := GetOrigin(c)
66 | s.Equal("http://another.demoticker.org", domain)
67 | s.NoError(err)
68 | })
69 | }
70 |
71 | func (s *UtilTestSuite) TestMe() {
72 | s.Run("when me is not set", func() {
73 | c := &gin.Context{}
74 | _, err := Me(c)
75 | s.Equal("me not found", err.Error())
76 | })
77 |
78 | s.Run("when me is set", func() {
79 | c := &gin.Context{}
80 | c.Set("me", storage.User{})
81 | _, err := Me(c)
82 | s.NoError(err)
83 | })
84 | }
85 |
86 | func (s *UtilTestSuite) TestIsAdmin() {
87 | s.Run("when me is not set", func() {
88 | c := &gin.Context{}
89 | isAdmin := IsAdmin(c)
90 | s.False(isAdmin)
91 | })
92 |
93 | s.Run("when me is set", func() {
94 | c := &gin.Context{}
95 | c.Set("me", storage.User{IsSuperAdmin: true})
96 | isAdmin := IsAdmin(c)
97 | s.True(isAdmin)
98 | })
99 | }
100 |
101 | func (s *UtilTestSuite) TestTicker() {
102 | s.Run("when ticker is not set", func() {
103 | c := &gin.Context{}
104 | _, err := Ticker(c)
105 | s.Equal("ticker not found", err.Error())
106 | })
107 |
108 | s.Run("when ticker is set", func() {
109 | c := &gin.Context{}
110 | c.Set("ticker", storage.Ticker{})
111 | _, err := Ticker(c)
112 | s.NoError(err)
113 | })
114 | }
115 |
116 | func (s *UtilTestSuite) TestMessage() {
117 | s.Run("when message is not set", func() {
118 | c := &gin.Context{}
119 | _, err := Message(c)
120 | s.Equal("message not found", err.Error())
121 | })
122 |
123 | s.Run("when message is set", func() {
124 | c := &gin.Context{}
125 | c.Set("message", storage.Message{})
126 | _, err := Message(c)
127 | s.NoError(err)
128 | })
129 | }
130 |
131 | func (s *UtilTestSuite) TestUser() {
132 | s.Run("when user is not set", func() {
133 | c := &gin.Context{}
134 | _, err := User(c)
135 | s.Equal("user not found", err.Error())
136 | })
137 |
138 | s.Run("when user is set", func() {
139 | c := &gin.Context{}
140 | c.Set("user", storage.User{})
141 | _, err := User(c)
142 | s.NoError(err)
143 | })
144 | }
145 |
146 | func (s *UtilTestSuite) buildContext(u url.URL, headers http.Header) *gin.Context {
147 | req := http.Request{
148 | Header: headers,
149 | URL: &u,
150 | }
151 |
152 | return &gin.Context{Request: &req}
153 | }
154 |
155 | func TestUtilTestSuite(t *testing.T) {
156 | suite.Run(t, new(UtilTestSuite))
157 | }
158 |
--------------------------------------------------------------------------------
/internal/api/init.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/systemli/ticker/internal/api/helper"
8 | "github.com/systemli/ticker/internal/api/response"
9 | )
10 |
11 | func (h *handler) GetInit(c *gin.Context) {
12 | settings := response.Settings{
13 | RefreshInterval: h.storage.GetRefreshIntervalSettings().RefreshInterval,
14 | }
15 | origin, err := helper.GetOrigin(c)
16 | if err != nil {
17 | c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"ticker": nil, "settings": settings}))
18 | return
19 | }
20 |
21 | ticker, err := h.storage.FindTickerByOrigin(origin)
22 | if err != nil || !ticker.Active {
23 | settings.InactiveSettings = h.storage.GetInactiveSettings()
24 | c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"ticker": nil, "settings": settings}))
25 | return
26 | }
27 |
28 | data := map[string]interface{}{"ticker": response.InitTickerResponse(ticker), "settings": settings}
29 | c.JSON(http.StatusOK, response.SuccessResponse(data))
30 | }
31 |
--------------------------------------------------------------------------------
/internal/api/init_test.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/stretchr/testify/mock"
11 | "github.com/stretchr/testify/suite"
12 | "github.com/systemli/ticker/internal/config"
13 | "github.com/systemli/ticker/internal/storage"
14 | )
15 |
16 | type InitTestSuite struct {
17 | w *httptest.ResponseRecorder
18 | ctx *gin.Context
19 | store *storage.MockStorage
20 | cfg config.Config
21 | suite.Suite
22 | }
23 |
24 | func (s *InitTestSuite) SetupTest() {
25 | gin.SetMode(gin.TestMode)
26 |
27 | s.w = httptest.NewRecorder()
28 | s.ctx, _ = gin.CreateTestContext(s.w)
29 | s.store = &storage.MockStorage{}
30 | s.store.On("GetRefreshIntervalSettings").Return(storage.DefaultRefreshIntervalSettings())
31 | s.cfg = config.LoadConfig("")
32 | }
33 |
34 | func (s *InitTestSuite) TestGetInit() {
35 | s.Run("if neither header nor query is set", func() {
36 | s.ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/init", nil)
37 | h := s.handler()
38 | h.GetInit(s.ctx)
39 |
40 | s.Equal(http.StatusOK, s.w.Code)
41 | s.Equal(`{"data":{"settings":{"refreshInterval":10000},"ticker":null},"status":"success","error":{}}`, s.w.Body.String())
42 | s.store.AssertNotCalled(s.T(), "FindTickerByOrigin", "", mock.Anything)
43 | s.store.AssertExpectations(s.T())
44 | })
45 |
46 | s.Run("when database returns error", func() {
47 | s.ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/init?origin=https://demoticker.org", nil)
48 | s.store.On("FindTickerByOrigin", "https://demoticker.org", mock.Anything).Return(storage.Ticker{}, errors.New("storage error")).Once()
49 | s.store.On("GetInactiveSettings").Return(storage.DefaultInactiveSettings()).Once()
50 | h := s.handler()
51 | h.GetInit(s.ctx)
52 |
53 | s.Equal(http.StatusOK, s.w.Code)
54 | s.Contains(s.w.Body.String(), `"ticker":null`)
55 | s.store.AssertCalled(s.T(), "FindTickerByOrigin", "https://demoticker.org", mock.Anything)
56 | s.store.AssertExpectations(s.T())
57 | })
58 |
59 | s.Run("when database returns an inactive ticker", func() {
60 | s.ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/init?origin=https://demoticker.org", nil)
61 | ticker := storage.NewTicker()
62 | ticker.Active = false
63 | s.store.On("FindTickerByOrigin", "https://demoticker.org", mock.Anything).Return(ticker, nil).Once()
64 | s.store.On("GetInactiveSettings").Return(storage.DefaultInactiveSettings()).Once()
65 | h := s.handler()
66 | h.GetInit(s.ctx)
67 |
68 | s.Equal(http.StatusOK, s.w.Code)
69 | s.Contains(s.w.Body.String(), `"ticker":null`)
70 | s.store.AssertCalled(s.T(), "FindTickerByOrigin", "https://demoticker.org", mock.Anything)
71 | })
72 |
73 | s.Run("when database returns an active ticker", func() {
74 | s.ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/init?origin=https://demoticker.org", nil)
75 | ticker := storage.NewTicker()
76 | ticker.Active = true
77 | s.store.On("FindTickerByOrigin", "https://demoticker.org", mock.Anything).Return(ticker, nil).Once()
78 | s.store.On("GetInactiveSettings").Return(storage.DefaultInactiveSettings()).Once()
79 | h := s.handler()
80 | h.GetInit(s.ctx)
81 |
82 | s.Equal(http.StatusOK, s.w.Code)
83 | s.store.AssertCalled(s.T(), "FindTickerByOrigin", "https://demoticker.org", mock.Anything)
84 | })
85 | }
86 |
87 | func (s *InitTestSuite) handler() handler {
88 | return handler{
89 | storage: s.store,
90 | config: s.cfg,
91 | }
92 | }
93 |
94 | func TestInitTestSuite(t *testing.T) {
95 | suite.Run(t, new(InitTestSuite))
96 | }
97 |
--------------------------------------------------------------------------------
/internal/api/media.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "strings"
7 | "time"
8 |
9 | "github.com/gin-gonic/gin"
10 | )
11 |
12 | func (h *handler) GetMedia(c *gin.Context) {
13 | parts := strings.Split(c.Param("fileName"), ".")
14 | upload, err := h.storage.FindUploadByUUID(parts[0])
15 | if err != nil {
16 | c.String(http.StatusNotFound, "%s", err.Error())
17 | return
18 | }
19 |
20 | expireTime := time.Now().AddDate(0, 1, 0)
21 | cacheControl := fmt.Sprintf("public, max-age=%d", expireTime.Unix())
22 | expires := expireTime.Format(http.TimeFormat)
23 |
24 | c.Header("Cache-Control", cacheControl)
25 | c.Header("Expires", expires)
26 | c.File(upload.FullPath(h.storage.UploadPath()))
27 | }
28 |
--------------------------------------------------------------------------------
/internal/api/media_test.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/stretchr/testify/mock"
11 | "github.com/stretchr/testify/suite"
12 | "github.com/systemli/ticker/internal/config"
13 | "github.com/systemli/ticker/internal/storage"
14 | )
15 |
16 | type MediaTestSuite struct {
17 | w *httptest.ResponseRecorder
18 | ctx *gin.Context
19 | store *storage.MockStorage
20 | cfg config.Config
21 | suite.Suite
22 | }
23 |
24 | func (s *MediaTestSuite) SetupTest() {
25 | gin.SetMode(gin.TestMode)
26 |
27 | s.w = httptest.NewRecorder()
28 | s.ctx, _ = gin.CreateTestContext(s.w)
29 | s.ctx.Request = httptest.NewRequest(http.MethodGet, "/media", nil)
30 | s.store = &storage.MockStorage{}
31 | s.cfg = config.LoadConfig("")
32 | }
33 |
34 | func (s *MediaTestSuite) TestGetMedia() {
35 | s.Run("when upload not found", func() {
36 | s.store.On("FindUploadByUUID", mock.Anything).Return(storage.Upload{}, errors.New("not found")).Once()
37 | h := s.handler()
38 | h.GetMedia(s.ctx)
39 |
40 | s.Equal(http.StatusNotFound, s.w.Code)
41 | s.store.AssertExpectations(s.T())
42 | })
43 |
44 | s.Run("when upload found", func() {
45 | upload := storage.NewUpload("image.jpg", "image/jpeg", 1)
46 | s.store.On("FindUploadByUUID", mock.Anything).Return(upload, nil).Once()
47 | s.store.On("UploadPath").Return("./uploads").Once()
48 |
49 | h := s.handler()
50 | h.GetMedia(s.ctx)
51 |
52 | s.Equal(http.StatusNotFound, s.w.Code)
53 | s.store.AssertExpectations(s.T())
54 | })
55 | }
56 |
57 | func (s *MediaTestSuite) handler() handler {
58 | return handler{
59 | storage: s.store,
60 | config: s.cfg,
61 | }
62 | }
63 |
64 | func TestMediaTestSuite(t *testing.T) {
65 | suite.Run(t, new(MediaTestSuite))
66 | }
67 |
--------------------------------------------------------------------------------
/internal/api/messages.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "strings"
7 |
8 | "github.com/gin-gonic/gin"
9 | geojson "github.com/paulmach/go.geojson"
10 | "github.com/systemli/ticker/internal/api/helper"
11 | "github.com/systemli/ticker/internal/api/pagination"
12 | "github.com/systemli/ticker/internal/api/response"
13 | "github.com/systemli/ticker/internal/storage"
14 | )
15 |
16 | func (h *handler) GetMessages(c *gin.Context) {
17 | ticker, err := helper.Ticker(c)
18 | if err != nil {
19 | c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound))
20 | return
21 | }
22 |
23 | pagination := pagination.NewPagination(c)
24 | messages, err := h.storage.FindMessagesByTickerAndPagination(ticker, *pagination, storage.WithAttachments())
25 | if err != nil {
26 | c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.StorageError))
27 | return
28 | }
29 |
30 | data := map[string]interface{}{"messages": response.MessagesResponse(messages, h.config)}
31 | c.JSON(http.StatusOK, response.SuccessResponse(data))
32 | }
33 |
34 | func (h *handler) GetMessage(c *gin.Context) {
35 | message, err := helper.Message(c)
36 | if err != nil {
37 | c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound))
38 | return
39 | }
40 |
41 | data := map[string]interface{}{"message": response.MessageResponse(message, h.config)}
42 | c.JSON(http.StatusOK, response.SuccessResponse(data))
43 | }
44 |
45 | func (h *handler) PostMessage(c *gin.Context) {
46 | ticker, err := helper.Ticker(c)
47 | if err != nil {
48 | c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound))
49 | return
50 | }
51 |
52 | var body struct {
53 | Text string `json:"text" binding:"required"`
54 | GeoInformation geojson.FeatureCollection `json:"geoInformation"`
55 | Attachments []int `json:"attachments"`
56 | }
57 | err = c.Bind(&body)
58 | if err != nil {
59 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.FormError))
60 | return
61 | }
62 |
63 | var uploads []storage.Upload
64 | if len(body.Attachments) > 0 {
65 | uploads, err = h.storage.FindUploadsByIDs(body.Attachments)
66 | if err != nil {
67 | c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeNotFound, response.UploadsNotFound))
68 | return
69 | }
70 | }
71 |
72 | message := storage.NewMessage()
73 | message.Text = body.Text
74 | message.TickerID = ticker.ID
75 | message.GeoInformation = body.GeoInformation
76 | message.AddAttachments(uploads)
77 |
78 | _ = h.bridges.Send(ticker, &message)
79 |
80 | err = h.storage.SaveMessage(&message)
81 | if err != nil {
82 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError))
83 | return
84 | }
85 |
86 | c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"message": response.MessageResponse(message, h.config)}))
87 | }
88 |
89 | func (h *handler) DeleteMessage(c *gin.Context) {
90 | ticker, err := helper.Ticker(c)
91 | if err != nil {
92 | c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound))
93 | return
94 | }
95 |
96 | message, err := helper.Message(c)
97 | if err != nil {
98 | c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound))
99 | return
100 | }
101 |
102 | _ = h.bridges.Delete(ticker, &message)
103 |
104 | err = h.storage.DeleteMessage(message)
105 | if err != nil {
106 | c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.StorageError))
107 | return
108 | }
109 |
110 | h.ClearMessagesCache(&ticker)
111 |
112 | c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{}))
113 | }
114 |
115 | // ClearMessagesCache clears the cache for the timeline endpoint of a ticker
116 | func (h *handler) ClearMessagesCache(ticker *storage.Ticker) {
117 | h.cache.Range(func(key, value interface{}) bool {
118 | if strings.HasPrefix(key.(string), fmt.Sprintf("response:%s:/v1/timeline", ticker.Domain)) {
119 | h.cache.Delete(key)
120 | }
121 |
122 | return true
123 | })
124 | }
125 |
--------------------------------------------------------------------------------
/internal/api/middleware/auth/auth.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "errors"
5 | "time"
6 |
7 | jwt "github.com/appleboy/gin-jwt/v2"
8 | "github.com/gin-gonic/gin"
9 | "github.com/sirupsen/logrus"
10 | "github.com/systemli/ticker/internal/api/response"
11 | "github.com/systemli/ticker/internal/storage"
12 | )
13 |
14 | var log = logrus.WithField("package", "auth")
15 |
16 | func AuthMiddleware(s storage.Storage, secret string) *jwt.GinJWTMiddleware {
17 | config := &jwt.GinJWTMiddleware{
18 | Realm: "ticker admin",
19 | Key: []byte(secret),
20 | Timeout: time.Hour * 24,
21 | MaxRefresh: time.Hour * 24,
22 | Authenticator: Authenticator(s),
23 | Authorizator: Authorizator(s),
24 | Unauthorized: Unauthorized,
25 | PayloadFunc: FillClaim,
26 | TimeFunc: time.Now,
27 | TokenLookup: "header: Authorization",
28 | IdentityKey: "id",
29 | }
30 |
31 | middleware, err := jwt.New(config)
32 | if err != nil {
33 | log.WithError(err).Fatal()
34 | }
35 |
36 | return middleware
37 | }
38 |
39 | func Authenticator(s storage.Storage) func(c *gin.Context) (interface{}, error) {
40 | return func(c *gin.Context) (interface{}, error) {
41 | type login struct {
42 | Username string `form:"username" json:"username" binding:"required"`
43 | Password string `form:"password" json:"password" binding:"required"`
44 | }
45 |
46 | var form login
47 | if err := c.ShouldBind(&form); err != nil {
48 | return "", jwt.ErrMissingLoginValues
49 | }
50 |
51 | user, err := s.FindUserByEmail(form.Username, storage.WithPreload())
52 | if err != nil {
53 | log.WithError(err).Debug("user not found")
54 | return "", err
55 | }
56 |
57 | if user.Authenticate(form.Password) {
58 | user.LastLogin = time.Now()
59 | if err = s.SaveUser(&user); err != nil {
60 | log.WithError(err).Error("failed to save user")
61 | }
62 |
63 | return user, nil
64 | }
65 |
66 | return "", errors.New("authentication failed")
67 | }
68 | }
69 |
70 | func Authorizator(s storage.Storage) func(data interface{}, c *gin.Context) bool {
71 | return func(data interface{}, c *gin.Context) bool {
72 | id := int(data.(float64))
73 |
74 | user, err := s.FindUserByID(id)
75 | if err != nil {
76 | log.WithError(err).WithField("data", data).Debug("user not found")
77 | }
78 |
79 | return user.ID != 0
80 | }
81 | }
82 |
83 | func Unauthorized(c *gin.Context, code int, message string) {
84 | log.WithFields(logrus.Fields{"code": code, "message": message, "url": c.Request.URL.String()}).Debug("unauthorized")
85 | c.JSON(code, response.ErrorResponse(response.CodeBadCredentials, response.Unauthorized))
86 | }
87 |
88 | func FillClaim(data interface{}) jwt.MapClaims {
89 | if u, ok := data.(storage.User); ok {
90 | return jwt.MapClaims{
91 | "id": u.ID,
92 | "email": u.Email,
93 | "roles": roles(u),
94 | }
95 | }
96 |
97 | return jwt.MapClaims{}
98 | }
99 |
100 | func roles(u storage.User) []string {
101 | roles := []string{"user"}
102 |
103 | if u.IsSuperAdmin {
104 | roles = append(roles, "admin")
105 | }
106 |
107 | return roles
108 | }
109 |
--------------------------------------------------------------------------------
/internal/api/middleware/auth/auth_test.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "net/http"
7 | "net/http/httptest"
8 | "strings"
9 | "testing"
10 |
11 | jwt "github.com/appleboy/gin-jwt/v2"
12 | "github.com/gin-gonic/gin"
13 | "github.com/stretchr/testify/mock"
14 | "github.com/stretchr/testify/suite"
15 | "github.com/systemli/ticker/internal/api/response"
16 | "github.com/systemli/ticker/internal/storage"
17 | )
18 |
19 | type AuthTestSuite struct {
20 | suite.Suite
21 | }
22 |
23 | func (s *AuthTestSuite) SetupTest() {
24 | gin.SetMode(gin.TestMode)
25 | }
26 |
27 | func (s *AuthTestSuite) TestAuthenticator() {
28 | s.Run("when form is empty", func() {
29 | mockStorage := &storage.MockStorage{}
30 | authenticator := Authenticator(mockStorage)
31 | c, _ := gin.CreateTestContext(httptest.NewRecorder())
32 | c.Request = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{}`))
33 |
34 | _, err := authenticator(c)
35 | s.Error(err)
36 | s.Equal("missing Username or Password", err.Error())
37 | })
38 |
39 | s.Run("when user is not found", func() {
40 | mockStorage := &storage.MockStorage{}
41 | mockStorage.On("FindUserByEmail", mock.Anything, mock.Anything).Return(storage.User{}, errors.New("not found"))
42 |
43 | authenticator := Authenticator(mockStorage)
44 | c, _ := gin.CreateTestContext(httptest.NewRecorder())
45 | c.Request = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"username": "user@systemli.org", "password": "password"}`))
46 | c.Request.Header.Set("Content-Type", "application/json")
47 |
48 | _, err := authenticator(c)
49 | s.Error(err)
50 | s.Equal("not found", err.Error())
51 | })
52 |
53 | s.Run("when user is found", func() {
54 | user, err := storage.NewUser("user@systemli.org", "password")
55 | s.NoError(err)
56 |
57 | mockStorage := &storage.MockStorage{}
58 | mockStorage.On("FindUserByEmail", mock.Anything, mock.Anything).Return(user, nil)
59 | mockStorage.On("SaveUser", mock.Anything).Return(nil)
60 | authenticator := Authenticator(mockStorage)
61 |
62 | s.Run("with correct password", func() {
63 | c, _ := gin.CreateTestContext(httptest.NewRecorder())
64 | c.Request = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"username": "user@systemli.org", "password": "password"}`))
65 | c.Request.Header.Set("Content-Type", "application/json")
66 | user, err := authenticator(c)
67 |
68 | s.NoError(err)
69 | s.Equal("user@systemli.org", user.(storage.User).Email)
70 | })
71 |
72 | s.Run("with incorrect password", func() {
73 | c, _ := gin.CreateTestContext(httptest.NewRecorder())
74 | c.Request = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"username": "user@systemli.org", "password": "password1"}`))
75 | c.Request.Header.Set("Content-Type", "application/json")
76 |
77 | _, err := authenticator(c)
78 |
79 | s.Error(err)
80 | s.Equal("authentication failed", err.Error())
81 | })
82 | })
83 | }
84 |
85 | func (s *AuthTestSuite) TestAuthorizator() {
86 | s.Run("when user is not found", func() {
87 | mockStorage := &storage.MockStorage{}
88 | mockStorage.On("FindUserByID", mock.Anything).Return(storage.User{}, errors.New("not found"))
89 | authorizator := Authorizator(mockStorage)
90 | c, _ := gin.CreateTestContext(httptest.NewRecorder())
91 |
92 | found := authorizator(float64(1), c)
93 | s.False(found)
94 | })
95 |
96 | s.Run("when user is found", func() {
97 | mockStorage := &storage.MockStorage{}
98 | mockStorage.On("FindUserByID", mock.Anything).Return(storage.User{ID: 1}, nil)
99 | authorizator := Authorizator(mockStorage)
100 | c, _ := gin.CreateTestContext(httptest.NewRecorder())
101 |
102 | found := authorizator(float64(1), c)
103 | s.True(found)
104 | })
105 | }
106 |
107 | func (s *AuthTestSuite) TestUnauthorized() {
108 | s.Run("returns a 403 with json payload", func() {
109 | rr := httptest.NewRecorder()
110 | c, _ := gin.CreateTestContext(rr)
111 | c.Request = httptest.NewRequest(http.MethodGet, "/login", nil)
112 |
113 | Unauthorized(c, 403, "unauthorized")
114 |
115 | err := json.Unmarshal(rr.Body.Bytes(), &response.Response{})
116 | s.NoError(err)
117 | s.Equal(403, rr.Code)
118 | })
119 | }
120 |
121 | func (s *AuthTestSuite) TestFillClaims() {
122 | s.Run("when user is empty", func() {
123 | claims := FillClaim("empty")
124 | s.Equal(jwt.MapClaims{}, claims)
125 | })
126 |
127 | s.Run("when user is valid", func() {
128 | user := storage.User{ID: 1, Email: "user@systemli.org", IsSuperAdmin: true}
129 | claims := FillClaim(user)
130 |
131 | s.Equal(jwt.MapClaims{"id": 1, "email": "user@systemli.org", "roles": []string{"user", "admin"}}, claims)
132 | })
133 | }
134 |
135 | func TestAuthTestSuite(t *testing.T) {
136 | suite.Run(t, new(AuthTestSuite))
137 | }
138 |
--------------------------------------------------------------------------------
/internal/api/middleware/cors/cors.go:
--------------------------------------------------------------------------------
1 | package cors
2 |
3 | import (
4 | "github.com/gin-contrib/cors"
5 | "github.com/gin-gonic/gin"
6 | )
7 |
8 | func NewCORS() gin.HandlerFunc {
9 | config := cors.DefaultConfig()
10 | config.AllowAllOrigins = true
11 | config.AllowCredentials = true
12 | config.AllowHeaders = []string{"Authorization", "Origin", "Content-Length", "Content-Type"}
13 | config.AllowMethods = []string{`GET`, `POST`, `PUT`, `DELETE`, `OPTIONS`}
14 |
15 | return cors.New(config)
16 | }
17 |
--------------------------------------------------------------------------------
/internal/api/middleware/logger/logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/sirupsen/logrus"
6 | ginlogrus "github.com/toorop/gin-logrus"
7 | )
8 |
9 | func Logger(logger *logrus.Logger) gin.HandlerFunc {
10 | return ginlogrus.Logger(logger)
11 | }
12 |
--------------------------------------------------------------------------------
/internal/api/middleware/me/me.go:
--------------------------------------------------------------------------------
1 | package me
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/systemli/ticker/internal/api/response"
8 | "github.com/systemli/ticker/internal/storage"
9 | )
10 |
11 | func MeMiddleware(store storage.Storage) gin.HandlerFunc {
12 | return func(c *gin.Context) {
13 | userID, exists := c.Get("id")
14 | if !exists {
15 | c.AbortWithStatusJSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.UserIdentifierMissing))
16 | return
17 | }
18 |
19 | user, err := store.FindUserByID(int(userID.(float64)), storage.WithTickers())
20 | if err != nil {
21 | c.AbortWithStatusJSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.UserNotFound))
22 | return
23 | }
24 |
25 | c.Set("me", user)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/internal/api/middleware/me/me_test.go:
--------------------------------------------------------------------------------
1 | package me
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/stretchr/testify/mock"
11 | "github.com/stretchr/testify/suite"
12 | "github.com/systemli/ticker/internal/storage"
13 | )
14 |
15 | type MeTestSuite struct {
16 | suite.Suite
17 | }
18 |
19 | func (s *MeTestSuite) SetupTest() {
20 | gin.SetMode(gin.TestMode)
21 | }
22 |
23 | func (s *MeTestSuite) TestMeMiddleware() {
24 | s.Run("when id is not present", func() {
25 | mockStorage := &storage.MockStorage{}
26 | mw := MeMiddleware(mockStorage)
27 | w := httptest.NewRecorder()
28 | c, _ := gin.CreateTestContext(w)
29 |
30 | mw(c)
31 |
32 | s.Equal(http.StatusBadRequest, w.Code)
33 | })
34 |
35 | s.Run("when id is present", func() {
36 | s.Run("when user is not found", func() {
37 | mockStorage := &storage.MockStorage{}
38 | mockStorage.On("FindUserByID", mock.Anything, mock.Anything).Return(storage.User{}, errors.New("not found"))
39 | mw := MeMiddleware(mockStorage)
40 | w := httptest.NewRecorder()
41 | c, _ := gin.CreateTestContext(w)
42 | c.Set("id", float64(1))
43 |
44 | mw(c)
45 |
46 | s.Equal(http.StatusBadRequest, w.Code)
47 | })
48 |
49 | s.Run("when user is found", func() {
50 | mockStorage := &storage.MockStorage{}
51 | mockStorage.On("FindUserByID", mock.Anything, mock.Anything).Return(storage.User{ID: 1}, nil)
52 | mw := MeMiddleware(mockStorage)
53 | w := httptest.NewRecorder()
54 | c, _ := gin.CreateTestContext(w)
55 | c.Set("id", float64(1))
56 |
57 | mw(c)
58 |
59 | user, exists := c.Get("me")
60 | s.True(exists)
61 | s.IsType(storage.User{}, user)
62 | })
63 | })
64 | }
65 |
66 | func TestMeTestSuite(t *testing.T) {
67 | suite.Run(t, new(MeTestSuite))
68 | }
69 |
--------------------------------------------------------------------------------
/internal/api/middleware/message/message.go:
--------------------------------------------------------------------------------
1 | package message
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/systemli/ticker/internal/api/helper"
9 | "github.com/systemli/ticker/internal/api/response"
10 | "github.com/systemli/ticker/internal/storage"
11 | )
12 |
13 | func PrefetchMessage(s storage.Storage) gin.HandlerFunc {
14 | return func(c *gin.Context) {
15 | ticker, _ := helper.Ticker(c)
16 |
17 | messageID, err := strconv.Atoi(c.Param("messageID"))
18 | if err != nil {
19 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.TickerIdentifierMissing))
20 | return
21 | }
22 |
23 | message, err := s.FindMessage(ticker.ID, messageID, storage.WithAttachments())
24 | if err != nil {
25 | c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeNotFound, response.MessageNotFound))
26 | return
27 | }
28 |
29 | c.Set("message", message)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/internal/api/middleware/message/message_test.go:
--------------------------------------------------------------------------------
1 | package message
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/stretchr/testify/mock"
11 | "github.com/stretchr/testify/suite"
12 | "github.com/systemli/ticker/internal/storage"
13 | )
14 |
15 | type MessageTestSuite struct {
16 | suite.Suite
17 | }
18 |
19 | func (s *MessageTestSuite) SetupTest() {
20 | gin.SetMode(gin.TestMode)
21 | }
22 |
23 | func (s *MessageTestSuite) TestMessage() {
24 | s.Run("when param is missing", func() {
25 | w := httptest.NewRecorder()
26 | c, _ := gin.CreateTestContext(w)
27 | c.Set("ticker", storage.Ticker{})
28 | store := &storage.MockStorage{}
29 | mw := PrefetchMessage(store)
30 |
31 | mw(c)
32 |
33 | s.Equal(http.StatusBadRequest, w.Code)
34 | })
35 |
36 | s.Run("storage returns error", func() {
37 | w := httptest.NewRecorder()
38 | c, _ := gin.CreateTestContext(w)
39 | c.AddParam("messageID", "1")
40 | c.Set("ticker", storage.Ticker{})
41 | store := &storage.MockStorage{}
42 | store.On("FindMessage", mock.Anything, mock.Anything, mock.Anything).Return(storage.Message{}, errors.New("storage error"))
43 | mw := PrefetchMessage(store)
44 |
45 | mw(c)
46 |
47 | s.Equal(http.StatusNotFound, w.Code)
48 | })
49 |
50 | s.Run("storage returns message", func() {
51 | w := httptest.NewRecorder()
52 | c, _ := gin.CreateTestContext(w)
53 | c.AddParam("messageID", "1")
54 | c.Set("ticker", storage.Ticker{})
55 | store := &storage.MockStorage{}
56 | message := storage.Message{ID: 1}
57 | store.On("FindMessage", mock.Anything, mock.Anything, mock.Anything).Return(message, nil)
58 | mw := PrefetchMessage(store)
59 |
60 | mw(c)
61 |
62 | me, e := c.Get("message")
63 | s.True(e)
64 | s.Equal(message, me.(storage.Message))
65 | })
66 | }
67 |
68 | func TestMessageTestSuite(t *testing.T) {
69 | suite.Run(t, new(MessageTestSuite))
70 | }
71 |
--------------------------------------------------------------------------------
/internal/api/middleware/prometheus/prometheus.go:
--------------------------------------------------------------------------------
1 | package prometheus
2 |
3 | import (
4 | "regexp"
5 | "strconv"
6 | "time"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/prometheus/client_golang/prometheus"
10 | log "github.com/sirupsen/logrus"
11 | "github.com/systemli/ticker/internal/api/helper"
12 | )
13 |
14 | var (
15 | requestDurationHistogram = prometheus.NewHistogramVec(prometheus.HistogramOpts{
16 | Name: "http_request_duration_seconds",
17 | Help: "The HTTP requests latency in seconds",
18 | }, []string{"method", "path", "origin", "code"})
19 | )
20 |
21 | func NewPrometheus() gin.HandlerFunc {
22 | err := prometheus.Register(requestDurationHistogram)
23 | if err != nil {
24 | log.WithError(err).Error(`"requestDurationHistogram" could not be registered in Prometheus`)
25 | return func(c *gin.Context) {}
26 | }
27 |
28 | return func(c *gin.Context) {
29 | start := time.Now()
30 |
31 | c.Next()
32 |
33 | method := c.Request.Method
34 | path := c.FullPath()
35 | origin := prepareOrigin(c)
36 | code := strconv.Itoa(c.Writer.Status())
37 |
38 | requestDurationHistogram.WithLabelValues(method, path, origin, code).Observe(time.Since(start).Seconds())
39 | }
40 | }
41 |
42 | func prepareOrigin(c *gin.Context) string {
43 | origin, err := helper.GetOrigin(c)
44 | if err != nil {
45 | return ""
46 | }
47 |
48 | re := regexp.MustCompile(`^https?://`)
49 |
50 | return re.ReplaceAllString(origin, "")
51 | }
52 |
--------------------------------------------------------------------------------
/internal/api/middleware/prometheus/prometheus_test.go:
--------------------------------------------------------------------------------
1 | package prometheus
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/stretchr/testify/suite"
6 | "net/http"
7 | "net/url"
8 | "testing"
9 | )
10 |
11 | type PrometheusTestSuite struct {
12 | suite.Suite
13 | }
14 |
15 | func (s *PrometheusTestSuite) TestPrepareOrigin() {
16 |
17 | s.Run("when request is empty", func() {
18 | origin := prepareOrigin(s.buildContext(url.URL{}, http.Header{}))
19 | s.Empty(origin)
20 | })
21 |
22 | s.Run("when origin is in query", func() {
23 | s.Run("when origin is valid url", func() {
24 | origin := prepareOrigin(s.buildContext(url.URL{RawQuery: "origin=https://example.com"}, http.Header{}))
25 | s.Equal("example.com", origin)
26 | })
27 |
28 | s.Run("when origin is invalid url", func() {
29 | origin := prepareOrigin(s.buildContext(url.URL{RawQuery: "origin=invalid"}, http.Header{}))
30 | s.Empty(origin)
31 | })
32 | })
33 |
34 | s.Run("when origin is in header", func() {
35 | s.Run("when origin is valid url", func() {
36 | origin := prepareOrigin(s.buildContext(url.URL{}, http.Header{
37 | "Origin": []string{"https://example.com"},
38 | }))
39 | s.Equal("example.com", origin)
40 | })
41 |
42 | s.Run("when origin is invalid url", func() {
43 | origin := prepareOrigin(s.buildContext(url.URL{}, http.Header{
44 | "Origin": []string{"invalid"},
45 | }))
46 | s.Empty(origin)
47 | })
48 | })
49 | }
50 |
51 | func (s *PrometheusTestSuite) buildContext(u url.URL, headers http.Header) *gin.Context {
52 | req := http.Request{
53 | Header: headers,
54 | URL: &u,
55 | }
56 |
57 | return &gin.Context{Request: &req}
58 | }
59 |
60 | func TestPrometheusSuite(t *testing.T) {
61 | suite.Run(t, new(PrometheusTestSuite))
62 | }
63 |
--------------------------------------------------------------------------------
/internal/api/middleware/response_cache/response_cache.go:
--------------------------------------------------------------------------------
1 | package response_cache
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "time"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/systemli/ticker/internal/api/helper"
10 | "github.com/systemli/ticker/internal/cache"
11 | )
12 |
13 | // responseCache is a struct to cache the response
14 | type responseCache struct {
15 | Status int
16 | Header http.Header
17 | Body []byte
18 | }
19 |
20 | // cachedWriter is a wrapper around the gin.ResponseWriter
21 | var _ gin.ResponseWriter = &cachedWriter{}
22 |
23 | // cachedWriter is a wrapper around the gin.ResponseWriter
24 | type cachedWriter struct {
25 | gin.ResponseWriter
26 | status int
27 | written bool
28 | key string
29 | expires time.Duration
30 | cache *cache.Cache
31 | }
32 |
33 | // WriteHeader is a wrapper around the gin.ResponseWriter.WriteHeader
34 | func (w *cachedWriter) WriteHeader(code int) {
35 | w.status = code
36 | w.written = true
37 | w.ResponseWriter.WriteHeader(code)
38 | }
39 |
40 | // Status is a wrapper around the gin.ResponseWriter.Status
41 | func (w *cachedWriter) Status() int {
42 | return w.ResponseWriter.Status()
43 | }
44 |
45 | // Written is a wrapper around the gin.ResponseWriter.Written
46 | func (w *cachedWriter) Written() bool {
47 | return w.ResponseWriter.Written()
48 | }
49 |
50 | // Write is a wrapper around the gin.ResponseWriter.Write
51 | // It will cache the response if the status code is below 300
52 | func (w *cachedWriter) Write(data []byte) (int, error) {
53 | ret, err := w.ResponseWriter.Write(data)
54 | if err == nil && w.Status() < 300 {
55 | value := responseCache{
56 | Status: w.Status(),
57 | Header: w.Header(),
58 | Body: data,
59 | }
60 | w.cache.Set(w.key, value, w.expires)
61 | }
62 |
63 | return ret, err
64 | }
65 |
66 | // WriteString is a wrapper around the gin.ResponseWriter.WriteString
67 | // It will cache the response if the status code is below 300
68 | func (w *cachedWriter) WriteString(s string) (int, error) {
69 | ret, err := w.ResponseWriter.WriteString(s)
70 | if err == nil && w.Status() < 300 {
71 | value := responseCache{
72 | Status: w.Status(),
73 | Header: w.Header(),
74 | Body: []byte(s),
75 | }
76 | w.cache.Set(w.key, value, w.expires)
77 | }
78 |
79 | return ret, err
80 | }
81 |
82 | func newCachedWriter(w gin.ResponseWriter, cache *cache.Cache, key string, expires time.Duration) *cachedWriter {
83 | return &cachedWriter{
84 | ResponseWriter: w,
85 | cache: cache,
86 | key: key,
87 | expires: expires,
88 | }
89 | }
90 |
91 | // CachePage is a middleware to cache the response of a request
92 | func CachePage(cache *cache.Cache, expires time.Duration, handle gin.HandlerFunc) gin.HandlerFunc {
93 | return func(c *gin.Context) {
94 | key := CreateKey(c)
95 | if value, exists := cache.Get(key); exists {
96 | v := value.(responseCache)
97 | for k, values := range v.Header {
98 | for _, value := range values {
99 | c.Writer.Header().Set(k, value)
100 | }
101 | }
102 | c.Writer.WriteHeader(v.Status)
103 | _, _ = c.Writer.Write(v.Body)
104 |
105 | return
106 | } else {
107 | writer := newCachedWriter(c.Writer, cache, key, expires)
108 | c.Writer = writer
109 | handle(c)
110 |
111 | if c.IsAborted() {
112 | cache.Delete(key)
113 | }
114 | }
115 | }
116 | }
117 |
118 | func CreateKey(c *gin.Context) string {
119 | domain, err := helper.GetOrigin(c)
120 | if err != nil {
121 | domain = "unknown"
122 | }
123 | name := c.Request.URL.Path
124 | query := c.Request.URL.Query().Encode()
125 |
126 | return fmt.Sprintf("response:%s:%s:%s", domain, name, query)
127 | }
128 |
--------------------------------------------------------------------------------
/internal/api/middleware/response_cache/response_cache_test.go:
--------------------------------------------------------------------------------
1 | package response_cache
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "net/url"
7 | "testing"
8 | "time"
9 |
10 | "github.com/gin-gonic/gin"
11 | "github.com/stretchr/testify/suite"
12 | "github.com/systemli/ticker/internal/cache"
13 | )
14 |
15 | type ResponseCacheTestSuite struct {
16 | suite.Suite
17 | }
18 |
19 | func (s *ResponseCacheTestSuite) SetupTest() {
20 | gin.SetMode(gin.TestMode)
21 | }
22 |
23 | func (s *ResponseCacheTestSuite) TestCreateKey() {
24 | s.Run("create cache key with origin", func() {
25 | c := gin.Context{
26 | Request: &http.Request{
27 | Method: "GET",
28 | URL: &url.URL{Path: "/api/v1/settings", RawQuery: "origin=http://localhost"},
29 | },
30 | }
31 |
32 | key := CreateKey(&c)
33 | s.Equal("response:http://localhost:/api/v1/settings:origin=http%3A%2F%2Flocalhost", key)
34 | })
35 |
36 | s.Run("create cache key without origin", func() {
37 | c := gin.Context{
38 | Request: &http.Request{
39 | Method: "GET",
40 | URL: &url.URL{Path: "/api/v1/settings"},
41 | },
42 | }
43 |
44 | key := CreateKey(&c)
45 | s.Equal("response:unknown:/api/v1/settings:", key)
46 | })
47 | }
48 |
49 | func (s *ResponseCacheTestSuite) TestCachePage() {
50 | s.Run("when cache is empty", func() {
51 | w := httptest.NewRecorder()
52 | c, _ := gin.CreateTestContext(w)
53 | c.Request = &http.Request{
54 | Method: "GET",
55 | URL: &url.URL{Path: "/ping", RawQuery: "origin=localhost"},
56 | }
57 |
58 | inMemoryCache := cache.NewCache(time.Minute)
59 | defer inMemoryCache.Close()
60 | CachePage(inMemoryCache, time.Minute, func(c *gin.Context) {
61 | c.String(http.StatusOK, "pong")
62 | })(c)
63 |
64 | s.Equal(http.StatusOK, w.Code)
65 | s.Equal("pong", w.Body.String())
66 |
67 | count := 0
68 | inMemoryCache.Range(func(key, value interface{}) bool {
69 | count++
70 | return true
71 | })
72 | s.Equal(1, count)
73 | })
74 |
75 | s.Run("when cache is not empty", func() {
76 | w := httptest.NewRecorder()
77 | c, _ := gin.CreateTestContext(w)
78 | c.Request = &http.Request{
79 | Method: "GET",
80 | Header: http.Header{
81 | "Origin": []string{"http://localhost/"},
82 | },
83 | URL: &url.URL{Path: "/ping"},
84 | }
85 |
86 | inMemoryCache := cache.NewCache(time.Minute)
87 | defer inMemoryCache.Close()
88 | inMemoryCache.Set("response:http://localhost:/ping:", responseCache{
89 | Status: http.StatusOK,
90 | Header: http.Header{
91 | "DNT": []string{"1"},
92 | },
93 | Body: []byte("cached"),
94 | }, time.Minute)
95 |
96 | CachePage(inMemoryCache, time.Minute, func(c *gin.Context) {
97 | c.String(http.StatusOK, "pong")
98 | })(c)
99 |
100 | s.Equal(http.StatusOK, w.Code)
101 | s.Equal("cached", w.Body.String())
102 | })
103 | }
104 |
105 | func TestResponseCacheTestSuite(t *testing.T) {
106 | suite.Run(t, new(ResponseCacheTestSuite))
107 | }
108 |
--------------------------------------------------------------------------------
/internal/api/middleware/ticker/ticker.go:
--------------------------------------------------------------------------------
1 | package ticker
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/systemli/ticker/internal/api/helper"
9 | "github.com/systemli/ticker/internal/api/response"
10 | "github.com/systemli/ticker/internal/storage"
11 | "gorm.io/gorm"
12 | )
13 |
14 | func PrefetchTicker(s storage.Storage, opts ...func(*gorm.DB) *gorm.DB) gin.HandlerFunc {
15 | return func(c *gin.Context) {
16 | user, _ := helper.Me(c)
17 | tickerID, err := strconv.Atoi(c.Param("tickerID"))
18 | if err != nil {
19 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.TickerIdentifierMissing))
20 | return
21 | }
22 |
23 | ticker, err := s.FindTickerByUserAndID(user, tickerID, opts...)
24 | if err != nil {
25 | c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeNotFound, response.TickerNotFound))
26 | return
27 | }
28 |
29 | c.Set("ticker", ticker)
30 | }
31 | }
32 |
33 | func PrefetchTickerFromRequest(s storage.Storage, opts ...func(*gorm.DB) *gorm.DB) gin.HandlerFunc {
34 | return func(c *gin.Context) {
35 | origin, err := helper.GetOrigin(c)
36 | if err != nil {
37 | c.JSON(http.StatusOK, response.ErrorResponse(response.CodeDefault, response.TickerNotFound))
38 | return
39 | }
40 |
41 | ticker, err := s.FindTickerByOrigin(origin, opts...)
42 | if err != nil {
43 | c.JSON(http.StatusOK, response.ErrorResponse(response.CodeDefault, response.TickerNotFound))
44 | return
45 | }
46 |
47 | c.Set("ticker", ticker)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/internal/api/middleware/ticker/ticker_test.go:
--------------------------------------------------------------------------------
1 | package ticker
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/stretchr/testify/mock"
11 | "github.com/stretchr/testify/suite"
12 | "github.com/systemli/ticker/internal/storage"
13 | )
14 |
15 | type TickerTestSuite struct {
16 | suite.Suite
17 | }
18 |
19 | func (s *TickerTestSuite) SetupTest() {
20 | gin.SetMode(gin.TestMode)
21 | }
22 |
23 | func (s *TickerTestSuite) TestPrefetchTicker() {
24 | s.Run("when param is missing", func() {
25 | w := httptest.NewRecorder()
26 | c, _ := gin.CreateTestContext(w)
27 | store := &storage.MockStorage{}
28 | mw := PrefetchTicker(store)
29 |
30 | mw(c)
31 |
32 | s.Equal(http.StatusBadRequest, w.Code)
33 | })
34 |
35 | s.Run("storage returns error", func() {
36 | w := httptest.NewRecorder()
37 | c, _ := gin.CreateTestContext(w)
38 | c.AddParam("tickerID", "1")
39 | store := &storage.MockStorage{}
40 | store.On("FindTickerByUserAndID", mock.Anything, mock.Anything, mock.Anything).Return(storage.Ticker{}, errors.New("storage error"))
41 | mw := PrefetchTicker(store)
42 |
43 | mw(c)
44 |
45 | s.Equal(http.StatusNotFound, w.Code)
46 | })
47 |
48 | s.Run("storage returns ticker", func() {
49 | w := httptest.NewRecorder()
50 | c, _ := gin.CreateTestContext(w)
51 | c.AddParam("tickerID", "1")
52 | store := &storage.MockStorage{}
53 | ticker := storage.Ticker{ID: 1}
54 | store.On("FindTickerByUserAndID", mock.Anything, mock.Anything, mock.Anything).Return(ticker, nil)
55 | mw := PrefetchTicker(store)
56 |
57 | mw(c)
58 |
59 | ti, e := c.Get("ticker")
60 | s.True(e)
61 | s.Equal(ticker, ti.(storage.Ticker))
62 | })
63 | }
64 |
65 | func (s *TickerTestSuite) TestPrefetchTickerFromRequest() {
66 | s.Run("when origin is missing", func() {
67 | w := httptest.NewRecorder()
68 | c, _ := gin.CreateTestContext(w)
69 | c.Request = httptest.NewRequest(http.MethodGet, "/v1/timeline", nil)
70 | store := &storage.MockStorage{}
71 | mw := PrefetchTickerFromRequest(store)
72 |
73 | mw(c)
74 |
75 | s.Equal(http.StatusOK, w.Code)
76 | ticker, exists := c.Get("ticker")
77 | s.Nil(ticker)
78 | s.False(exists)
79 | })
80 |
81 | s.Run("when ticker is not found", func() {
82 | w := httptest.NewRecorder()
83 | c, _ := gin.CreateTestContext(w)
84 | c.Request = httptest.NewRequest(http.MethodGet, "/v1/timeline", nil)
85 | c.Request.Header.Set("Origin", "https://demoticker.org")
86 | store := &storage.MockStorage{}
87 | store.On("FindTickerByOrigin", mock.Anything).Return(storage.Ticker{}, errors.New("not found"))
88 | mw := PrefetchTickerFromRequest(store)
89 |
90 | mw(c)
91 |
92 | s.Equal(http.StatusOK, w.Code)
93 | ticker, exists := c.Get("ticker")
94 | s.Nil(ticker)
95 | s.False(exists)
96 | })
97 |
98 | s.Run("when ticker is found", func() {
99 | w := httptest.NewRecorder()
100 | c, _ := gin.CreateTestContext(w)
101 | c.Request = httptest.NewRequest(http.MethodGet, "/v1/timeline", nil)
102 | c.Request.Header.Set("Origin", "https://demoticker.org")
103 | store := &storage.MockStorage{}
104 | store.On("FindTickerByOrigin", mock.Anything).Return(storage.Ticker{}, nil)
105 | mw := PrefetchTickerFromRequest(store)
106 |
107 | mw(c)
108 |
109 | s.Equal(http.StatusOK, w.Code)
110 | ticker, exists := c.Get("ticker")
111 | s.NotNil(ticker)
112 | s.True(exists)
113 | })
114 | }
115 |
116 | func TestTickerTestSuite(t *testing.T) {
117 | suite.Run(t, new(TickerTestSuite))
118 | }
119 |
--------------------------------------------------------------------------------
/internal/api/middleware/user/admin.go:
--------------------------------------------------------------------------------
1 | package user
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/systemli/ticker/internal/api/helper"
8 | "github.com/systemli/ticker/internal/api/response"
9 | )
10 |
11 | func NeedAdmin() gin.HandlerFunc {
12 | return func(c *gin.Context) {
13 | user, err := helper.Me(c)
14 | if err != nil {
15 | c.AbortWithStatusJSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.UserIdentifierMissing))
16 | return
17 | }
18 |
19 | if !user.IsSuperAdmin {
20 | c.AbortWithStatusJSON(http.StatusForbidden, response.ErrorResponse(response.CodeDefault, response.UserIdentifierMissing))
21 | return
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/internal/api/middleware/user/admin_test.go:
--------------------------------------------------------------------------------
1 | package user
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "testing"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/stretchr/testify/suite"
10 | "github.com/systemli/ticker/internal/storage"
11 | )
12 |
13 | type AdminTestSuite struct {
14 | suite.Suite
15 | }
16 |
17 | func (s *AdminTestSuite) SetupTest() {
18 | gin.SetMode(gin.TestMode)
19 | }
20 |
21 | func (s *AdminTestSuite) TestNeedAdmin() {
22 | s.Run("when user is missing", func() {
23 | w := httptest.NewRecorder()
24 | c, _ := gin.CreateTestContext(w)
25 | mw := NeedAdmin()
26 |
27 | mw(c)
28 |
29 | s.Equal(http.StatusBadRequest, w.Code)
30 | })
31 |
32 | s.Run("when user is not admin", func() {
33 | w := httptest.NewRecorder()
34 | c, _ := gin.CreateTestContext(w)
35 | c.Set("me", storage.User{})
36 | mw := NeedAdmin()
37 |
38 | mw(c)
39 |
40 | s.Equal(http.StatusForbidden, w.Code)
41 | })
42 | }
43 |
44 | func TestAdminTestSuite(t *testing.T) {
45 | suite.Run(t, new(AdminTestSuite))
46 | }
47 |
--------------------------------------------------------------------------------
/internal/api/middleware/user/user.go:
--------------------------------------------------------------------------------
1 | package user
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/systemli/ticker/internal/api/response"
9 | "github.com/systemli/ticker/internal/storage"
10 | )
11 |
12 | func PrefetchUser(s storage.Storage) gin.HandlerFunc {
13 | return func(c *gin.Context) {
14 | userID, err := strconv.Atoi(c.Param("userID"))
15 | if err != nil {
16 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.UserIdentifierMissing))
17 | return
18 | }
19 |
20 | user, err := s.FindUserByID(userID, storage.WithTickers())
21 | if err != nil {
22 | c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeNotFound, response.UserNotFound))
23 | return
24 | }
25 |
26 | c.Set("user", user)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/internal/api/middleware/user/user_test.go:
--------------------------------------------------------------------------------
1 | package user
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/stretchr/testify/mock"
11 | "github.com/stretchr/testify/suite"
12 | "github.com/systemli/ticker/internal/storage"
13 | )
14 |
15 | type UserTestSuite struct {
16 | suite.Suite
17 | }
18 |
19 | func (s *UserTestSuite) SetupTest() {
20 | gin.SetMode(gin.TestMode)
21 | }
22 |
23 | func (s *UserTestSuite) TestPrefetchUser() {
24 | s.Run("when param is missing", func() {
25 | w := httptest.NewRecorder()
26 | c, _ := gin.CreateTestContext(w)
27 | store := &storage.MockStorage{}
28 | mw := PrefetchUser(store)
29 |
30 | mw(c)
31 |
32 | s.Equal(http.StatusBadRequest, w.Code)
33 | })
34 |
35 | s.Run("storage returns error", func() {
36 | w := httptest.NewRecorder()
37 | c, _ := gin.CreateTestContext(w)
38 | c.AddParam("userID", "1")
39 | store := &storage.MockStorage{}
40 | store.On("FindUserByID", mock.Anything, mock.Anything).Return(storage.User{}, errors.New("storage error"))
41 | mw := PrefetchUser(store)
42 |
43 | mw(c)
44 |
45 | s.Equal(http.StatusNotFound, w.Code)
46 | })
47 |
48 | s.Run("storage returns user", func() {
49 | w := httptest.NewRecorder()
50 | c, _ := gin.CreateTestContext(w)
51 | c.AddParam("userID", "1")
52 | store := &storage.MockStorage{}
53 | user := storage.User{ID: 1}
54 | store.On("FindUserByID", mock.Anything, mock.Anything).Return(user, nil)
55 | mw := PrefetchUser(store)
56 |
57 | mw(c)
58 |
59 | us, e := c.Get("user")
60 | s.True(e)
61 | s.Equal(user, us.(storage.User))
62 | })
63 | }
64 |
65 | func TestUserTestSuite(t *testing.T) {
66 | suite.Run(t, new(UserTestSuite))
67 | }
68 |
--------------------------------------------------------------------------------
/internal/api/pagination/pagination.go:
--------------------------------------------------------------------------------
1 | package pagination
2 |
3 | import (
4 | "strconv"
5 |
6 | "github.com/gin-gonic/gin"
7 | )
8 |
9 | const DefaultLimit = 10
10 |
11 | // Pagination represents data for retrieving time related structures.
12 | type Pagination struct {
13 | limit int
14 | before int
15 | after int
16 | }
17 |
18 | // NewPagination returns a Pagination.
19 | func NewPagination(c *gin.Context) *Pagination {
20 | var pagination Pagination
21 |
22 | limit, err := strconv.Atoi(c.Query("limit"))
23 | if err != nil {
24 | limit = DefaultLimit
25 | }
26 | pagination.limit = limit
27 |
28 | before, err := strconv.Atoi(c.Query("before"))
29 | if err == nil {
30 | pagination.before = before
31 | }
32 | after, err := strconv.Atoi(c.Query("after"))
33 | if err == nil {
34 | pagination.after = after
35 | }
36 |
37 | return &pagination
38 | }
39 |
40 | // GetLimit returns limit.
41 | func (p *Pagination) GetLimit() int {
42 | return p.limit
43 | }
44 |
45 | // GetBefore return before.
46 | func (p *Pagination) GetBefore() int {
47 | return p.before
48 | }
49 |
50 | // GetAfter returns after.
51 | func (p *Pagination) GetAfter() int {
52 | return p.after
53 | }
54 |
--------------------------------------------------------------------------------
/internal/api/pagination/pagination_test.go:
--------------------------------------------------------------------------------
1 | package pagination
2 |
3 | import (
4 | "net/http"
5 | "net/url"
6 | "testing"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/stretchr/testify/suite"
10 | )
11 |
12 | type PaginationTestSuite struct {
13 | suite.Suite
14 | }
15 |
16 | func (s *PaginationTestSuite) TestNewPagination() {
17 | s.Run("with default values", func() {
18 | req := http.Request{
19 | URL: &url.URL{
20 | RawQuery: ``,
21 | },
22 | }
23 |
24 | c := gin.Context{Request: &req}
25 | p := NewPagination(&c)
26 |
27 | s.Equal(10, p.GetLimit())
28 | s.Equal(0, p.GetBefore())
29 | s.Equal(0, p.GetAfter())
30 | })
31 |
32 | s.Run("with custom values", func() {
33 | req := http.Request{
34 | URL: &url.URL{
35 | RawQuery: `limit=20&before=1&after=1`,
36 | },
37 | }
38 |
39 | c := gin.Context{Request: &req}
40 | p := NewPagination(&c)
41 |
42 | s.Equal(20, p.GetLimit())
43 | s.Equal(1, p.GetBefore())
44 | s.Equal(1, p.GetAfter())
45 | })
46 | }
47 |
48 | func TestPaginationTestSuite(t *testing.T) {
49 | suite.Run(t, new(PaginationTestSuite))
50 | }
51 |
--------------------------------------------------------------------------------
/internal/api/renderer/renderer.go:
--------------------------------------------------------------------------------
1 | package renderer
2 |
3 | import "github.com/sirupsen/logrus"
4 |
5 | var log = logrus.WithField("package", "renderer")
6 |
--------------------------------------------------------------------------------
/internal/api/renderer/rss.go:
--------------------------------------------------------------------------------
1 | package renderer
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gorilla/feeds"
7 | )
8 |
9 | const (
10 | AtomFormat Format = "atom"
11 | RSSFormat Format = "rss"
12 | )
13 |
14 | type Format string
15 |
16 | func FormatFromString(format string) Format {
17 | if format == string(AtomFormat) {
18 | return AtomFormat
19 | }
20 |
21 | return RSSFormat
22 | }
23 |
24 | type Feed struct {
25 | Format Format
26 | Data *feeds.Feed
27 | }
28 |
29 | var feedContentType = []string{"application/xml; charset=utf-8"}
30 |
31 | func (r Feed) Render(w http.ResponseWriter) error {
32 | return WriteFeed(w, r.Data, r.Format)
33 | }
34 |
35 | func (r Feed) WriteContentType(w http.ResponseWriter) {
36 | writeContentType(w, feedContentType)
37 | }
38 |
39 | func WriteFeed(w http.ResponseWriter, data *feeds.Feed, format Format) error {
40 | var feed string
41 | var err error
42 |
43 | writeContentType(w, feedContentType)
44 | if format == AtomFormat {
45 | feed, err = data.ToAtom()
46 | } else {
47 | feed, err = data.ToRss()
48 | }
49 | if err != nil {
50 | log.WithError(err).Error("failed to generate atom")
51 | return err
52 | }
53 |
54 | _, err = w.Write([]byte(feed))
55 | if err != nil {
56 | log.WithError(err).Error("failed to write response")
57 | return err
58 | }
59 |
60 | return nil
61 | }
62 |
63 | func writeContentType(w http.ResponseWriter, value []string) {
64 | header := w.Header()
65 | if val := header["Content-Type"]; len(val) == 0 {
66 | header["Content-Type"] = value
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/internal/api/renderer/rss_test.go:
--------------------------------------------------------------------------------
1 | package renderer
2 |
3 | import (
4 | "net/http/httptest"
5 | "testing"
6 | "time"
7 |
8 | "github.com/gorilla/feeds"
9 | "github.com/stretchr/testify/suite"
10 | )
11 |
12 | type RendererTestSuite struct {
13 | suite.Suite
14 | }
15 |
16 | func (s *RendererTestSuite) TestFormatFromString() {
17 | s.Run("when format is atom", func() {
18 | format := FormatFromString("atom")
19 | s.Equal(AtomFormat, format)
20 | })
21 |
22 | s.Run("when format is rss", func() {
23 | format := FormatFromString("rss")
24 | s.Equal(RSSFormat, format)
25 | })
26 |
27 | s.Run("when format is empty", func() {
28 | format := FormatFromString("")
29 | s.Equal(RSSFormat, format)
30 | })
31 | }
32 |
33 | func (s *RendererTestSuite) TestWriteFeed() {
34 | feed := &feeds.Feed{
35 | Title: "Title",
36 | Author: &feeds.Author{
37 | Name: "Name",
38 | Email: "Email",
39 | },
40 | Link: &feeds.Link{
41 | Href: "https://demoticker.org",
42 | },
43 | Created: time.Now(),
44 | }
45 |
46 | s.Run("when format is atom", func() {
47 | w := httptest.NewRecorder()
48 | atom := Feed{Data: feed, Format: AtomFormat}
49 |
50 | err := atom.Render(w)
51 | s.NoError(err)
52 |
53 | atom.WriteContentType(w)
54 | s.Equal("application/xml; charset=utf-8", w.Header().Get("Content-Type"))
55 | })
56 |
57 | s.Run("when format is rss", func() {
58 | w := httptest.NewRecorder()
59 | rss := Feed{Data: feed, Format: RSSFormat}
60 |
61 | err := rss.Render(w)
62 | s.NoError(err)
63 |
64 | rss.WriteContentType(w)
65 | s.Equal("application/xml; charset=utf-8", w.Header().Get("Content-Type"))
66 | })
67 | }
68 |
69 | func TestRendererTestSuite(t *testing.T) {
70 | suite.Run(t, new(RendererTestSuite))
71 | }
72 |
--------------------------------------------------------------------------------
/internal/api/response/init.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/systemli/ticker/internal/storage"
7 | )
8 |
9 | type InitTicker struct {
10 | ID int `json:"id"`
11 | CreatedAt time.Time `json:"createdAt"`
12 | Title string `json:"title"`
13 | Description string `json:"description"`
14 | Information InitTickerInformation `json:"information"`
15 | }
16 |
17 | type InitTickerInformation struct {
18 | Author string `json:"author"`
19 | URL string `json:"url"`
20 | Email string `json:"email"`
21 | Twitter string `json:"twitter"`
22 | Facebook string `json:"facebook"`
23 | Instagram string `json:"instagram"`
24 | Threads string `json:"threads"`
25 | Telegram string `json:"telegram"`
26 | Mastodon string `json:"mastodon"`
27 | Bluesky string `json:"bluesky"`
28 | }
29 |
30 | func InitTickerResponse(ticker storage.Ticker) InitTicker {
31 | return InitTicker{
32 | ID: ticker.ID,
33 | CreatedAt: ticker.CreatedAt,
34 | Title: ticker.Title,
35 | Description: ticker.Description,
36 | Information: InitTickerInformation{
37 | Author: ticker.Information.Author,
38 | URL: ticker.Information.URL,
39 | Email: ticker.Information.Email,
40 | Twitter: ticker.Information.Twitter,
41 | Facebook: ticker.Information.Facebook,
42 | Instagram: ticker.Information.Instagram,
43 | Threads: ticker.Information.Threads,
44 | Telegram: ticker.Information.Telegram,
45 | Mastodon: ticker.Information.Mastodon,
46 | Bluesky: ticker.Information.Bluesky,
47 | },
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/internal/api/response/init_test.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/stretchr/testify/suite"
8 | "github.com/systemli/ticker/internal/storage"
9 | )
10 |
11 | type InitTickerResponseTestSuite struct {
12 | suite.Suite
13 | }
14 |
15 | func (s *InitTickerResponseTestSuite) TestInitTickerResponse() {
16 | ticker := storage.Ticker{
17 | ID: 1,
18 | CreatedAt: time.Now(),
19 | Domain: "example.com",
20 | Title: "Example",
21 | Description: "Example",
22 | Information: storage.TickerInformation{
23 | Author: "Example",
24 | URL: "https://example.com",
25 | Email: "contact@example.com",
26 | Twitter: "example",
27 | Facebook: "example",
28 | Instagram: "example",
29 | Threads: "example",
30 | Telegram: "example",
31 | Mastodon: "example",
32 | Bluesky: "example",
33 | },
34 | }
35 |
36 | response := InitTickerResponse(ticker)
37 |
38 | s.Equal(ticker.ID, response.ID)
39 | s.Equal(ticker.CreatedAt, response.CreatedAt)
40 | s.Equal(ticker.Title, response.Title)
41 | s.Equal(ticker.Description, response.Description)
42 | s.Equal(ticker.Information.Author, response.Information.Author)
43 | s.Equal(ticker.Information.URL, response.Information.URL)
44 | s.Equal(ticker.Information.Email, response.Information.Email)
45 | s.Equal(ticker.Information.Twitter, response.Information.Twitter)
46 | s.Equal(ticker.Information.Facebook, response.Information.Facebook)
47 | s.Equal(ticker.Information.Instagram, response.Information.Instagram)
48 | s.Equal(ticker.Information.Threads, response.Information.Threads)
49 | s.Equal(ticker.Information.Telegram, response.Information.Telegram)
50 | s.Equal(ticker.Information.Mastodon, response.Information.Mastodon)
51 | s.Equal(ticker.Information.Bluesky, response.Information.Bluesky)
52 | }
53 |
54 | func TestInitTickerResponseTestSuite(t *testing.T) {
55 | suite.Run(t, new(InitTickerResponseTestSuite))
56 | }
57 |
--------------------------------------------------------------------------------
/internal/api/response/message.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/systemli/ticker/internal/config"
8 | "github.com/systemli/ticker/internal/storage"
9 | )
10 |
11 | type Message struct {
12 | ID int `json:"id"`
13 | CreatedAt time.Time `json:"createdAt"`
14 | Text string `json:"text"`
15 | Ticker int `json:"ticker"`
16 | TelegramURL string `json:"telegramUrl,omitempty"`
17 | MastodonURL string `json:"mastodonUrl,omitempty"`
18 | BlueskyURL string `json:"blueskyUrl,omitempty"`
19 | GeoInformation string `json:"geoInformation"`
20 | Attachments []MessageAttachment `json:"attachments"`
21 | }
22 |
23 | type MessageAttachment struct {
24 | URL string `json:"url"`
25 | ContentType string `json:"contentType"`
26 | }
27 |
28 | func MessageResponse(message storage.Message, config config.Config) Message {
29 | m, _ := message.GeoInformation.MarshalJSON()
30 | var attachments []MessageAttachment
31 |
32 | for _, attachment := range message.Attachments {
33 | name := fmt.Sprintf("%s.%s", attachment.UUID, attachment.Extension)
34 | attachments = append(attachments, MessageAttachment{URL: MediaURL(config.Upload.URL, name), ContentType: attachment.ContentType})
35 | }
36 |
37 | return Message{
38 | ID: message.ID,
39 | CreatedAt: message.CreatedAt,
40 | Text: message.Text,
41 | Ticker: message.TickerID,
42 | TelegramURL: message.TelegramURL(),
43 | MastodonURL: message.MastodonURL(),
44 | BlueskyURL: message.BlueskyURL(),
45 | GeoInformation: string(m),
46 | Attachments: attachments,
47 | }
48 | }
49 |
50 | func MessagesResponse(messages []storage.Message, config config.Config) []Message {
51 | msgs := make([]Message, 0)
52 | for _, message := range messages {
53 | msgs = append(msgs, MessageResponse(message, config))
54 | }
55 | return msgs
56 | }
57 |
58 | func MediaURL(uploadURL, name string) string {
59 | return fmt.Sprintf("%s/media/%s", uploadURL, name)
60 | }
61 |
--------------------------------------------------------------------------------
/internal/api/response/message_test.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/suite"
7 | "github.com/systemli/ticker/internal/config"
8 | "github.com/systemli/ticker/internal/storage"
9 | )
10 |
11 | type MessagesResponseTestSuite struct {
12 | suite.Suite
13 | }
14 |
15 | func (s *MessagesResponseTestSuite) TestMessagesResponse() {
16 | config := config.Config{Upload: config.Upload{URL: "https://upload.example.com"}}
17 | message := storage.NewMessage()
18 | message.Attachments = []storage.Attachment{{UUID: "uuid", Extension: "jpg"}}
19 |
20 | response := MessagesResponse([]storage.Message{message}, config)
21 |
22 | s.Equal(1, len(response))
23 | s.Empty(response[0].TelegramURL)
24 | s.Empty(response[0].MastodonURL)
25 | s.Empty(response[0].BlueskyURL)
26 | s.Equal(`{"type":"FeatureCollection","features":[]}`, response[0].GeoInformation)
27 | s.Equal(1, len(response[0].Attachments))
28 |
29 | attachments := response[0].Attachments
30 |
31 | s.Equal("https://upload.example.com/media/uuid.jpg", attachments[0].URL)
32 | }
33 |
34 | func TestMessagesResponseTestSuite(t *testing.T) {
35 | suite.Run(t, new(MessagesResponseTestSuite))
36 | }
37 |
--------------------------------------------------------------------------------
/internal/api/response/response.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | const (
4 | CodeDefault ErrorCode = 1000
5 | CodeNotFound ErrorCode = 1001
6 | CodeBadCredentials ErrorCode = 1002
7 | CodeInsufficientPermissions ErrorCode = 1003
8 |
9 | InsufficientPermissions ErrorMessage = "insufficient permissions"
10 | Unauthorized ErrorMessage = "unauthorized"
11 | UserIdentifierMissing ErrorMessage = "user identifier not found"
12 | TickerIdentifierMissing ErrorMessage = "ticker identifier not found"
13 | MessageNotFound ErrorMessage = "message not found"
14 | FilesIdentifierMissing ErrorMessage = "files identifier not found"
15 | TooMuchFiles ErrorMessage = "upload limit exceeded"
16 | UserNotFound ErrorMessage = "user not found"
17 | TickerNotFound ErrorMessage = "ticker not found"
18 | SettingNotFound ErrorMessage = "setting not found"
19 | MessageFetchError ErrorMessage = "messages couldn't fetched"
20 | FormError ErrorMessage = "invalid form values"
21 | StorageError ErrorMessage = "failed to save"
22 | UploadsNotFound ErrorMessage = "uploads not found"
23 | BridgeError ErrorMessage = "unable to update ticker in bridges"
24 | MastodonError ErrorMessage = "unable to connect to mastodon"
25 | BlueskyError ErrorMessage = "unable to connect to bluesky"
26 | SignalGroupError ErrorMessage = "unable to connect to signal"
27 | SignalGroupDeleteError ErrorMessage = "unable to delete signal group"
28 | PasswordError ErrorMessage = "could not authenticate password"
29 |
30 | StatusSuccess Status = `success`
31 | StatusError Status = `error`
32 | )
33 |
34 | type ErrorCode int
35 | type ErrorMessage string
36 | type Data map[string]interface{}
37 | type Status string
38 |
39 | type Response struct {
40 | Data Data `json:"data" swaggertype:"object,string"`
41 | Status Status `json:"status"`
42 | Error Error `json:"error,omitempty"`
43 | }
44 |
45 | type Error struct {
46 | Code ErrorCode `json:"code,omitempty"`
47 | Message ErrorMessage `json:"message,omitempty"`
48 | }
49 |
50 | func SuccessResponse(data map[string]interface{}) Response {
51 | return Response{
52 | Data: data,
53 | Status: StatusSuccess,
54 | }
55 | }
56 |
57 | func ErrorResponse(code ErrorCode, message ErrorMessage) Response {
58 | return Response{
59 | Error: Error{
60 | Code: code,
61 | Message: message,
62 | },
63 | Status: StatusError,
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/internal/api/response/response_test.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/suite"
7 | )
8 |
9 | type ResponseTestSuite struct {
10 | suite.Suite
11 | }
12 |
13 | func (s *ResponseTestSuite) TestResponse() {
14 | s.Run("when status is success", func() {
15 | d := []string{"value1", "value2"}
16 | r := SuccessResponse(map[string]interface{}{"user": d})
17 |
18 | s.Equal(StatusSuccess, r.Status)
19 | s.Equal(Data(map[string]interface{}{"user": d}), r.Data)
20 | s.Equal(Error{}, r.Error)
21 | })
22 |
23 | s.Run("when status is error", func() {
24 | r := ErrorResponse(CodeDefault, InsufficientPermissions)
25 |
26 | s.Equal(StatusError, r.Status)
27 | s.Equal(Data(nil), r.Data)
28 | s.Equal(Error{Code: CodeDefault, Message: InsufficientPermissions}, r.Error)
29 | })
30 | }
31 |
32 | func TestResponseTestSuite(t *testing.T) {
33 | suite.Run(t, new(ResponseTestSuite))
34 | }
35 |
--------------------------------------------------------------------------------
/internal/api/response/settings.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import "github.com/systemli/ticker/internal/storage"
4 |
5 | type Settings struct {
6 | RefreshInterval int `json:"refreshInterval,omitempty"`
7 | InactiveSettings interface{} `json:"inactiveSettings,omitempty"`
8 | }
9 |
10 | type Setting struct {
11 | Name string `json:"name"`
12 | Value interface{} `json:"value"`
13 | }
14 |
15 | func InactiveSettingsResponse(inactiveSettings storage.InactiveSettings) Setting {
16 | return Setting{
17 | Name: storage.SettingInactiveName,
18 | Value: inactiveSettings,
19 | }
20 | }
21 |
22 | func RefreshIntervalSettingsResponse(refreshIntervalSettings storage.RefreshIntervalSettings) Setting {
23 | return Setting{
24 | Name: storage.SettingRefreshInterval,
25 | Value: refreshIntervalSettings,
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/internal/api/response/settings_test.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/suite"
7 | "github.com/systemli/ticker/internal/storage"
8 | )
9 |
10 | type SettingsResponseTestSuite struct {
11 | suite.Suite
12 | }
13 |
14 | func (s *SettingsResponseTestSuite) TestInactiveSettingsResponse() {
15 | inactiveSettings := storage.DefaultInactiveSettings()
16 |
17 | setting := InactiveSettingsResponse(inactiveSettings)
18 |
19 | s.Equal(storage.SettingInactiveName, setting.Name)
20 | s.Equal(inactiveSettings, setting.Value)
21 | }
22 |
23 | func (s *SettingsResponseTestSuite) TestRefreshIntervalSettingsResponse() {
24 | refreshIntervalSettings := storage.DefaultRefreshIntervalSettings()
25 |
26 | setting := RefreshIntervalSettingsResponse(refreshIntervalSettings)
27 |
28 | s.Equal(storage.SettingRefreshInterval, setting.Name)
29 | s.Equal(refreshIntervalSettings, setting.Value)
30 | }
31 |
32 | func TestSettingsResponseTestSuite(t *testing.T) {
33 | suite.Run(t, new(SettingsResponseTestSuite))
34 | }
35 |
--------------------------------------------------------------------------------
/internal/api/response/ticker.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/systemli/ticker/internal/config"
7 | "github.com/systemli/ticker/internal/storage"
8 | )
9 |
10 | type Ticker struct {
11 | ID int `json:"id"`
12 | CreatedAt time.Time `json:"createdAt"`
13 | Title string `json:"title"`
14 | Description string `json:"description"`
15 | Active bool `json:"active"`
16 | Information Information `json:"information"`
17 | Websites []Website `json:"websites"`
18 | Telegram Telegram `json:"telegram"`
19 | Mastodon Mastodon `json:"mastodon"`
20 | Bluesky Bluesky `json:"bluesky"`
21 | SignalGroup SignalGroup `json:"signalGroup"`
22 | Location Location `json:"location"`
23 | }
24 |
25 | type Information struct {
26 | Author string `json:"author"`
27 | URL string `json:"url"`
28 | Email string `json:"email"`
29 | Twitter string `json:"twitter"`
30 | Facebook string `json:"facebook"`
31 | Instagram string `json:"instagram"`
32 | Threads string `json:"threads"`
33 | Telegram string `json:"telegram"`
34 | Mastodon string `json:"mastodon"`
35 | Bluesky string `json:"bluesky"`
36 | }
37 |
38 | type Website struct {
39 | ID int `json:"id"`
40 | CreatedAt time.Time `json:"createdAt"`
41 | Origin string `json:"origin"`
42 | }
43 |
44 | type Telegram struct {
45 | Active bool `json:"active"`
46 | Connected bool `json:"connected"`
47 | BotUsername string `json:"botUsername"`
48 | ChannelName string `json:"channelName"`
49 | }
50 |
51 | type Mastodon struct {
52 | Active bool `json:"active"`
53 | Connected bool `json:"connected"`
54 | Name string `json:"name"`
55 | Server string `json:"server"`
56 | ScreenName string `json:"screenName"`
57 | Description string `json:"description"`
58 | ImageURL string `json:"imageUrl"`
59 | }
60 |
61 | type Bluesky struct {
62 | Active bool `json:"active"`
63 | Connected bool `json:"connected"`
64 | Handle string `json:"handle"`
65 | }
66 |
67 | type SignalGroup struct {
68 | Active bool `json:"active"`
69 | Connected bool `json:"connected"`
70 | GroupID string `json:"groupID"`
71 | GroupInviteLink string `json:"groupInviteLink"`
72 | }
73 |
74 | type Location struct {
75 | Lat float64 `json:"lat"`
76 | Lon float64 `json:"lon"`
77 | }
78 |
79 | func TickerResponse(t storage.Ticker, config config.Config) Ticker {
80 | websites := make([]Website, 0)
81 | for _, website := range t.Websites {
82 | websites = append(websites, Website{
83 | ID: website.ID,
84 | CreatedAt: website.CreatedAt,
85 | Origin: website.Origin,
86 | })
87 | }
88 |
89 | return Ticker{
90 | ID: t.ID,
91 | CreatedAt: t.CreatedAt,
92 | Title: t.Title,
93 | Description: t.Description,
94 | Active: t.Active,
95 | Information: Information{
96 | Author: t.Information.Author,
97 | URL: t.Information.URL,
98 | Email: t.Information.Email,
99 | Twitter: t.Information.Twitter,
100 | Facebook: t.Information.Facebook,
101 | Instagram: t.Information.Instagram,
102 | Threads: t.Information.Threads,
103 | Telegram: t.Information.Telegram,
104 | Mastodon: t.Information.Mastodon,
105 | Bluesky: t.Information.Bluesky,
106 | },
107 | Websites: websites,
108 | Telegram: Telegram{
109 | Active: t.Telegram.Active,
110 | Connected: t.Telegram.Connected(),
111 | BotUsername: config.Telegram.User.UserName,
112 | ChannelName: t.Telegram.ChannelName,
113 | },
114 | Mastodon: Mastodon{
115 | Active: t.Mastodon.Active,
116 | Connected: t.Mastodon.Connected(),
117 | Name: t.Mastodon.User.Username,
118 | Server: t.Mastodon.Server,
119 | ScreenName: t.Mastodon.User.DisplayName,
120 | ImageURL: t.Mastodon.User.Avatar,
121 | },
122 | Bluesky: Bluesky{
123 | Active: t.Bluesky.Active,
124 | Connected: t.Bluesky.Connected(),
125 | Handle: t.Bluesky.Handle,
126 | },
127 | SignalGroup: SignalGroup{
128 | Active: t.SignalGroup.Active,
129 | Connected: t.SignalGroup.Connected(),
130 | GroupID: t.SignalGroup.GroupID,
131 | GroupInviteLink: t.SignalGroup.GroupInviteLink,
132 | },
133 | Location: Location{
134 | Lat: t.Location.Lat,
135 | Lon: t.Location.Lon,
136 | },
137 | }
138 | }
139 |
140 | func TickersResponse(tickers []storage.Ticker, config config.Config) []Ticker {
141 | t := make([]Ticker, 0)
142 |
143 | for _, ticker := range tickers {
144 | t = append(t, TickerResponse(ticker, config))
145 | }
146 | return t
147 | }
148 |
--------------------------------------------------------------------------------
/internal/api/response/ticker_test.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
8 | "github.com/stretchr/testify/suite"
9 | "github.com/systemli/ticker/internal/config"
10 | "github.com/systemli/ticker/internal/storage"
11 | )
12 |
13 | type TickersResponseTestSuite struct {
14 | suite.Suite
15 | }
16 |
17 | func (s *TickersResponseTestSuite) TestTickersResponse() {
18 | ticker := storage.Ticker{
19 | ID: 1,
20 | CreatedAt: time.Now(),
21 | Domain: "example.com",
22 | Title: "Example",
23 | Description: "Example",
24 | Active: true,
25 | Information: storage.TickerInformation{
26 | Author: "Example",
27 | URL: "https://example.com",
28 | Email: "contact@example.com",
29 | Twitter: "@example",
30 | Facebook: "https://facebook.com/example",
31 | Telegram: "example",
32 | Mastodon: "https://systemli.social/@example",
33 | Bluesky: "https://example.com",
34 | },
35 | Websites: []storage.TickerWebsite{
36 | {
37 | Origin: "example.com",
38 | },
39 | },
40 | Telegram: storage.TickerTelegram{
41 | Active: true,
42 | ChannelName: "example",
43 | },
44 | Mastodon: storage.TickerMastodon{
45 | Active: true,
46 | Server: "https://example.com",
47 | User: storage.MastodonUser{
48 | Username: "example",
49 | DisplayName: "Example",
50 | Avatar: "https://example.com/avatar.png",
51 | },
52 | },
53 | SignalGroup: storage.TickerSignalGroup{
54 | Active: true,
55 | GroupID: "example",
56 | GroupInviteLink: "https://signal.group/#example",
57 | },
58 | Location: storage.TickerLocation{
59 | Lat: 0.0,
60 | Lon: 0.0,
61 | },
62 | }
63 |
64 | config := config.Config{
65 | Telegram: config.Telegram{
66 | User: tgbotapi.User{
67 | UserName: "ticker",
68 | },
69 | },
70 | }
71 |
72 | tickerResponse := TickersResponse([]storage.Ticker{ticker}, config)
73 |
74 | s.Equal(1, len(tickerResponse))
75 | s.Equal(ticker.ID, tickerResponse[0].ID)
76 | s.Equal(ticker.CreatedAt, tickerResponse[0].CreatedAt)
77 | s.Equal(ticker.Title, tickerResponse[0].Title)
78 | s.Equal(ticker.Description, tickerResponse[0].Description)
79 | s.Equal(ticker.Active, tickerResponse[0].Active)
80 | s.Equal(ticker.Information.Author, tickerResponse[0].Information.Author)
81 | s.Equal(ticker.Information.URL, tickerResponse[0].Information.URL)
82 | s.Equal(ticker.Information.Email, tickerResponse[0].Information.Email)
83 | s.Equal(ticker.Information.Twitter, tickerResponse[0].Information.Twitter)
84 | s.Equal(ticker.Information.Facebook, tickerResponse[0].Information.Facebook)
85 | s.Equal(ticker.Information.Telegram, tickerResponse[0].Information.Telegram)
86 | s.Equal(ticker.Information.Mastodon, tickerResponse[0].Information.Mastodon)
87 | s.Equal(ticker.Information.Bluesky, tickerResponse[0].Information.Bluesky)
88 | s.Equal(1, len(ticker.Websites))
89 | s.Equal(ticker.Websites[0].Origin, tickerResponse[0].Websites[0].Origin)
90 | s.Equal(ticker.Telegram.Active, tickerResponse[0].Telegram.Active)
91 | s.Equal(ticker.Telegram.Connected(), tickerResponse[0].Telegram.Connected)
92 | s.Equal(config.Telegram.User.UserName, tickerResponse[0].Telegram.BotUsername)
93 | s.Equal(ticker.Telegram.ChannelName, tickerResponse[0].Telegram.ChannelName)
94 | s.Equal(ticker.Mastodon.Active, tickerResponse[0].Mastodon.Active)
95 | s.Equal(ticker.Mastodon.Connected(), tickerResponse[0].Mastodon.Connected)
96 | s.Equal(ticker.Mastodon.User.Username, tickerResponse[0].Mastodon.Name)
97 | s.Equal(ticker.Mastodon.Server, tickerResponse[0].Mastodon.Server)
98 | s.Equal(ticker.Mastodon.User.DisplayName, tickerResponse[0].Mastodon.ScreenName)
99 | s.Equal(ticker.Mastodon.User.Avatar, tickerResponse[0].Mastodon.ImageURL)
100 | s.Equal(ticker.SignalGroup.Active, tickerResponse[0].SignalGroup.Active)
101 | s.Equal(ticker.SignalGroup.Connected(), tickerResponse[0].SignalGroup.Connected)
102 | s.Equal(ticker.SignalGroup.GroupID, tickerResponse[0].SignalGroup.GroupID)
103 | s.Equal(ticker.Location.Lat, tickerResponse[0].Location.Lat)
104 | s.Equal(ticker.Location.Lon, tickerResponse[0].Location.Lon)
105 | }
106 |
107 | func TestTickersResponseTestSuite(t *testing.T) {
108 | suite.Run(t, new(TickersResponseTestSuite))
109 | }
110 |
--------------------------------------------------------------------------------
/internal/api/response/timeline.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/systemli/ticker/internal/config"
8 | "github.com/systemli/ticker/internal/storage"
9 | )
10 |
11 | type Timeline []TimelineEntry
12 |
13 | type TimelineEntry struct {
14 | ID int `json:"id"`
15 | CreatedAt time.Time `json:"createdAt"`
16 | Text string `json:"text"`
17 | GeoInformation string `json:"geoInformation"`
18 | Attachments []Attachment `json:"attachments"`
19 | }
20 |
21 | type Attachment struct {
22 | URL string `json:"url"`
23 | ContentType string `json:"contentType"`
24 | }
25 |
26 | func TimelineResponse(messages []storage.Message, config config.Config) []TimelineEntry {
27 | timeline := make([]TimelineEntry, 0)
28 | for _, message := range messages {
29 | m, _ := message.GeoInformation.MarshalJSON()
30 |
31 | var attachments []Attachment
32 | for _, attachment := range message.Attachments {
33 | name := fmt.Sprintf("%s.%s", attachment.UUID, attachment.Extension)
34 | attachments = append(attachments, Attachment{URL: MediaURL(config.Upload.URL, name), ContentType: attachment.ContentType})
35 | }
36 |
37 | timeline = append(timeline, TimelineEntry{
38 | ID: message.ID,
39 | CreatedAt: message.CreatedAt,
40 | Text: message.Text,
41 | GeoInformation: string(m),
42 | Attachments: attachments,
43 | })
44 |
45 | }
46 | return timeline
47 | }
48 |
--------------------------------------------------------------------------------
/internal/api/response/timeline_test.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/suite"
7 | "github.com/systemli/ticker/internal/config"
8 | "github.com/systemli/ticker/internal/storage"
9 | )
10 |
11 | type TimelineTestSuite struct {
12 | suite.Suite
13 | }
14 |
15 | func (s *TimelineTestSuite) TestTimelineResponse() {
16 | config := config.Config{Upload: config.Upload{URL: "https://upload.example.com"}}
17 | message := storage.NewMessage()
18 | message.Attachments = []storage.Attachment{{UUID: "uuid", Extension: "jpg"}}
19 |
20 | response := TimelineResponse([]storage.Message{message}, config)
21 |
22 | s.Equal(1, len(response))
23 | s.Equal(`{"type":"FeatureCollection","features":[]}`, response[0].GeoInformation)
24 | s.Equal(1, len(response[0].Attachments))
25 |
26 | attachments := response[0].Attachments
27 |
28 | s.Equal("https://upload.example.com/media/uuid.jpg", attachments[0].URL)
29 | }
30 |
31 | func TestTimelineTestSuite(t *testing.T) {
32 | suite.Run(t, new(TimelineTestSuite))
33 | }
34 |
--------------------------------------------------------------------------------
/internal/api/response/upload.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/systemli/ticker/internal/config"
7 | "github.com/systemli/ticker/internal/storage"
8 | )
9 |
10 | type Upload struct {
11 | ID int `json:"id"`
12 | UUID string `json:"uuid"`
13 | CreatedAt time.Time `json:"createdAt"`
14 | URL string `json:"url"`
15 | ContentType string `json:"contentType"`
16 | }
17 |
18 | func UploadResponse(upload storage.Upload, config config.Config) Upload {
19 | return Upload{
20 | ID: upload.ID,
21 | UUID: upload.UUID,
22 | CreatedAt: upload.CreatedAt,
23 | URL: upload.URL(config.Upload.URL),
24 | ContentType: upload.ContentType,
25 | }
26 | }
27 |
28 | func UploadsResponse(uploads []storage.Upload, config config.Config) []Upload {
29 | ur := make([]Upload, 0)
30 | for _, upload := range uploads {
31 | ur = append(ur, UploadResponse(upload, config))
32 | }
33 |
34 | return ur
35 | }
36 |
--------------------------------------------------------------------------------
/internal/api/response/upload_test.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/suite"
8 | "github.com/systemli/ticker/internal/config"
9 | "github.com/systemli/ticker/internal/storage"
10 | )
11 |
12 | var (
13 | u = storage.NewUpload("image.jpg", "image/jpg", 1)
14 | c = config.Config{
15 | Upload: config.Upload{URL: "http://localhost:8080"},
16 | }
17 | )
18 |
19 | type UploadResponseTestSuite struct {
20 | suite.Suite
21 | }
22 |
23 | func (s *UploadResponseTestSuite) TestUploadResponse() {
24 | response := UploadResponse(u, c)
25 |
26 | s.Equal(fmt.Sprintf("%s/media/%s", c.Upload.URL, u.FileName()), response.URL)
27 | }
28 |
29 | func (s *UploadResponseTestSuite) TestUploadsResponse() {
30 | response := UploadsResponse([]storage.Upload{u}, c)
31 |
32 | s.Equal(1, len(response))
33 | }
34 |
35 | func TestUploadResponseTestSuite(t *testing.T) {
36 | suite.Run(t, new(UploadResponseTestSuite))
37 | }
38 |
--------------------------------------------------------------------------------
/internal/api/response/user.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/systemli/ticker/internal/storage"
7 | )
8 |
9 | type User struct {
10 | ID int `json:"id"`
11 | CreatedAt time.Time `json:"createdAt"`
12 | LastLogin time.Time `json:"lastLogin"`
13 | Email string `json:"email"`
14 | Role string `json:"role"`
15 | Tickers []UserTicker `json:"tickers"`
16 | IsSuperAdmin bool `json:"isSuperAdmin"`
17 | }
18 |
19 | type UserTicker struct {
20 | ID int `json:"id"`
21 | Domain string `json:"domain"`
22 | Title string `json:"title"`
23 | }
24 |
25 | func UserResponse(user storage.User) User {
26 | return User{
27 | ID: user.ID,
28 | CreatedAt: user.CreatedAt,
29 | LastLogin: user.LastLogin,
30 | Email: user.Email,
31 | IsSuperAdmin: user.IsSuperAdmin,
32 | Tickers: UserTickersResponse(user.Tickers),
33 | }
34 | }
35 |
36 | func UsersResponse(users []storage.User) []User {
37 | u := make([]User, 0)
38 | for _, user := range users {
39 | u = append(u, UserResponse(user))
40 | }
41 |
42 | return u
43 | }
44 |
45 | func UserTickersResponse(tickers []storage.Ticker) []UserTicker {
46 | t := make([]UserTicker, 0)
47 | for _, ticker := range tickers {
48 | t = append(t, UserTickerResponse(ticker))
49 | }
50 |
51 | return t
52 | }
53 |
54 | func UserTickerResponse(ticker storage.Ticker) UserTicker {
55 | return UserTicker{
56 | ID: ticker.ID,
57 | Domain: ticker.Domain,
58 | Title: ticker.Title,
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/internal/api/response/user_test.go:
--------------------------------------------------------------------------------
1 | package response
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/stretchr/testify/suite"
8 | "github.com/systemli/ticker/internal/storage"
9 | )
10 |
11 | type UsersResponseTestSuite struct {
12 | suite.Suite
13 | }
14 |
15 | func (s *UsersResponseTestSuite) TestUsersResponse() {
16 | users := []storage.User{
17 | {
18 | ID: 1,
19 | CreatedAt: time.Now(),
20 | LastLogin: time.Now(),
21 | Email: "user@systemli.org",
22 | IsSuperAdmin: true,
23 | Tickers: []storage.Ticker{
24 | {
25 | ID: 1,
26 | Domain: "example.com",
27 | Title: "Example",
28 | },
29 | },
30 | },
31 | }
32 |
33 | usersResponse := UsersResponse(users)
34 | s.Equal(1, len(usersResponse))
35 | s.Equal(users[0].ID, usersResponse[0].ID)
36 | s.Equal(users[0].CreatedAt, usersResponse[0].CreatedAt)
37 | s.Equal(users[0].LastLogin, usersResponse[0].LastLogin)
38 | s.Equal(users[0].Email, usersResponse[0].Email)
39 | s.Equal(users[0].IsSuperAdmin, usersResponse[0].IsSuperAdmin)
40 | s.Equal(1, len(usersResponse[0].Tickers))
41 | s.Equal(users[0].Tickers[0].ID, usersResponse[0].Tickers[0].ID)
42 | s.Equal(users[0].Tickers[0].Domain, usersResponse[0].Tickers[0].Domain)
43 | s.Equal(users[0].Tickers[0].Title, usersResponse[0].Tickers[0].Title)
44 | }
45 |
46 | func TestUsersResponseTestSuite(t *testing.T) {
47 | suite.Run(t, new(UsersResponseTestSuite))
48 | }
49 |
--------------------------------------------------------------------------------
/internal/api/settings.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/systemli/ticker/internal/api/helper"
8 | "github.com/systemli/ticker/internal/api/response"
9 | "github.com/systemli/ticker/internal/storage"
10 | )
11 |
12 | func (h *handler) GetSetting(c *gin.Context) {
13 | if !helper.IsAdmin(c) {
14 | c.JSON(http.StatusForbidden, response.ErrorResponse(response.CodeInsufficientPermissions, response.InsufficientPermissions))
15 | return
16 | }
17 |
18 | if c.Param("name") == storage.SettingInactiveName {
19 | setting := h.storage.GetInactiveSettings()
20 | data := map[string]interface{}{"setting": response.InactiveSettingsResponse(setting)}
21 | c.JSON(http.StatusOK, response.SuccessResponse(data))
22 | return
23 | }
24 |
25 | if c.Param("name") == storage.SettingRefreshInterval {
26 | setting := h.storage.GetRefreshIntervalSettings()
27 | data := map[string]interface{}{"setting": response.RefreshIntervalSettingsResponse(setting)}
28 | c.JSON(http.StatusOK, response.SuccessResponse(data))
29 | return
30 | }
31 |
32 | c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.SettingNotFound))
33 | }
34 |
35 | func (h *handler) PutInactiveSettings(c *gin.Context) {
36 | value := storage.InactiveSettings{}
37 | err := c.Bind(&value)
38 | if err != nil {
39 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.FormError))
40 | return
41 | }
42 |
43 | err = h.storage.SaveInactiveSettings(value)
44 | if err != nil {
45 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError))
46 | return
47 | }
48 |
49 | setting := h.storage.GetInactiveSettings()
50 | data := map[string]interface{}{"setting": response.InactiveSettingsResponse(setting)}
51 | c.JSON(http.StatusOK, response.SuccessResponse(data))
52 | }
53 |
54 | func (h *handler) PutRefreshInterval(c *gin.Context) {
55 | value := storage.RefreshIntervalSettings{}
56 | err := c.Bind(&value)
57 | if err != nil {
58 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.FormError))
59 | return
60 | }
61 |
62 | err = h.storage.SaveRefreshIntervalSettings(storage.RefreshIntervalSettings{RefreshInterval: value.RefreshInterval})
63 | if err != nil {
64 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError))
65 | return
66 | }
67 |
68 | setting := h.storage.GetRefreshIntervalSettings()
69 | data := map[string]interface{}{"setting": response.RefreshIntervalSettingsResponse(setting)}
70 | c.JSON(http.StatusOK, response.SuccessResponse(data))
71 | }
72 |
--------------------------------------------------------------------------------
/internal/api/timeline.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/systemli/ticker/internal/api/helper"
8 | "github.com/systemli/ticker/internal/api/pagination"
9 | "github.com/systemli/ticker/internal/api/response"
10 | "github.com/systemli/ticker/internal/storage"
11 | )
12 |
13 | func (h *handler) GetTimeline(c *gin.Context) {
14 | ticker, err := helper.Ticker(c)
15 | if err != nil {
16 | c.JSON(http.StatusOK, response.ErrorResponse(response.CodeDefault, response.TickerNotFound))
17 | return
18 | }
19 |
20 | messages := make([]storage.Message, 0)
21 | if ticker.Active {
22 | pagination := pagination.NewPagination(c)
23 | messages, err = h.storage.FindMessagesByTickerAndPagination(ticker, *pagination, storage.WithAttachments())
24 | if err != nil {
25 | c.JSON(http.StatusOK, response.ErrorResponse(response.CodeDefault, response.MessageFetchError))
26 | return
27 | }
28 | }
29 |
30 | c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"messages": response.TimelineResponse(messages, h.config)}))
31 | }
32 |
--------------------------------------------------------------------------------
/internal/api/timeline_test.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/stretchr/testify/mock"
11 | "github.com/stretchr/testify/suite"
12 | "github.com/systemli/ticker/internal/api/response"
13 | "github.com/systemli/ticker/internal/config"
14 | "github.com/systemli/ticker/internal/storage"
15 | )
16 |
17 | type TimelineTestSuite struct {
18 | w *httptest.ResponseRecorder
19 | ctx *gin.Context
20 | store *storage.MockStorage
21 | cfg config.Config
22 | suite.Suite
23 | }
24 |
25 | func (s *TimelineTestSuite) SetupTest() {
26 | gin.SetMode(gin.TestMode)
27 | }
28 |
29 | func (s *TimelineTestSuite) Run(name string, subtest func()) {
30 | s.T().Run(name, func(t *testing.T) {
31 | s.w = httptest.NewRecorder()
32 | s.ctx, _ = gin.CreateTestContext(s.w)
33 | s.store = &storage.MockStorage{}
34 | s.cfg = config.LoadConfig("")
35 |
36 | subtest()
37 | })
38 | }
39 |
40 | func (s *TimelineTestSuite) TestGetTimeline() {
41 | s.Run("when ticker is missing", func() {
42 | s.ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/timeline", nil)
43 | h := s.handler()
44 | h.GetTimeline(s.ctx)
45 |
46 | s.Equal(http.StatusOK, s.w.Code)
47 | s.Contains(s.w.Body.String(), response.TickerNotFound)
48 | s.store.AssertExpectations(s.T())
49 | })
50 |
51 | s.Run("when storage returns an error", func() {
52 | s.ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/timeline", nil)
53 | s.ctx.Set("ticker", storage.Ticker{Active: true})
54 | s.store.On("FindMessagesByTickerAndPagination", mock.Anything, mock.Anything, mock.Anything).Return([]storage.Message{}, errors.New("storage error")).Once()
55 | h := s.handler()
56 | h.GetTimeline(s.ctx)
57 |
58 | s.Equal(http.StatusOK, s.w.Code)
59 | s.Contains(s.w.Body.String(), response.MessageFetchError)
60 | s.store.AssertExpectations(s.T())
61 | })
62 |
63 | s.Run("when storage returns messages", func() {
64 | s.ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/timeline", nil)
65 | s.ctx.Set("ticker", storage.Ticker{Active: true})
66 | s.store.On("FindMessagesByTickerAndPagination", mock.Anything, mock.Anything, mock.Anything).Return([]storage.Message{}, nil).Once()
67 | h := s.handler()
68 | h.GetTimeline(s.ctx)
69 |
70 | s.Equal(http.StatusOK, s.w.Code)
71 | s.store.AssertExpectations(s.T())
72 | })
73 | }
74 |
75 | func (s *TimelineTestSuite) handler() handler {
76 | return handler{
77 | storage: s.store,
78 | config: s.cfg,
79 | }
80 | }
81 |
82 | func TestTimelineTestSuite(t *testing.T) {
83 | suite.Run(t, new(TimelineTestSuite))
84 | }
85 |
--------------------------------------------------------------------------------
/internal/api/upload.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "path/filepath"
7 | "strconv"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/systemli/ticker/internal/api/helper"
11 | "github.com/systemli/ticker/internal/api/response"
12 | "github.com/systemli/ticker/internal/config"
13 | "github.com/systemli/ticker/internal/storage"
14 |
15 | "github.com/systemli/ticker/internal/util"
16 | )
17 |
18 | var allowedContentTypes = []string{"image/jpeg", "image/gif", "image/png"}
19 |
20 | func (h *handler) PostUpload(c *gin.Context) {
21 | me, err := helper.Me(c)
22 | if err != nil {
23 | c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.UserNotFound))
24 | return
25 | }
26 |
27 | form, err := c.MultipartForm()
28 | if err != nil {
29 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.FormError))
30 | return
31 | }
32 |
33 | if len(form.Value["ticker"]) != 1 {
34 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.TickerIdentifierMissing))
35 | return
36 | }
37 |
38 | tickerID, err := strconv.Atoi(form.Value["ticker"][0])
39 | if err != nil {
40 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.TickerIdentifierMissing))
41 | return
42 | }
43 |
44 | ticker, err := h.storage.FindTickerByUserAndID(me, tickerID)
45 | if err != nil {
46 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.TickerNotFound))
47 | return
48 | }
49 |
50 | files := form.File["files"]
51 | if len(files) < 1 {
52 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.FilesIdentifierMissing))
53 | return
54 | }
55 | if len(files) > 3 {
56 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.TooMuchFiles))
57 | return
58 | }
59 | uploads := make([]storage.Upload, 0)
60 | for _, fileHeader := range files {
61 | file, err := fileHeader.Open()
62 | if err != nil {
63 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.FormError))
64 | return
65 | }
66 |
67 | contentType := util.DetectContentType(file)
68 | if !util.ContainsString(allowedContentTypes, contentType) {
69 | log.Error(fmt.Sprintf("%s is not allowed to uploaded", contentType))
70 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, "failed to upload"))
71 | return
72 | }
73 |
74 | u := storage.NewUpload(fileHeader.Filename, contentType, ticker.ID)
75 | err = h.storage.SaveUpload(&u)
76 | if err != nil {
77 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.FormError))
78 | return
79 | }
80 |
81 | err = preparePath(u, h.config)
82 | if err != nil {
83 | c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.FormError))
84 | return
85 | }
86 |
87 | if u.ContentType == "image/gif" {
88 | err = c.SaveUploadedFile(fileHeader, u.FullPath(h.config.Upload.Path))
89 | if err != nil {
90 | c.JSON(http.StatusInternalServerError, response.ErrorResponse(response.CodeDefault, response.FormError))
91 | return
92 | }
93 | } else {
94 | nFile, _ := fileHeader.Open()
95 | image, err := util.ResizeImage(nFile, 1280)
96 | if err != nil {
97 | c.JSON(http.StatusInternalServerError, response.ErrorResponse(response.CodeDefault, response.FormError))
98 | return
99 | }
100 |
101 | err = util.SaveImage(image, u.FullPath(h.config.Upload.Path))
102 | if err != nil {
103 | c.JSON(http.StatusInternalServerError, response.ErrorResponse(response.CodeDefault, response.FormError))
104 | return
105 | }
106 | }
107 |
108 | uploads = append(uploads, u)
109 | }
110 |
111 | c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"uploads": response.UploadsResponse(uploads, h.config)}))
112 | }
113 |
114 | func preparePath(upload storage.Upload, config config.Config) error {
115 | path := upload.FullPath(config.Upload.Path)
116 | fs := config.FileBackend
117 | return fs.MkdirAll(filepath.Dir(path), 0750)
118 | }
119 |
--------------------------------------------------------------------------------
/internal/bluesky/bluesky.go:
--------------------------------------------------------------------------------
1 | package bluesky
2 |
3 | import (
4 | "context"
5 | "net/http"
6 |
7 | comatproto "github.com/bluesky-social/indigo/api/atproto"
8 | "github.com/bluesky-social/indigo/xrpc"
9 | "github.com/sirupsen/logrus"
10 | )
11 |
12 | var log = logrus.WithField("package", "bluesky")
13 |
14 | func Authenticate(handle, password string) (*xrpc.Client, error) {
15 | client := &xrpc.Client{
16 | Client: &http.Client{},
17 | Host: "https://bsky.social",
18 | Auth: &xrpc.AuthInfo{Handle: handle},
19 | }
20 |
21 | auth, err := comatproto.ServerCreateSession(context.TODO(), client, &comatproto.ServerCreateSession_Input{
22 | Identifier: handle,
23 | Password: password,
24 | })
25 | if err != nil {
26 | log.WithError(err).Error("failed to create session")
27 | return nil, err
28 | }
29 |
30 | client.Auth.Did = auth.Did
31 | client.Auth.AccessJwt = auth.AccessJwt
32 | client.Auth.RefreshJwt = auth.RefreshJwt
33 |
34 | return client, nil
35 | }
36 |
--------------------------------------------------------------------------------
/internal/bluesky/bluesky_test.go:
--------------------------------------------------------------------------------
1 | package bluesky
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/h2non/gock"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestAuthenticate_Success(t *testing.T) {
11 | gock.DisableNetworking()
12 | defer gock.Off()
13 |
14 | gock.New("https://bsky.social").
15 | Post("/xrpc/com.atproto.server.createSession").
16 | MatchHeader("Content-Type", "application/json").
17 | Reply(200).
18 | JSON(map[string]string{
19 | "Did": "sample-did",
20 | "AccessJwt": "sample-access-jwt",
21 | "RefreshJwt": "sample-refresh-jwt",
22 | })
23 |
24 | client, err := Authenticate("handle123", "passwordABC")
25 |
26 | assert.NoError(t, err)
27 |
28 | assert.NotNil(t, client)
29 | assert.Equal(t, "sample-did", client.Auth.Did)
30 | assert.Equal(t, "sample-access-jwt", client.Auth.AccessJwt)
31 | assert.Equal(t, "sample-refresh-jwt", client.Auth.RefreshJwt)
32 |
33 | assert.True(t, gock.IsDone(), "Not all gock interceptors were triggered")
34 | }
35 |
36 | func TestAuthenticate_Failure(t *testing.T) {
37 | gock.DisableNetworking()
38 | defer gock.Off()
39 |
40 | gock.New("https://bsky.social").
41 | Post("/xrpc/com.atproto.server.createSession").
42 | Reply(401)
43 |
44 | client, err := Authenticate("handle123", "passwordABC")
45 |
46 | assert.Error(t, err)
47 | assert.Nil(t, client)
48 |
49 | assert.True(t, gock.IsDone(), "Not all gock interceptors were triggered")
50 | }
51 |
--------------------------------------------------------------------------------
/internal/bridge/bluesky.go:
--------------------------------------------------------------------------------
1 | package bridge
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "net/http"
8 | "os"
9 | "strings"
10 | "time"
11 |
12 | comatproto "github.com/bluesky-social/indigo/api/atproto"
13 | "github.com/bluesky-social/indigo/api/bsky"
14 | lexutil "github.com/bluesky-social/indigo/lex/util"
15 | "github.com/systemli/ticker/internal/bluesky"
16 | "github.com/systemli/ticker/internal/config"
17 | "github.com/systemli/ticker/internal/storage"
18 | "github.com/systemli/ticker/internal/util"
19 | )
20 |
21 | type BlueskyBridge struct {
22 | config config.Config
23 | storage storage.Storage
24 | }
25 |
26 | func (bb *BlueskyBridge) Update(ticker storage.Ticker) error {
27 | return nil
28 | }
29 |
30 | func (bb *BlueskyBridge) Send(ticker storage.Ticker, message *storage.Message) error {
31 | if !ticker.Bluesky.Connected() || !ticker.Bluesky.Active {
32 | return nil
33 | }
34 |
35 | client, err := bluesky.Authenticate(ticker.Bluesky.Handle, ticker.Bluesky.AppKey)
36 | if err != nil {
37 | log.WithError(err).Error("failed to create client")
38 | return err
39 | }
40 |
41 | post := &bsky.FeedPost{
42 | Text: message.Text,
43 | CreatedAt: time.Now().Local().Format(time.RFC3339),
44 | Facets: []*bsky.RichtextFacet{},
45 | }
46 |
47 | links := util.ExtractURLs(message.Text)
48 | for _, link := range links {
49 | startIndex := strings.Index(message.Text, link)
50 | endIndex := startIndex + len(link)
51 | post.Facets = append(post.Facets, &bsky.RichtextFacet{
52 | Features: []*bsky.RichtextFacet_Features_Elem{
53 | {
54 | RichtextFacet_Link: &bsky.RichtextFacet_Link{
55 | Uri: link,
56 | },
57 | },
58 | },
59 | Index: &bsky.RichtextFacet_ByteSlice{
60 | ByteStart: int64(startIndex),
61 | ByteEnd: int64(endIndex),
62 | },
63 | })
64 | }
65 |
66 | hashtags := util.ExtractHashtags(message.Text)
67 | for _, hashtag := range hashtags {
68 | startIndex := strings.Index(message.Text, hashtag)
69 | endIndex := startIndex + len(hashtag)
70 | post.Facets = append(post.Facets, &bsky.RichtextFacet{
71 | Features: []*bsky.RichtextFacet_Features_Elem{
72 | {
73 | RichtextFacet_Tag: &bsky.RichtextFacet_Tag{
74 | Tag: hashtag[1:],
75 | },
76 | },
77 | },
78 | Index: &bsky.RichtextFacet_ByteSlice{
79 | ByteStart: int64(startIndex),
80 | ByteEnd: int64(endIndex),
81 | },
82 | })
83 | }
84 |
85 | if len(message.Attachments) > 0 {
86 | var images []*bsky.EmbedImages_Image
87 |
88 | for _, attachment := range message.Attachments {
89 | upload, err := bb.storage.FindUploadByUUID(attachment.UUID)
90 | if err != nil {
91 | log.WithError(err).Error("failed to find upload")
92 | continue
93 | }
94 |
95 | b, err := os.ReadFile(upload.FullPath(bb.config.Upload.Path))
96 | if err != nil {
97 | log.WithError(err).Error("failed to read file")
98 | continue
99 | }
100 |
101 | resp, err := comatproto.RepoUploadBlob(context.TODO(), client, bytes.NewReader(b))
102 | if err != nil {
103 | log.WithError(err).Error("failed to upload blob")
104 | continue
105 | }
106 |
107 | images = append(images, &bsky.EmbedImages_Image{
108 | Image: &lexutil.LexBlob{
109 | Ref: resp.Blob.Ref,
110 | MimeType: http.DetectContentType(b),
111 | Size: resp.Blob.Size,
112 | },
113 | })
114 | }
115 |
116 | if post.Embed == nil {
117 | post.Embed = &bsky.FeedPost_Embed{}
118 | }
119 |
120 | post.Embed.EmbedImages = &bsky.EmbedImages{
121 | Images: images,
122 | }
123 | }
124 |
125 | resp, err := comatproto.RepoCreateRecord(context.TODO(), client, &comatproto.RepoCreateRecord_Input{
126 | Collection: "app.bsky.feed.post",
127 | Repo: client.Auth.Did,
128 | Record: &lexutil.LexiconTypeDecoder{
129 | Val: post,
130 | },
131 | })
132 | if err != nil {
133 | log.WithError(err).Error("failed to create post")
134 | return err
135 | }
136 |
137 | message.Bluesky = storage.BlueskyMeta{
138 | Handle: ticker.Bluesky.Handle,
139 | Uri: resp.Uri,
140 | Cid: resp.Cid,
141 | }
142 |
143 | return nil
144 | }
145 |
146 | func (bb *BlueskyBridge) Delete(ticker storage.Ticker, message *storage.Message) error {
147 | if !ticker.Bluesky.Connected() {
148 | return nil
149 | }
150 |
151 | if message.Bluesky.Uri == "" {
152 | return nil
153 | }
154 |
155 | client, err := bluesky.Authenticate(ticker.Bluesky.Handle, ticker.Bluesky.AppKey)
156 | if err != nil {
157 | log.WithError(err).Error("failed to create client")
158 | return err
159 | }
160 |
161 | uri := message.Bluesky.Uri
162 | if !strings.HasPrefix(uri, "at://did:plc:") {
163 | uri = "at://did:plc:" + uri
164 | }
165 |
166 | parts := strings.Split(uri, "/")
167 | if len(parts) < 3 {
168 | log.WithError(err).WithField("uri", uri).Error("invalid post uri")
169 | return fmt.Errorf("invalid post uri")
170 | }
171 | rkey := parts[len(parts)-1]
172 | schema := parts[len(parts)-2]
173 |
174 | _, err = comatproto.RepoDeleteRecord(context.TODO(), client, &comatproto.RepoDeleteRecord_Input{
175 | Repo: client.Auth.Did,
176 | Collection: schema,
177 | Rkey: rkey,
178 | })
179 | if err != nil {
180 | log.WithError(err).Error("failed to delete post")
181 | }
182 |
183 | return err
184 | }
185 |
--------------------------------------------------------------------------------
/internal/bridge/bridge.go:
--------------------------------------------------------------------------------
1 | package bridge
2 |
3 | import (
4 | "github.com/sirupsen/logrus"
5 | "github.com/systemli/ticker/internal/config"
6 | "github.com/systemli/ticker/internal/storage"
7 | )
8 |
9 | var log = logrus.WithField("package", "bridge")
10 |
11 | type Bridge interface {
12 | Update(ticker storage.Ticker) error
13 | Send(ticker storage.Ticker, message *storage.Message) error
14 | Delete(ticker storage.Ticker, message *storage.Message) error
15 | }
16 |
17 | type Bridges map[string]Bridge
18 |
19 | func RegisterBridges(config config.Config, storage storage.Storage) Bridges {
20 | telegram := TelegramBridge{config, storage}
21 | mastodon := MastodonBridge{config, storage}
22 | bluesky := BlueskyBridge{config, storage}
23 | signalGroup := SignalGroupBridge{config, storage}
24 |
25 | return Bridges{"telegram": &telegram, "mastodon": &mastodon, "bluesky": &bluesky, "signalGroup": &signalGroup}
26 | }
27 |
28 | func (b *Bridges) Update(ticker storage.Ticker) error {
29 | var err error
30 | for name, bridge := range *b {
31 | err := bridge.Update(ticker)
32 | if err != nil {
33 | log.WithError(err).WithField("bridge_name", name).Error("failed to update ticker")
34 | }
35 | }
36 |
37 | return err
38 | }
39 |
40 | func (b *Bridges) Send(ticker storage.Ticker, message *storage.Message) error {
41 | var err error
42 | for name, bridge := range *b {
43 | err := bridge.Send(ticker, message)
44 | if err != nil {
45 | log.WithError(err).WithField("bridge_name", name).Error("failed to send message")
46 | }
47 | }
48 |
49 | return err
50 | }
51 |
52 | func (b *Bridges) Delete(ticker storage.Ticker, message *storage.Message) error {
53 | var err error
54 | for name, bridge := range *b {
55 | err := bridge.Delete(ticker, message)
56 | if err != nil {
57 | log.WithError(err).WithField("bridge_name", name).Error("failed to delete message")
58 | }
59 | }
60 |
61 | return err
62 | }
63 |
--------------------------------------------------------------------------------
/internal/bridge/bridge_test.go:
--------------------------------------------------------------------------------
1 | package bridge
2 |
3 | import (
4 | "errors"
5 | "io"
6 | "testing"
7 |
8 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
9 | "github.com/h2non/gock"
10 | "github.com/stretchr/testify/mock"
11 | "github.com/stretchr/testify/suite"
12 | "github.com/systemli/ticker/internal/config"
13 | "github.com/systemli/ticker/internal/storage"
14 | )
15 |
16 | var tickerWithoutBridges storage.Ticker
17 | var tickerWithBridges storage.Ticker
18 |
19 | var messageWithoutBridges storage.Message
20 | var messageWithBridges storage.Message
21 |
22 | type BridgeTestSuite struct {
23 | suite.Suite
24 | }
25 |
26 | func (s *BridgeTestSuite) SetupTest() {
27 | log.Logger.SetOutput(io.Discard)
28 | gock.DisableNetworking()
29 | defer gock.Off()
30 |
31 | tickerWithoutBridges = storage.Ticker{
32 | Mastodon: storage.TickerMastodon{
33 | Active: false,
34 | },
35 | Telegram: storage.TickerTelegram{
36 | Active: false,
37 | },
38 | }
39 | tickerWithBridges = storage.Ticker{
40 | Mastodon: storage.TickerMastodon{
41 | Active: true,
42 | Server: "https://systemli.social",
43 | Token: "token",
44 | Secret: "secret",
45 | AccessToken: "access_token",
46 | },
47 | Telegram: storage.TickerTelegram{
48 | Active: true,
49 | ChannelName: "channel",
50 | },
51 | Bluesky: storage.TickerBluesky{
52 | Active: true,
53 | Handle: "handle",
54 | AppKey: "app_key",
55 | },
56 | SignalGroup: storage.TickerSignalGroup{
57 | Active: true,
58 | GroupID: "sample-group-id",
59 | },
60 | }
61 | messageWithoutBridges = storage.Message{
62 | Text: "Hello World https://example.com",
63 | Attachments: []storage.Attachment{
64 | {
65 | UUID: "123",
66 | },
67 | },
68 | }
69 | messageWithBridges = storage.Message{
70 | Text: "Hello World",
71 | Attachments: []storage.Attachment{
72 | {
73 | UUID: "123",
74 | },
75 | {
76 | UUID: "456",
77 | },
78 | },
79 | Mastodon: storage.MastodonMeta{
80 | ID: "123",
81 | },
82 | Telegram: storage.TelegramMeta{
83 | Messages: []tgbotapi.Message{
84 | {
85 | MessageID: 123,
86 | Chat: &tgbotapi.Chat{
87 | ID: 123,
88 | },
89 | },
90 | },
91 | },
92 | Bluesky: storage.BlueskyMeta{
93 | Handle: "handle",
94 | Uri: "at://did:plc:sample-uri",
95 | Cid: "cid",
96 | },
97 | SignalGroup: storage.SignalGroupMeta{
98 | Timestamp: 123,
99 | },
100 | }
101 | }
102 |
103 | func (s *BridgeTestSuite) TestUpdate() {
104 | s.Run("when successful", func() {
105 | ticker := storage.Ticker{}
106 | bridge := MockBridge{}
107 | bridge.On("Update", ticker).Return(nil).Once()
108 |
109 | bridges := Bridges{"mock": &bridge}
110 | err := bridges.Update(ticker)
111 | s.NoError(err)
112 | s.True(bridge.AssertExpectations(s.T()))
113 | })
114 |
115 | s.Run("when failed", func() {
116 | ticker := storage.Ticker{}
117 | bridge := MockBridge{}
118 | bridge.On("Update", ticker).Return(errors.New("failed to update ticker")).Once()
119 |
120 | bridges := Bridges{"mock": &bridge}
121 | _ = bridges.Update(ticker)
122 | s.True(bridge.AssertExpectations(s.T()))
123 | })
124 | }
125 |
126 | func (s *BridgeTestSuite) TestSend() {
127 | s.Run("when successful", func() {
128 | ticker := storage.Ticker{}
129 | bridge := MockBridge{}
130 | bridge.On("Send", ticker, mock.Anything).Return(nil).Once()
131 |
132 | bridges := Bridges{"mock": &bridge}
133 | err := bridges.Send(ticker, nil)
134 | s.NoError(err)
135 | s.True(bridge.AssertExpectations(s.T()))
136 | })
137 |
138 | s.Run("when failed", func() {
139 | ticker := storage.Ticker{}
140 | bridge := MockBridge{}
141 | bridge.On("Send", ticker, mock.Anything).Return(errors.New("failed to send message")).Once()
142 |
143 | bridges := Bridges{"mock": &bridge}
144 | _ = bridges.Send(ticker, nil)
145 | s.True(bridge.AssertExpectations(s.T()))
146 | })
147 | }
148 |
149 | func (s *BridgeTestSuite) TestDelete() {
150 | s.Run("when successful", func() {
151 | ticker := storage.Ticker{}
152 | bridge := MockBridge{}
153 | bridge.On("Delete", ticker, mock.Anything).Return(nil).Once()
154 |
155 | bridges := Bridges{"mock": &bridge}
156 | err := bridges.Delete(ticker, nil)
157 | s.NoError(err)
158 | s.True(bridge.AssertExpectations(s.T()))
159 | })
160 |
161 | s.Run("when failed", func() {
162 | ticker := storage.Ticker{}
163 | bridge := MockBridge{}
164 | bridge.On("Delete", ticker, mock.Anything).Return(errors.New("failed to delete message")).Once()
165 |
166 | bridges := Bridges{"mock": &bridge}
167 | _ = bridges.Delete(ticker, nil)
168 | s.True(bridge.AssertExpectations(s.T()))
169 | })
170 | }
171 |
172 | func (s *BridgeTestSuite) TestRegisterBridges() {
173 | bridges := RegisterBridges(config.Config{}, nil)
174 | s.Equal(4, len(bridges))
175 | }
176 |
177 | func TestBrigde(t *testing.T) {
178 | suite.Run(t, new(BridgeTestSuite))
179 | }
180 |
--------------------------------------------------------------------------------
/internal/bridge/mastodon.go:
--------------------------------------------------------------------------------
1 | package bridge
2 |
3 | import (
4 | "context"
5 | "errors"
6 |
7 | "github.com/mattn/go-mastodon"
8 | "github.com/systemli/ticker/internal/config"
9 | "github.com/systemli/ticker/internal/storage"
10 | )
11 |
12 | type MastodonBridge struct {
13 | config config.Config
14 | storage storage.Storage
15 | }
16 |
17 | func (mb *MastodonBridge) Update(ticker storage.Ticker) error {
18 | return nil
19 | }
20 |
21 | func (mb *MastodonBridge) Send(ticker storage.Ticker, message *storage.Message) error {
22 | if !ticker.Mastodon.Active {
23 | return nil
24 | }
25 |
26 | ctx := context.Background()
27 | client := client(ticker)
28 |
29 | var mediaIDs []mastodon.ID
30 | if len(message.Attachments) > 0 {
31 | for _, attachment := range message.Attachments {
32 | upload, err := mb.storage.FindUploadByUUID(attachment.UUID)
33 | if err != nil {
34 | log.WithError(err).Error("failed to find upload")
35 | continue
36 | }
37 |
38 | media, err := client.UploadMedia(ctx, upload.FullPath(mb.config.Upload.Path))
39 | if err != nil {
40 | log.WithError(err).Error("unable to upload the attachment")
41 | continue
42 | }
43 | mediaIDs = append(mediaIDs, media.ID)
44 | }
45 | }
46 |
47 | toot := mastodon.Toot{
48 | Status: message.Text,
49 | MediaIDs: mediaIDs,
50 | }
51 |
52 | status, err := client.PostStatus(ctx, &toot)
53 | if err != nil {
54 | return err
55 | }
56 |
57 | message.Mastodon = storage.MastodonMeta{
58 | ID: string(status.ID),
59 | URI: status.URI,
60 | URL: status.URL,
61 | }
62 |
63 | return nil
64 | }
65 |
66 | func (mb *MastodonBridge) Delete(ticker storage.Ticker, message *storage.Message) error {
67 | if message.Mastodon.ID == "" {
68 | return nil
69 | }
70 |
71 | if !ticker.Mastodon.Connected() {
72 | return errors.New("unable to delete the status")
73 | }
74 |
75 | ctx := context.Background()
76 | client := client(ticker)
77 |
78 | return client.DeleteStatus(ctx, mastodon.ID(message.Mastodon.ID))
79 | }
80 |
81 | func client(ticker storage.Ticker) *mastodon.Client {
82 | return mastodon.NewClient(&mastodon.Config{
83 | Server: ticker.Mastodon.Server,
84 | ClientID: ticker.Mastodon.Token,
85 | ClientSecret: ticker.Mastodon.Secret,
86 | AccessToken: ticker.Mastodon.AccessToken,
87 | })
88 | }
89 |
--------------------------------------------------------------------------------
/internal/bridge/mastodon_test.go:
--------------------------------------------------------------------------------
1 | package bridge
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/h2non/gock"
7 | "github.com/systemli/ticker/internal/config"
8 | "github.com/systemli/ticker/internal/storage"
9 | )
10 |
11 | func (s *BridgeTestSuite) TestMastodonUpdate() {
12 | s.Run("does nothing", func() {
13 | bridge := s.mastodonBridge(config.Config{}, &storage.MockStorage{})
14 |
15 | err := bridge.Update(tickerWithBridges)
16 | s.NoError(err)
17 | })
18 | }
19 |
20 | func (s *BridgeTestSuite) TestMastodonSend() {
21 | s.Run("when mastodon is inactive", func() {
22 | bridge := s.mastodonBridge(config.Config{}, &storage.MockStorage{})
23 |
24 | err := bridge.Send(tickerWithoutBridges, &messageWithoutBridges)
25 | s.NoError(err)
26 | })
27 |
28 | s.Run("when mastodon is active but upload cant not found", func() {
29 | mockStorage := &storage.MockStorage{}
30 | mockStorage.On("FindUploadByUUID", "123").Return(storage.Upload{}, nil).Once()
31 | bridge := s.mastodonBridge(config.Config{}, mockStorage)
32 |
33 | gock.New("https://systemli.social").
34 | Post("/api/v1/statuses").
35 | Reply(200).
36 | JSON(map[string]string{
37 | "id": "123",
38 | "uri": "https://systemli.social/@systemli/123",
39 | "url": "https://systemli.social/@systemli/123",
40 | })
41 |
42 | err := bridge.Send(tickerWithBridges, &messageWithoutBridges)
43 | s.NoError(err)
44 | s.Equal("123", messageWithoutBridges.Mastodon.ID)
45 | s.Equal("https://systemli.social/@systemli/123", messageWithoutBridges.Mastodon.URI)
46 | s.Equal("https://systemli.social/@systemli/123", messageWithoutBridges.Mastodon.URL)
47 | s.True(gock.IsDone())
48 | s.True(mockStorage.AssertExpectations(s.T()))
49 | })
50 |
51 | s.Run("when mastodon is active and upload is not found", func() {
52 | mockStorage := &storage.MockStorage{}
53 | mockStorage.On("FindUploadByUUID", "123").Return(storage.Upload{}, errors.New("upload not found")).Once()
54 | bridge := s.mastodonBridge(config.Config{}, mockStorage)
55 |
56 | gock.New("https://systemli.social").
57 | Post("/api/v1/statuses").
58 | Reply(200).
59 | JSON(map[string]string{
60 | "id": "123",
61 | "uri": "https://systemli.social/@systemli/123",
62 | "url": "https://systemli.social/@systemli/123",
63 | })
64 |
65 | err := bridge.Send(tickerWithBridges, &messageWithoutBridges)
66 | s.NoError(err)
67 | s.Equal("123", messageWithoutBridges.Mastodon.ID)
68 | s.Equal("https://systemli.social/@systemli/123", messageWithoutBridges.Mastodon.URI)
69 | s.Equal("https://systemli.social/@systemli/123", messageWithoutBridges.Mastodon.URL)
70 | s.True(gock.IsDone())
71 | s.True(mockStorage.AssertExpectations(s.T()))
72 | })
73 |
74 | s.Run("when mastodon is active but post status fails", func() {
75 | mockStorage := &storage.MockStorage{}
76 | mockStorage.On("FindUploadByUUID", "123").Return(storage.Upload{}, nil).Once()
77 | bridge := s.mastodonBridge(config.Config{}, mockStorage)
78 |
79 | gock.New("https://systemli.social").
80 | Post("/api/v1/statuses").
81 | Reply(500)
82 |
83 | err := bridge.Send(tickerWithBridges, &messageWithoutBridges)
84 | s.Error(err)
85 | s.True(gock.IsDone())
86 | s.True(mockStorage.AssertExpectations(s.T()))
87 | })
88 | }
89 |
90 | func (s *BridgeTestSuite) TestMastodonDelete() {
91 | s.Run("when message has no mastodon meta", func() {
92 | bridge := s.mastodonBridge(config.Config{}, &storage.MockStorage{})
93 |
94 | err := bridge.Delete(tickerWithBridges, &messageWithoutBridges)
95 | s.NoError(err)
96 | })
97 |
98 | s.Run("when mastodon is inactive", func() {
99 | bridge := s.mastodonBridge(config.Config{}, &storage.MockStorage{})
100 |
101 | err := bridge.Delete(tickerWithoutBridges, &messageWithBridges)
102 | s.Error(err)
103 | s.Equal("unable to delete the status", err.Error())
104 | })
105 |
106 | s.Run("when mastodon is active", func() {
107 | bridge := s.mastodonBridge(config.Config{}, &storage.MockStorage{})
108 |
109 | gock.New("https://systemli.social").
110 | Delete("/api/v1/statuses/123").
111 | Reply(200)
112 |
113 | err := bridge.Delete(tickerWithBridges, &messageWithBridges)
114 | s.NoError(err)
115 | s.True(gock.IsDone())
116 | })
117 | }
118 |
119 | func (s *BridgeTestSuite) mastodonBridge(config config.Config, storage storage.Storage) *MastodonBridge {
120 | return &MastodonBridge{
121 | config: config,
122 | storage: storage,
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/internal/bridge/mock_Bridge.go:
--------------------------------------------------------------------------------
1 | // Code generated by mockery v2.43.0. DO NOT EDIT.
2 |
3 | package bridge
4 |
5 | import (
6 | mock "github.com/stretchr/testify/mock"
7 | storage "github.com/systemli/ticker/internal/storage"
8 | )
9 |
10 | // MockBridge is an autogenerated mock type for the Bridge type
11 | type MockBridge struct {
12 | mock.Mock
13 | }
14 |
15 | func (_m *MockBridge) Update(ticker storage.Ticker) error {
16 | ret := _m.Called(ticker)
17 |
18 | if len(ret) == 0 {
19 | panic("no return value specified for Update")
20 | }
21 |
22 | var r0 error
23 | if rf, ok := ret.Get(0).(func(storage.Ticker) error); ok {
24 | r0 = rf(ticker)
25 | } else {
26 | r0 = ret.Error(0)
27 | }
28 |
29 | return r0
30 | }
31 |
32 | // Delete provides a mock function with given fields: ticker, message
33 | func (_m *MockBridge) Delete(ticker storage.Ticker, message *storage.Message) error {
34 | ret := _m.Called(ticker, message)
35 |
36 | if len(ret) == 0 {
37 | panic("no return value specified for Delete")
38 | }
39 |
40 | var r0 error
41 | if rf, ok := ret.Get(0).(func(storage.Ticker, *storage.Message) error); ok {
42 | r0 = rf(ticker, message)
43 | } else {
44 | r0 = ret.Error(0)
45 | }
46 |
47 | return r0
48 | }
49 |
50 | // Send provides a mock function with given fields: ticker, message
51 | func (_m *MockBridge) Send(ticker storage.Ticker, message *storage.Message) error {
52 | ret := _m.Called(ticker, message)
53 |
54 | if len(ret) == 0 {
55 | panic("no return value specified for Send")
56 | }
57 |
58 | var r0 error
59 | if rf, ok := ret.Get(0).(func(storage.Ticker, *storage.Message) error); ok {
60 | r0 = rf(ticker, message)
61 | } else {
62 | r0 = ret.Error(0)
63 | }
64 |
65 | return r0
66 | }
67 |
68 | // NewMockBridge creates a new instance of MockBridge. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
69 | // The first argument is typically a *testing.T value.
70 | func NewMockBridge(t interface {
71 | mock.TestingT
72 | Cleanup(func())
73 | }) *MockBridge {
74 | mock := &MockBridge{}
75 | mock.Mock.Test(t)
76 |
77 | t.Cleanup(func() { mock.AssertExpectations(t) })
78 |
79 | return mock
80 | }
81 |
--------------------------------------------------------------------------------
/internal/bridge/signal_group.go:
--------------------------------------------------------------------------------
1 | package bridge
2 |
3 | import (
4 | "context"
5 | "encoding/base64"
6 | "errors"
7 | "fmt"
8 | "os"
9 |
10 | "github.com/systemli/ticker/internal/config"
11 | "github.com/systemli/ticker/internal/signal"
12 | "github.com/systemli/ticker/internal/storage"
13 | )
14 |
15 | type SignalGroupBridge struct {
16 | config config.Config
17 | storage storage.Storage
18 | }
19 |
20 | type SignalGroupResponse struct {
21 | Timestamp int `json:"timestamp"`
22 | }
23 |
24 | func (sb *SignalGroupBridge) Update(ticker storage.Ticker) error {
25 | if !sb.config.SignalGroup.Enabled() || !ticker.SignalGroup.Connected() {
26 | return nil
27 | }
28 |
29 | groupClient := signal.NewGroupClient(sb.config)
30 | err := groupClient.CreateOrUpdateGroup(&ticker)
31 | if err != nil {
32 | return err
33 | }
34 |
35 | return nil
36 | }
37 |
38 | func (sb *SignalGroupBridge) Send(ticker storage.Ticker, message *storage.Message) error {
39 | if !sb.config.SignalGroup.Enabled() || !ticker.SignalGroup.Connected() || !ticker.SignalGroup.Active {
40 | return nil
41 | }
42 |
43 | ctx := context.Background()
44 | client := signal.Client(sb.config)
45 |
46 | var attachments []string
47 | if len(message.Attachments) > 0 {
48 | for _, attachment := range message.Attachments {
49 | upload, err := sb.storage.FindUploadByUUID(attachment.UUID)
50 | if err != nil {
51 | log.WithError(err).Error("failed to find upload")
52 | continue
53 | }
54 |
55 | fileContent, err := os.ReadFile(upload.FullPath(sb.config.Upload.Path))
56 | if err != nil {
57 | log.WithError(err).Error("failed to read file")
58 | continue
59 | }
60 | fileBase64 := base64.StdEncoding.EncodeToString(fileContent)
61 | aString := fmt.Sprintf("data:%s;filename=%s;base64,%s", upload.ContentType, upload.FileName(), fileBase64)
62 | attachments = append(attachments, aString)
63 | }
64 | }
65 |
66 | params := struct {
67 | Account string `json:"account"`
68 | GroupID string `json:"group-id"`
69 | Message string `json:"message"`
70 | Attachment []string `json:"attachment"`
71 | }{
72 | Account: sb.config.SignalGroup.Account,
73 | GroupID: ticker.SignalGroup.GroupID,
74 | Message: message.Text,
75 | Attachment: attachments,
76 | }
77 |
78 | var response SignalGroupResponse
79 | err := client.CallFor(ctx, &response, "send", ¶ms)
80 | if err != nil {
81 | return err
82 | }
83 | if response.Timestamp == 0 {
84 | return errors.New("SignalGroup Bridge: No timestamp in send response")
85 | }
86 |
87 | message.SignalGroup = storage.SignalGroupMeta{
88 | Timestamp: response.Timestamp,
89 | }
90 |
91 | return nil
92 | }
93 |
94 | func (sb *SignalGroupBridge) Delete(ticker storage.Ticker, message *storage.Message) error {
95 | if !sb.config.SignalGroup.Enabled() || !ticker.SignalGroup.Connected() || !ticker.SignalGroup.Active || message.SignalGroup.Timestamp == 0 {
96 | return nil
97 | }
98 |
99 | client := signal.Client(sb.config)
100 | params := struct {
101 | Account string `json:"account"`
102 | GroupID string `json:"group-id"`
103 | TargetTimestamp int `json:"target-timestamp"`
104 | }{
105 | Account: sb.config.SignalGroup.Account,
106 | GroupID: ticker.SignalGroup.GroupID,
107 | TargetTimestamp: message.SignalGroup.Timestamp,
108 | }
109 |
110 | var response SignalGroupResponse
111 | err := client.CallFor(context.Background(), &response, "remoteDelete", ¶ms)
112 | if err != nil {
113 | return err
114 | }
115 |
116 | return nil
117 | }
118 |
--------------------------------------------------------------------------------
/internal/bridge/telegram.go:
--------------------------------------------------------------------------------
1 | package bridge
2 |
3 | import (
4 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
5 | "github.com/systemli/ticker/internal/config"
6 | "github.com/systemli/ticker/internal/storage"
7 | )
8 |
9 | type TelegramBridge struct {
10 | config config.Config
11 | storage storage.Storage
12 | }
13 |
14 | func (tb *TelegramBridge) Update(ticker storage.Ticker) error {
15 | return nil
16 | }
17 |
18 | func (tb *TelegramBridge) Send(ticker storage.Ticker, message *storage.Message) error {
19 | if ticker.Telegram.ChannelName == "" || !tb.config.Telegram.Enabled() || !ticker.Telegram.Active {
20 | return nil
21 | }
22 |
23 | bot, err := tgbotapi.NewBotAPI(tb.config.Telegram.Token)
24 | if err != nil {
25 | return err
26 | }
27 |
28 | if len(message.Attachments) == 0 {
29 | msgConfig := tgbotapi.NewMessageToChannel(ticker.Telegram.ChannelName, message.Text)
30 | msg, err := bot.Send(msgConfig)
31 | if err != nil {
32 | return err
33 | }
34 | message.Telegram = storage.TelegramMeta{Messages: []tgbotapi.Message{msg}}
35 | } else {
36 | var photos []interface{}
37 | for i, attachment := range message.Attachments {
38 | upload, err := tb.storage.FindUploadByUUID(attachment.UUID)
39 | if err != nil {
40 | log.WithError(err).Error("failed to find upload")
41 | continue
42 | }
43 |
44 | media := tgbotapi.FilePath(upload.FullPath(tb.config.Upload.Path))
45 | if upload.ContentType == "image/gif" {
46 | photo := tgbotapi.NewInputMediaDocument(media)
47 | if i == 0 {
48 | photo.Caption = message.Text
49 | }
50 | photos = append(photos, photo)
51 | } else {
52 | photo := tgbotapi.NewInputMediaPhoto(media)
53 | if i == 0 {
54 | photo.Caption = message.Text
55 | }
56 | photos = append(photos, photo)
57 | }
58 | }
59 |
60 | mediaGroup := tgbotapi.MediaGroupConfig{
61 | ChannelUsername: ticker.Telegram.ChannelName,
62 | Media: photos,
63 | }
64 |
65 | msgs, err := bot.SendMediaGroup(mediaGroup)
66 | if err != nil {
67 | return err
68 | }
69 | message.Telegram = storage.TelegramMeta{Messages: msgs}
70 | }
71 |
72 | return nil
73 | }
74 |
75 | func (tb *TelegramBridge) Delete(ticker storage.Ticker, message *storage.Message) error {
76 | if ticker.Telegram.ChannelName == "" || !tb.config.Telegram.Enabled() {
77 | return nil
78 | }
79 |
80 | if len(message.Telegram.Messages) == 0 {
81 | return nil
82 | }
83 |
84 | bot, err := tgbotapi.NewBotAPI(tb.config.Telegram.Token)
85 | if err != nil {
86 | return err
87 | }
88 |
89 | for _, message := range message.Telegram.Messages {
90 | deleteMessageConfig := tgbotapi.DeleteMessageConfig{MessageID: message.MessageID, ChatID: message.Chat.ID}
91 | _, err = bot.Request(deleteMessageConfig)
92 | if err != nil {
93 | log.WithError(err).Error("failed to delete telegram message")
94 | continue
95 | }
96 | }
97 |
98 | return nil
99 | }
100 |
101 | func BotUser(token string) (tgbotapi.User, error) {
102 | bot, err := tgbotapi.NewBotAPI(token)
103 | if err != nil {
104 | return tgbotapi.User{}, err
105 | }
106 |
107 | return bot.Self, nil
108 | }
109 |
--------------------------------------------------------------------------------
/internal/cache/cache.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | import (
4 | "sync"
5 | "time"
6 |
7 | "github.com/sirupsen/logrus"
8 | )
9 |
10 | var log = logrus.WithField("package", "cache")
11 |
12 | // Cache is a simple in-memory cache with expiration.
13 | type Cache struct {
14 | items sync.Map
15 | close chan struct{}
16 | }
17 |
18 | type item struct {
19 | data interface{}
20 | expires int64
21 | }
22 |
23 | // NewCache creates a new cache with a cleaning interval.
24 | func NewCache(cleaningInterval time.Duration) *Cache {
25 | cache := &Cache{
26 | close: make(chan struct{}),
27 | }
28 |
29 | go func() {
30 | ticker := time.NewTicker(cleaningInterval)
31 | defer ticker.Stop()
32 |
33 | for {
34 | select {
35 | case <-ticker.C:
36 | now := time.Now().UnixNano()
37 |
38 | cache.items.Range(func(key, value interface{}) bool {
39 | item := value.(item)
40 |
41 | if item.expires > 0 && now > item.expires {
42 | cache.items.Delete(key)
43 | }
44 |
45 | return true
46 | })
47 |
48 | case <-cache.close:
49 | return
50 | }
51 | }
52 | }()
53 |
54 | return cache
55 | }
56 |
57 | // Get returns a value from the cache.
58 | func (cache *Cache) Get(key interface{}) (interface{}, bool) {
59 | obj, exists := cache.items.Load(key)
60 |
61 | if !exists {
62 | log.WithField("key", key).Debug("cache miss")
63 | return nil, false
64 | }
65 |
66 | item := obj.(item)
67 |
68 | if item.expires > 0 && time.Now().UnixNano() > item.expires {
69 | log.WithField("key", key).Debug("cache expired")
70 | return nil, false
71 | }
72 |
73 | log.WithField("key", key).Debug("cache hit")
74 | return item.data, true
75 | }
76 |
77 | // Set stores a value in the cache.
78 | func (cache *Cache) Set(key interface{}, value interface{}, duration time.Duration) {
79 | var expires int64
80 |
81 | if duration > 0 {
82 | expires = time.Now().Add(duration).UnixNano()
83 | }
84 |
85 | cache.items.Store(key, item{
86 | data: value,
87 | expires: expires,
88 | })
89 | }
90 |
91 | // Range loops over all items in the cache.
92 | func (cache *Cache) Range(f func(key, value interface{}) bool) {
93 | now := time.Now().UnixNano()
94 |
95 | fn := func(key, value interface{}) bool {
96 | item := value.(item)
97 |
98 | if item.expires > 0 && now > item.expires {
99 | return true
100 | }
101 |
102 | return f(key, item.data)
103 | }
104 |
105 | cache.items.Range(fn)
106 | }
107 |
108 | // Delete removes a value from the cache.
109 | func (cache *Cache) Delete(key interface{}) {
110 | cache.items.Delete(key)
111 | }
112 |
113 | // Close stops the cleaning interval and clears the cache.
114 | func (cache *Cache) Close() {
115 | cache.close <- struct{}{}
116 | cache.items = sync.Map{}
117 | }
118 |
--------------------------------------------------------------------------------
/internal/cache/cache_test.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/stretchr/testify/suite"
8 | )
9 |
10 | type CacheTestSuite struct {
11 | suite.Suite
12 | }
13 |
14 | func TestCacheTestSuite(t *testing.T) {
15 | suite.Run(t, new(CacheTestSuite))
16 | }
17 |
18 | func (s *CacheTestSuite) TestCache() {
19 | interval := 100 * time.Microsecond
20 | c := NewCache(interval)
21 | defer c.Close()
22 |
23 | s.Run("Set", func() {
24 | s.Run("adds value to cache", func() {
25 | c.Set("foo", "bar", 0)
26 | foo, found := c.Get("foo")
27 | s.True(found)
28 | s.Equal("bar", foo)
29 | })
30 |
31 | s.Run("add value to cache with expiration", func() {
32 | c.Set("foo", "bar", interval/2)
33 | foo, found := c.Get("foo")
34 | s.True(found)
35 | s.Equal("bar", foo)
36 |
37 | time.Sleep(interval)
38 |
39 | foo, found = c.Get("foo")
40 | s.False(found)
41 | s.Empty(foo)
42 | })
43 | })
44 |
45 | s.Run("Get", func() {
46 | s.Run("returns empty value if not found", func() {
47 | foo, found := c.Get("foo")
48 | s.False(found)
49 | s.Empty(foo)
50 | })
51 |
52 | s.Run("returns empty value if expired", func() {
53 | c.Set("foo", "bar", interval/2)
54 | foo, found := c.Get("foo")
55 | s.True(found)
56 | s.Equal("bar", foo)
57 |
58 | time.Sleep(interval)
59 |
60 | foo, found = c.Get("foo")
61 | s.False(found)
62 | s.Empty(foo)
63 | })
64 | })
65 |
66 | s.Run("Delete", func() {
67 | s.Run("removes value from cache", func() {
68 | c.Set("foo", "bar", 0)
69 | foo, found := c.Get("foo")
70 | s.True(found)
71 | s.Equal("bar", foo)
72 |
73 | c.Delete("foo")
74 | foo, found = c.Get("foo")
75 | s.False(found)
76 | s.Empty(foo)
77 | })
78 | })
79 |
80 | s.Run("Range", func() {
81 | s.Run("iterates over all values in cache", func() {
82 | c.Set("foo", "bar", 0)
83 | c.Set("bar", "baz", 0)
84 |
85 | count := 0
86 | c.Range(func(key, value interface{}) bool {
87 | count++
88 | return true
89 | })
90 |
91 | s.Equal(2, count)
92 | })
93 |
94 | s.Run("iterates not over expired values", func() {
95 | c.Set("foo", "bar", interval/2)
96 | c.Set("bar", "baz", 0)
97 |
98 | time.Sleep(interval)
99 |
100 | count := 0
101 | c.Range(func(key, value interface{}) bool {
102 | count++
103 | return true
104 | })
105 |
106 | s.Equal(1, count)
107 | })
108 | })
109 | }
110 |
111 | func BenchmarkNew(b *testing.B) {
112 | b.ReportAllocs()
113 |
114 | b.RunParallel(func(pb *testing.PB) {
115 | for pb.Next() {
116 | NewCache(5 * time.Second).Close()
117 | }
118 | })
119 | }
120 |
121 | func BenchmarkGet(b *testing.B) {
122 | c := NewCache(5 * time.Second)
123 | defer c.Close()
124 |
125 | c.Set("foo", "bar", 0)
126 |
127 | b.ReportAllocs()
128 | b.ResetTimer()
129 |
130 | b.RunParallel(func(pb *testing.PB) {
131 | for pb.Next() {
132 | c.Get("foo")
133 | }
134 | })
135 | }
136 |
137 | func BenchmarkSet(b *testing.B) {
138 | c := NewCache(5 * time.Second)
139 | defer c.Close()
140 |
141 | b.ReportAllocs()
142 | b.ResetTimer()
143 |
144 | b.RunParallel(func(pb *testing.PB) {
145 | for pb.Next() {
146 | c.Set("foo", "bar", 0)
147 | }
148 | })
149 | }
150 |
151 | func BenchmarkDelete(b *testing.B) {
152 | c := NewCache(5 * time.Second)
153 | defer c.Close()
154 |
155 | b.ReportAllocs()
156 | b.ResetTimer()
157 |
158 | b.RunParallel(func(pb *testing.PB) {
159 | for pb.Next() {
160 | c.Delete("foo")
161 | }
162 | })
163 | }
164 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 |
7 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
8 | "github.com/sethvargo/go-password/password"
9 | "github.com/sirupsen/logrus"
10 | "github.com/spf13/afero"
11 | "gopkg.in/yaml.v3"
12 | )
13 |
14 | var log = logrus.WithField("package", "config")
15 |
16 | type Config struct {
17 | Listen string `yaml:"listen"`
18 | LogLevel string `yaml:"log_level"`
19 | LogFormat string `yaml:"log_format"`
20 | Secret string `yaml:"secret"`
21 | Database Database `yaml:"database"`
22 | Telegram Telegram `yaml:"telegram"`
23 | SignalGroup SignalGroup `yaml:"signal_group"`
24 | MetricsListen string `yaml:"metrics_listen"`
25 | Upload Upload `yaml:"upload"`
26 | FileBackend afero.Fs
27 | }
28 |
29 | type Database struct {
30 | Type string `yaml:"type"`
31 | DSN string `yaml:"dsn"`
32 | }
33 |
34 | type Telegram struct {
35 | Token string `yaml:"token"`
36 | User tgbotapi.User
37 | }
38 |
39 | type SignalGroup struct {
40 | ApiUrl string `yaml:"api_url"`
41 | Avatar string `yaml:"avatar"`
42 | Account string `yaml:"account"`
43 | }
44 |
45 | type Upload struct {
46 | Path string `yaml:"path"`
47 | URL string `yaml:"url"`
48 | }
49 |
50 | func defaultConfig() Config {
51 | secret, _ := password.Generate(64, 12, 12, false, true)
52 |
53 | return Config{
54 | Listen: ":8080",
55 | LogLevel: "debug",
56 | LogFormat: "json",
57 | Secret: secret,
58 | Database: Database{Type: "sqlite", DSN: "ticker.db"},
59 | MetricsListen: ":8181",
60 | Upload: Upload{
61 | Path: "uploads",
62 | URL: "http://localhost:8080",
63 | },
64 | FileBackend: afero.NewOsFs(),
65 | }
66 | }
67 |
68 | // Enabled returns true if the required token is not empty.
69 | func (t *Telegram) Enabled() bool {
70 | return t.Token != ""
71 | }
72 |
73 | // Enabled returns true if requried API URL and account are set.
74 | func (t *SignalGroup) Enabled() bool {
75 | return t.ApiUrl != "" && t.Account != ""
76 | }
77 |
78 | // LoadConfig loads config from file.
79 | func LoadConfig(path string) Config {
80 | c := defaultConfig()
81 | c.FileBackend = afero.NewOsFs()
82 |
83 | if path != "" {
84 | bytes, err := os.ReadFile(filepath.Clean(path))
85 | if err != nil {
86 | log.WithError(err).Error("Unable to load config")
87 | }
88 | if err := yaml.Unmarshal(bytes, &c); err != nil {
89 | log.WithError(err).Error("Unable to load config")
90 | }
91 | }
92 |
93 | if os.Getenv("TICKER_LISTEN") != "" {
94 | c.Listen = os.Getenv("TICKER_LISTEN")
95 | }
96 | if os.Getenv("TICKER_LOG_LEVEL") != "" {
97 | c.LogLevel = os.Getenv("TICKER_LOG_LEVEL")
98 | }
99 | if os.Getenv("TICKER_LOG_FORMAT") != "" {
100 | c.LogFormat = os.Getenv("TICKER_LOG_FORMAT")
101 | }
102 | if os.Getenv("TICKER_SECRET") != "" {
103 | c.Secret = os.Getenv("TICKER_SECRET")
104 | }
105 | if os.Getenv("TICKER_DATABASE_TYPE") != "" {
106 | c.Database.Type = os.Getenv("TICKER_DATABASE_TYPE")
107 | }
108 | if os.Getenv("TICKER_DATABASE_DSN") != "" {
109 | c.Database.DSN = os.Getenv("TICKER_DATABASE_DSN")
110 | }
111 | if os.Getenv("TICKER_METRICS_LISTEN") != "" {
112 | c.MetricsListen = os.Getenv("TICKER_METRICS_LISTEN")
113 | }
114 | if os.Getenv("TICKER_UPLOAD_PATH") != "" {
115 | c.Upload.Path = os.Getenv("TICKER_UPLOAD_PATH")
116 | }
117 | if os.Getenv("TICKER_UPLOAD_URL") != "" {
118 | c.Upload.URL = os.Getenv("TICKER_UPLOAD_URL")
119 | }
120 | if os.Getenv("TICKER_TELEGRAM_TOKEN") != "" {
121 | c.Telegram.Token = os.Getenv("TICKER_TELEGRAM_TOKEN")
122 | }
123 | if os.Getenv("TICKER_SIGNAL_GROUP_API_URL") != "" {
124 | c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_API_URL")
125 | }
126 | if os.Getenv("TICKER_SIGNAL_GROUP_ACCOUNT") != "" {
127 | c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_ACCOUNT")
128 | }
129 | if os.Getenv("TICKER_SIGNAL_GROUP_AVATAR") != "" {
130 | c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_AVATAR")
131 | }
132 |
133 | return c
134 | }
135 |
--------------------------------------------------------------------------------
/internal/config/config_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "io"
5 | "os"
6 | "path/filepath"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/suite"
10 | )
11 |
12 | type ConfigTestSuite struct {
13 | envs map[string]string
14 | suite.Suite
15 | }
16 |
17 | func (s *ConfigTestSuite) SetupTest() {
18 | log.Logger.SetOutput(io.Discard)
19 |
20 | s.envs = map[string]string{
21 | "TICKER_LISTEN": ":7070",
22 | "TICKER_LOG_LEVEL": "trace",
23 | "TICKER_LOG_FORMAT": "text",
24 | "TICKER_SECRET": "secret",
25 | "TICKER_DATABASE_TYPE": "mysql",
26 | "TICKER_DATABASE_DSN": "user:password@tcp(localhost:3306)/ticker?charset=utf8mb4&parseTime=True&loc=Local",
27 | "TICKER_METRICS_LISTEN": ":9191",
28 | "TICKER_UPLOAD_PATH": "/data/uploads",
29 | "TICKER_UPLOAD_URL": "https://example.com",
30 | "TICKER_TELEGRAM_TOKEN": "token",
31 | }
32 | }
33 |
34 | func (s *ConfigTestSuite) TestConfig() {
35 | s.Run("LoadConfig", func() {
36 | s.Run("when path is empty", func() {
37 | s.Run("loads config with default values", func() {
38 | c := LoadConfig("")
39 | s.Equal(":8080", c.Listen)
40 | s.Equal("debug", c.LogLevel)
41 | s.Equal("json", c.LogFormat)
42 | s.NotEmpty(c.Secret)
43 | s.Equal("sqlite", c.Database.Type)
44 | s.Equal("ticker.db", c.Database.DSN)
45 | s.Equal(":8181", c.MetricsListen)
46 | s.Equal("uploads", c.Upload.Path)
47 | s.Equal("http://localhost:8080", c.Upload.URL)
48 | s.Empty(c.Telegram.Token)
49 | s.False(c.Telegram.Enabled())
50 | s.Empty(c.SignalGroup.ApiUrl)
51 | s.Empty(c.SignalGroup.Account)
52 | s.False(c.SignalGroup.Enabled())
53 | })
54 |
55 | s.Run("loads config from env", func() {
56 | for key, value := range s.envs {
57 | err := os.Setenv(key, value)
58 | s.NoError(err)
59 | }
60 |
61 | c := LoadConfig("")
62 | s.Equal(s.envs["TICKER_LISTEN"], c.Listen)
63 | s.Equal(s.envs["TICKER_LOG_LEVEL"], c.LogLevel)
64 | s.Equal(s.envs["TICKER_LOG_FORMAT"], c.LogFormat)
65 | s.Equal(s.envs["TICKER_SECRET"], c.Secret)
66 | s.Equal(s.envs["TICKER_DATABASE_TYPE"], c.Database.Type)
67 | s.Equal(s.envs["TICKER_DATABASE_DSN"], c.Database.DSN)
68 | s.Equal(s.envs["TICKER_METRICS_LISTEN"], c.MetricsListen)
69 | s.Equal(s.envs["TICKER_UPLOAD_PATH"], c.Upload.Path)
70 | s.Equal(s.envs["TICKER_UPLOAD_URL"], c.Upload.URL)
71 | s.Equal(s.envs["TICKER_TELEGRAM_TOKEN"], c.Telegram.Token)
72 | s.Equal(s.envs["TICKER_SIGNAL_GROUP_API_URL"], c.SignalGroup.ApiUrl)
73 | s.Equal(s.envs["TICKER_SIGNAL_GROUP_ACCOUNT"], c.SignalGroup.Account)
74 | s.True(c.Telegram.Enabled())
75 |
76 | for key := range s.envs {
77 | os.Unsetenv(key)
78 | }
79 | })
80 | })
81 |
82 | s.Run("when path is not empty", func() {
83 | s.Run("when path is absolute", func() {
84 | s.Run("loads config from file", func() {
85 | path, err := filepath.Abs("../../testdata/config_valid.yml")
86 | s.NoError(err)
87 | c := LoadConfig(path)
88 | s.Equal("127.0.0.1:8888", c.Listen)
89 | })
90 | })
91 |
92 | s.Run("when path is relative", func() {
93 | s.Run("loads config from file", func() {
94 | c := LoadConfig("../../testdata/config_valid.yml")
95 | s.Equal("127.0.0.1:8888", c.Listen)
96 | })
97 | })
98 |
99 | s.Run("when file does not exist", func() {
100 | s.Run("loads config with default values", func() {
101 | c := LoadConfig("config_notfound.yml")
102 | s.Equal(":8080", c.Listen)
103 | })
104 | })
105 |
106 | s.Run("when file is invalid", func() {
107 | s.Run("loads config with default values", func() {
108 | c := LoadConfig("../../testdata/config_invalid.txt")
109 | s.Equal(":8080", c.Listen)
110 | })
111 | })
112 | })
113 | })
114 | }
115 |
116 | func TestConfig(t *testing.T) {
117 | suite.Run(t, new(ConfigTestSuite))
118 | }
119 |
--------------------------------------------------------------------------------
/internal/logger/gorm.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/sirupsen/logrus"
8 | "gorm.io/gorm/logger"
9 | )
10 |
11 | type GormLogger struct {
12 | Log *logrus.Logger
13 | }
14 |
15 | func NewGormLogger(log *logrus.Logger) *GormLogger {
16 | return &GormLogger{Log: log}
17 | }
18 |
19 | func (l *GormLogger) LogMode(level logger.LogLevel) logger.Interface {
20 | return l
21 | }
22 |
23 | func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) {
24 | l.Log.WithContext(ctx).Infof(msg, data...)
25 | }
26 |
27 | func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
28 | l.Log.WithContext(ctx).Warnf(msg, data...)
29 | }
30 |
31 | func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) {
32 | l.Log.WithContext(ctx).Errorf(msg, data...)
33 | }
34 |
35 | func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
36 | if l.Log.IsLevelEnabled(logrus.TraceLevel) {
37 | elapsed := time.Since(begin)
38 | sql, rows := fc()
39 | fields := logrus.Fields{
40 | "sql": sql,
41 | "rows": rows,
42 | "elapsed_ms": float64(elapsed.Nanoseconds()) / 1e6,
43 | }
44 | if err != nil {
45 | l.Log.WithContext(ctx).WithFields(fields).WithError(err).Trace("gorm: error")
46 | } else {
47 | l.Log.WithContext(ctx).WithFields(fields).Trace("gorm: trace")
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/internal/logger/logrus.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import "github.com/sirupsen/logrus"
4 |
5 | func NewLogrus(level, format string) *logrus.Logger {
6 | logger := logrus.New()
7 | lvl, err := logrus.ParseLevel(level)
8 | if err != nil {
9 | lvl = logrus.InfoLevel
10 | }
11 | logger.SetLevel(lvl)
12 |
13 | switch format {
14 | case "json":
15 | logger.SetFormatter(&logrus.JSONFormatter{})
16 | default:
17 | logger.SetFormatter(&logrus.TextFormatter{FullTimestamp: true})
18 | }
19 |
20 | return logger
21 | }
22 |
--------------------------------------------------------------------------------
/internal/storage/message.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "strings"
7 | "time"
8 |
9 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
10 | geojson "github.com/paulmach/go.geojson"
11 | )
12 |
13 | type Message struct {
14 | ID int `gorm:"primaryKey"`
15 | CreatedAt time.Time
16 | UpdatedAt time.Time
17 | TickerID int `gorm:"index"`
18 | Text string
19 | Attachments []Attachment
20 | GeoInformation geojson.FeatureCollection `gorm:"serializer:json"`
21 | Telegram TelegramMeta `gorm:"serializer:json"`
22 | Mastodon MastodonMeta `gorm:"serializer:json"`
23 | Bluesky BlueskyMeta `gorm:"serializer:json"`
24 | SignalGroup SignalGroupMeta `gorm:"serializer:json"`
25 | }
26 |
27 | func NewMessage() Message {
28 | return Message{}
29 | }
30 |
31 | func (m *Message) AsMap() map[string]interface{} {
32 | geoInformation, _ := m.GeoInformation.MarshalJSON()
33 | telegram, _ := json.Marshal(m.Telegram)
34 | mastodon, _ := json.Marshal(m.Mastodon)
35 | bluesky, _ := json.Marshal(m.Bluesky)
36 | signalGroup, _ := json.Marshal(m.SignalGroup)
37 |
38 | return map[string]interface{}{
39 | "id": m.ID,
40 | "created_at": m.CreatedAt,
41 | "updated_at": m.UpdatedAt,
42 | "ticker_id": m.TickerID,
43 | "text": m.Text,
44 | "geo_information": string(geoInformation),
45 | "telegram": telegram,
46 | "mastodon": mastodon,
47 | "bluesky": bluesky,
48 | "signal_group": signalGroup,
49 | }
50 | }
51 |
52 | type TelegramMeta struct {
53 | Messages []tgbotapi.Message
54 | }
55 |
56 | type MastodonMeta struct {
57 | ID string
58 | URI string
59 | URL string
60 | }
61 |
62 | type BlueskyMeta struct {
63 | Handle string
64 | Uri string
65 | Cid string
66 | }
67 |
68 | type SignalGroupMeta struct {
69 | Timestamp int
70 | }
71 |
72 | type Attachment struct {
73 | ID int `gorm:"primaryKey"`
74 | CreatedAt time.Time
75 | UpdatedAt time.Time
76 | MessageID int `gorm:"index"`
77 | UUID string
78 | Extension string
79 | ContentType string
80 | }
81 |
82 | func (m *Message) AddAttachment(upload Upload) {
83 | attachment := Attachment{
84 | UUID: upload.UUID,
85 | Extension: upload.Extension,
86 | ContentType: upload.ContentType,
87 | }
88 |
89 | m.Attachments = append(m.Attachments, attachment)
90 | }
91 |
92 | func (m *Message) AddAttachments(uploads []Upload) {
93 | for _, upload := range uploads {
94 | m.AddAttachment(upload)
95 | }
96 | }
97 |
98 | func (m *Message) TelegramURL() string {
99 | if len(m.Telegram.Messages) == 0 {
100 | return ""
101 | }
102 |
103 | message := m.Telegram.Messages[0]
104 | return fmt.Sprintf("https://t.me/%s/%d", message.Chat.UserName, message.MessageID)
105 | }
106 |
107 | func (m *Message) MastodonURL() string {
108 | if m.Mastodon.ID == "" {
109 | return ""
110 | }
111 |
112 | return m.Mastodon.URL
113 | }
114 |
115 | func (m *Message) BlueskyURL() string {
116 | parts := strings.Split(m.Bluesky.Uri, "/")
117 | if len(parts) < 3 {
118 | return ""
119 | }
120 |
121 | return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", m.Bluesky.Handle, parts[len(parts)-1])
122 | }
123 |
--------------------------------------------------------------------------------
/internal/storage/message_test.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "testing"
5 |
6 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestAddAttachments(t *testing.T) {
11 | upload := NewUpload("image.jpg", "image/jped", 1)
12 | message := NewMessage()
13 | message.AddAttachments([]Upload{upload})
14 |
15 | assert.Equal(t, 1, len(message.Attachments))
16 | }
17 |
18 | func TestTelegramURL(t *testing.T) {
19 | message := NewMessage()
20 |
21 | assert.Empty(t, message.TelegramURL())
22 |
23 | message.Telegram = TelegramMeta{
24 | Messages: []tgbotapi.Message{
25 | {
26 | MessageID: 1,
27 | Chat: &tgbotapi.Chat{
28 | UserName: "systemli",
29 | }},
30 | },
31 | }
32 |
33 | assert.Equal(t, "https://t.me/systemli/1", message.TelegramURL())
34 |
35 | }
36 |
37 | func TestMastodonURL(t *testing.T) {
38 | message := NewMessage()
39 |
40 | assert.Empty(t, message.MastodonURL())
41 |
42 | url := "https://mastodon.social/web/@systemli/1"
43 | message.Mastodon = MastodonMeta{
44 | ID: "1",
45 | URL: url,
46 | }
47 |
48 | assert.Equal(t, url, message.MastodonURL())
49 | }
50 |
51 | func TestBlueskyURL(t *testing.T) {
52 | message := NewMessage()
53 |
54 | assert.Empty(t, message.BlueskyURL())
55 |
56 | message.Bluesky = BlueskyMeta{
57 | Uri: "",
58 | }
59 |
60 | assert.Empty(t, message.BlueskyURL())
61 |
62 | url := "https://bsky.app/profile/systemli.bsky.social/post/3kr7p3jxkpw2n"
63 | message.Bluesky = BlueskyMeta{
64 | Uri: "at://did:plc:izpk4tc54wu6b3yufcdixqje/app.bsky.feed.post/3kr7p3jxkpw2n",
65 | Handle: "systemli.bsky.social",
66 | }
67 |
68 | assert.Equal(t, url, message.BlueskyURL())
69 | }
70 |
--------------------------------------------------------------------------------
/internal/storage/migrations.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "fmt"
5 | "gorm.io/gorm"
6 | )
7 |
8 | // MigrateDB migrates the database
9 | func MigrateDB(db *gorm.DB) error {
10 | if err := db.AutoMigrate(
11 | &Ticker{},
12 | &TickerMastodon{},
13 | &TickerTelegram{},
14 | &TickerBluesky{},
15 | &TickerSignalGroup{},
16 | &TickerWebsite{},
17 | &User{},
18 | &Setting{},
19 | &Upload{},
20 | &Message{},
21 | &Attachment{},
22 | ); err != nil {
23 | return err
24 | }
25 |
26 | // Migrate all Ticker.Origin to TickerWebsite
27 | var tickers []Ticker
28 | if err := db.Find(&tickers).Error; err != nil {
29 | return err
30 | }
31 |
32 | for _, ticker := range tickers {
33 | if ticker.Domain != "" {
34 | if err := db.Create(&TickerWebsite{
35 | TickerID: ticker.ID,
36 | Origin: migrateDomain(ticker.Domain),
37 | }).Error; err != nil {
38 | return err
39 | }
40 |
41 | if err := db.Model(&ticker).Update("Domain", "").Error; err != nil {
42 | return err
43 | }
44 | }
45 | }
46 |
47 | return nil
48 | }
49 |
50 | func migrateDomain(old string) string {
51 | if old == "localhost" {
52 | return "http://localhost"
53 | }
54 |
55 | return fmt.Sprintf("https://%s", old)
56 | }
57 |
--------------------------------------------------------------------------------
/internal/storage/migrations_test.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "github.com/stretchr/testify/suite"
5 | "gorm.io/driver/sqlite"
6 | "gorm.io/gorm"
7 | "testing"
8 | )
9 |
10 | type MigrationTestSuite struct {
11 | db *gorm.DB
12 | suite.Suite
13 | }
14 |
15 | func (s *MigrationTestSuite) SetupSuite() {
16 | db, err := gorm.Open(sqlite.Open("file:testdatabase?mode=memory&cache=shared"), &gorm.Config{})
17 | s.NoError(err)
18 |
19 | err = db.AutoMigrate(
20 | &Ticker{},
21 | &TickerTelegram{},
22 | &TickerMastodon{},
23 | &TickerBluesky{},
24 | &TickerSignalGroup{},
25 | &TickerWebsite{},
26 | &User{},
27 | &Message{},
28 | &Upload{},
29 | &Attachment{},
30 | &Setting{},
31 | )
32 | s.NoError(err)
33 |
34 | s.db = db
35 | }
36 |
37 | func (s *MigrationTestSuite) TestMigrateDB() {
38 | s.Run("without existing data", func() {
39 | err := MigrateDB(s.db)
40 | s.NoError(err)
41 | })
42 |
43 | s.Run("with existing data", func() {
44 | ticker := Ticker{Domain: "example.org"}
45 | err := s.db.Create(&ticker).Error
46 | s.NoError(err)
47 |
48 | err = MigrateDB(s.db)
49 | s.NoError(err)
50 |
51 | var tickerWebsite TickerWebsite
52 | err = s.db.First(&tickerWebsite).Error
53 | s.NoError(err)
54 | s.Equal(tickerWebsite.TickerID, ticker.ID)
55 | s.Equal(tickerWebsite.Origin, "https://example.org")
56 | })
57 | }
58 |
59 | func (s *MigrationTestSuite) TestMigrateDomain() {
60 | s.Run("when domain is localhost", func() {
61 | s.Equal("http://localhost", migrateDomain("localhost"))
62 | })
63 |
64 | s.Run("when domain is not localhost", func() {
65 | s.Equal("https://example.org", migrateDomain("example.org"))
66 | })
67 | }
68 |
69 | func TestMigrationTestSuite(t *testing.T) {
70 | suite.Run(t, new(MigrationTestSuite))
71 | }
72 |
--------------------------------------------------------------------------------
/internal/storage/setting.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import "time"
4 |
5 | const (
6 | SettingInactiveName = `inactive_settings`
7 | SettingRefreshInterval = `refresh_interval`
8 | SettingInactiveHeadline = `The ticker is currently inactive.`
9 | SettingInactiveSubHeadline = `Please contact us if you want to use it.`
10 | SettingInactiveDescription = `...`
11 | SettingInactiveAuthor = `systemli.org Ticker Team`
12 | SettingInactiveEmail = `admin@systemli.org`
13 | SettingInactiveHomepage = `https://www.systemli.org/`
14 | SettingInactiveTwitter = `systemli`
15 | SettingDefaultRefreshInterval int = 10000
16 | )
17 |
18 | type Setting struct {
19 | ID int `gorm:"primaryKey"`
20 | CreatedAt time.Time
21 | UpdatedAt time.Time
22 | Name string `gorm:"unique"`
23 | Value string `gorm:"type:json"`
24 | }
25 |
26 | type InactiveSettings struct {
27 | Headline string `json:"headline" binding:"required"`
28 | SubHeadline string `json:"subHeadline" binding:"required"`
29 | Description string `json:"description" binding:"required"`
30 | Author string `json:"author" binding:"required"`
31 | Email string `json:"email" binding:"required"`
32 | Homepage string `json:"homepage" binding:"required"`
33 | Twitter string `json:"twitter" binding:"required"`
34 | }
35 |
36 | type RefreshIntervalSettings struct {
37 | RefreshInterval int `json:"refreshInterval" binding:"required"`
38 | }
39 |
40 | func DefaultRefreshIntervalSettings() RefreshIntervalSettings {
41 | return RefreshIntervalSettings{RefreshInterval: SettingDefaultRefreshInterval}
42 | }
43 |
44 | func DefaultInactiveSettings() InactiveSettings {
45 | return InactiveSettings{
46 | Headline: SettingInactiveHeadline,
47 | SubHeadline: SettingInactiveSubHeadline,
48 | Description: SettingInactiveDescription,
49 | Author: SettingInactiveAuthor,
50 | Email: SettingInactiveEmail,
51 | Homepage: SettingInactiveHomepage,
52 | Twitter: SettingInactiveTwitter,
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/internal/storage/storage.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "github.com/sirupsen/logrus"
5 | "github.com/systemli/ticker/internal/api/pagination"
6 | "gorm.io/gorm"
7 | )
8 |
9 | var log = logrus.WithField("package", "storage")
10 |
11 | type Storage interface {
12 | FindUsers(filter UserFilter, opts ...func(*gorm.DB) *gorm.DB) ([]User, error)
13 | FindUserByID(id int, opts ...func(*gorm.DB) *gorm.DB) (User, error)
14 | FindUsersByIDs(ids []int, opts ...func(*gorm.DB) *gorm.DB) ([]User, error)
15 | FindUserByEmail(email string, opts ...func(*gorm.DB) *gorm.DB) (User, error)
16 | FindUsersByTicker(ticker Ticker, opts ...func(*gorm.DB) *gorm.DB) ([]User, error)
17 | SaveUser(user *User) error
18 | DeleteUser(user User) error
19 | DeleteTickerUsers(ticker *Ticker) error
20 | DeleteTickerUser(ticker *Ticker, user *User) error
21 | AddTickerUser(ticker *Ticker, user *User) error
22 | FindTickersByUser(user User, filter TickerFilter, opts ...func(*gorm.DB) *gorm.DB) ([]Ticker, error)
23 | FindTickerByUserAndID(user User, id int, opts ...func(*gorm.DB) *gorm.DB) (Ticker, error)
24 | FindTickersByIDs(ids []int, opts ...func(*gorm.DB) *gorm.DB) ([]Ticker, error)
25 | FindTickerByOrigin(origin string, opts ...func(*gorm.DB) *gorm.DB) (Ticker, error)
26 | FindTickerByID(id int, opts ...func(*gorm.DB) *gorm.DB) (Ticker, error)
27 | SaveTicker(ticker *Ticker) error
28 | DeleteTicker(ticker *Ticker) error
29 | SaveTickerWebsites(ticker *Ticker, websites []TickerWebsite) error
30 | DeleteTickerWebsites(ticker *Ticker) error
31 | ResetTicker(ticker *Ticker) error
32 | DeleteIntegrations(ticker *Ticker) error
33 | DeleteMastodon(ticker *Ticker) error
34 | DeleteTelegram(ticker *Ticker) error
35 | DeleteBluesky(ticker *Ticker) error
36 | DeleteSignalGroup(ticker *Ticker) error
37 | SaveUpload(upload *Upload) error
38 | FindUploadByUUID(uuid string) (Upload, error)
39 | FindUploadsByIDs(ids []int) ([]Upload, error)
40 | DeleteUpload(upload Upload) error
41 | DeleteUploads(uploads []Upload)
42 | DeleteUploadsByTicker(ticker *Ticker) error
43 | FindMessage(tickerID, messageID int, opts ...func(*gorm.DB) *gorm.DB) (Message, error)
44 | FindMessagesByTicker(ticker Ticker, opts ...func(*gorm.DB) *gorm.DB) ([]Message, error)
45 | FindMessagesByTickerAndPagination(ticker Ticker, pagination pagination.Pagination, opts ...func(*gorm.DB) *gorm.DB) ([]Message, error)
46 | SaveMessage(message *Message) error
47 | DeleteMessage(message Message) error
48 | DeleteMessages(ticker *Ticker) error
49 | GetInactiveSettings() InactiveSettings
50 | GetRefreshIntervalSettings() RefreshIntervalSettings
51 | SaveInactiveSettings(inactiveSettings InactiveSettings) error
52 | SaveRefreshIntervalSettings(refreshInterval RefreshIntervalSettings) error
53 | UploadPath() string
54 | }
55 |
--------------------------------------------------------------------------------
/internal/storage/ticker.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 | "time"
7 | )
8 |
9 | type Ticker struct {
10 | ID int `gorm:"primaryKey"`
11 | CreatedAt time.Time
12 | UpdatedAt time.Time
13 | Domain string
14 | Title string
15 | Description string
16 | Active bool
17 | Information TickerInformation `gorm:"embedded"`
18 | Location TickerLocation `gorm:"embedded"`
19 | Telegram TickerTelegram
20 | Mastodon TickerMastodon
21 | Bluesky TickerBluesky
22 | SignalGroup TickerSignalGroup
23 | Websites []TickerWebsite `gorm:"foreignKey:TickerID;"`
24 | Users []User `gorm:"many2many:ticker_users;"`
25 | }
26 |
27 | func NewTicker() Ticker {
28 | return Ticker{}
29 | }
30 |
31 | func (t *Ticker) AsMap() map[string]interface{} {
32 | return map[string]interface{}{
33 | "id": t.ID,
34 | "created_at": t.CreatedAt,
35 | "updated_at": t.UpdatedAt,
36 | "domain": t.Domain,
37 | "title": t.Title,
38 | "description": t.Description,
39 | "active": t.Active,
40 | "author": t.Information.Author,
41 | "url": t.Information.URL,
42 | "email": t.Information.Email,
43 | "twitter": t.Information.Twitter,
44 | "facebook": t.Information.Facebook,
45 | "threads": t.Information.Threads,
46 | "instagram": t.Information.Instagram,
47 | "telegram": t.Information.Telegram,
48 | "bluesky": t.Information.Bluesky,
49 | "mastodon": t.Information.Mastodon,
50 | "lat": t.Location.Lat,
51 | "lon": t.Location.Lon,
52 | }
53 | }
54 |
55 | type TickerInformation struct {
56 | Author string
57 | URL string
58 | Email string
59 | Twitter string
60 | Facebook string
61 | Instagram string
62 | Threads string
63 | Telegram string
64 | Mastodon string
65 | Bluesky string
66 | }
67 |
68 | type TickerWebsite struct {
69 | ID int `gorm:"primaryKey"`
70 | CreatedAt time.Time
71 | UpdatedAt time.Time
72 | TickerID int `gorm:"index;not null"`
73 | Origin string `gorm:"unique;not null"`
74 | }
75 |
76 | type TickerTelegram struct {
77 | ID int `gorm:"primaryKey"`
78 | CreatedAt time.Time
79 | UpdatedAt time.Time
80 | TickerID int `gorm:"index"`
81 | Active bool
82 | ChannelName string
83 | }
84 |
85 | func (tg *TickerTelegram) Connected() bool {
86 | return tg.ChannelName != ""
87 | }
88 |
89 | type TickerMastodon struct {
90 | ID int `gorm:"primaryKey"`
91 | CreatedAt time.Time
92 | UpdatedAt time.Time
93 | TickerID int `gorm:"index"`
94 | Active bool
95 | Server string
96 | Token string
97 | Secret string
98 | AccessToken string
99 | User MastodonUser `gorm:"embedded"`
100 | }
101 |
102 | type MastodonUser struct {
103 | Username string
104 | DisplayName string
105 | Avatar string
106 | }
107 |
108 | func (m *TickerMastodon) Connected() bool {
109 | return m.Token != "" && m.Secret != "" && m.AccessToken != ""
110 | }
111 |
112 | type TickerBluesky struct {
113 | ID int `gorm:"primaryKey"`
114 | CreatedAt time.Time
115 | UpdatedAt time.Time
116 | TickerID int `gorm:"index"`
117 | Active bool
118 | Handle string
119 | // AppKey is the application password from Bluesky
120 | // Future consideration: persist the access token, refresh token instead of app key
121 | AppKey string
122 | }
123 |
124 | func (b *TickerBluesky) Connected() bool {
125 | return b.Handle != "" && b.AppKey != ""
126 | }
127 |
128 | type TickerSignalGroup struct {
129 | ID int `gorm:"primaryKey"`
130 | CreatedAt time.Time
131 | UpdatedAt time.Time
132 | TickerID int `gorm:"index"`
133 | Active bool
134 | GroupID string
135 | GroupInviteLink string
136 | }
137 |
138 | func (s *TickerSignalGroup) Connected() bool {
139 | return s.GroupID != ""
140 | }
141 |
142 | type TickerLocation struct {
143 | Lat float64
144 | Lon float64
145 | }
146 |
147 | type TickerFilter struct {
148 | Origin *string
149 | Title *string
150 | Active *bool
151 | OrderBy string
152 | Sort string
153 | }
154 |
155 | func NewTickerFilter(req *http.Request) TickerFilter {
156 | filter := TickerFilter{
157 | OrderBy: "id",
158 | Sort: "asc",
159 | }
160 |
161 | if req == nil {
162 | return filter
163 | }
164 |
165 | if req.URL.Query().Get("order_by") != "" {
166 | opts := []string{"id", "created_at", "updated_at", "origin", "title", "active"}
167 | for _, opt := range opts {
168 | if req.URL.Query().Get("order_by") == opt {
169 | filter.OrderBy = req.URL.Query().Get("order_by")
170 | break
171 | }
172 | }
173 | }
174 |
175 | if req.URL.Query().Get("sort") == "asc" {
176 | filter.Sort = "asc"
177 | } else {
178 | filter.Sort = "desc"
179 | }
180 |
181 | origin := req.URL.Query().Get("origin")
182 | if origin != "" {
183 | filter.Origin = &origin
184 | }
185 |
186 | title := req.URL.Query().Get("title")
187 | if title != "" {
188 | filter.Title = &title
189 | }
190 |
191 | active := req.URL.Query().Get("active")
192 | if active != "" {
193 | activeBool, _ := strconv.ParseBool(active)
194 | filter.Active = &activeBool
195 | }
196 |
197 | return filter
198 | }
199 |
--------------------------------------------------------------------------------
/internal/storage/ticker_test.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "net/http/httptest"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | var ticker = NewTicker()
11 |
12 | func TestTickerMastodonConnect(t *testing.T) {
13 | assert.False(t, ticker.Mastodon.Connected())
14 | }
15 |
16 | func TestTickerTelegramConnected(t *testing.T) {
17 | assert.False(t, ticker.Telegram.Connected())
18 |
19 | ticker.Telegram.ChannelName = "ChannelName"
20 |
21 | assert.True(t, ticker.Telegram.Connected())
22 | }
23 |
24 | func TestTickerBlueskyConnected(t *testing.T) {
25 | assert.False(t, ticker.Bluesky.Connected())
26 |
27 | ticker.Bluesky.Handle = "Handle"
28 | ticker.Bluesky.AppKey = "AppKey"
29 |
30 | assert.True(t, ticker.Bluesky.Connected())
31 | }
32 |
33 | func TestTickerSignalGroupConnect(t *testing.T) {
34 | assert.False(t, ticker.SignalGroup.Connected())
35 |
36 | ticker.SignalGroup.GroupID = "GroupID"
37 |
38 | assert.True(t, ticker.SignalGroup.Connected())
39 | }
40 |
41 | func TestNewTickerFilter(t *testing.T) {
42 | filter := NewTickerFilter(nil)
43 | assert.Nil(t, filter.Active)
44 | assert.Nil(t, filter.Origin)
45 | assert.Nil(t, filter.Title)
46 |
47 | req := httptest.NewRequest("GET", "/", nil)
48 | filter = NewTickerFilter(req)
49 | assert.Nil(t, filter.Active)
50 | assert.Nil(t, filter.Origin)
51 | assert.Nil(t, filter.Title)
52 |
53 | req = httptest.NewRequest("GET", "/?active=true&origin=example.org&title=Title", nil)
54 | filter = NewTickerFilter(req)
55 | assert.True(t, *filter.Active)
56 | assert.Equal(t, "example.org", *filter.Origin)
57 | assert.Equal(t, "Title", *filter.Title)
58 |
59 | req = httptest.NewRequest("GET", "/?order_by=created_at&sort=asc", nil)
60 | filter = NewTickerFilter(req)
61 | assert.Equal(t, "created_at", filter.OrderBy)
62 | assert.Equal(t, "asc", filter.Sort)
63 | }
64 |
--------------------------------------------------------------------------------
/internal/storage/upload.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "fmt"
5 | "path/filepath"
6 | "time"
7 |
8 | uuid2 "github.com/google/uuid"
9 | )
10 |
11 | type Upload struct {
12 | ID int `gorm:"primaryKey"`
13 | CreatedAt time.Time
14 | UpdatedAt time.Time
15 | UUID string `gorm:"index;unique"`
16 | TickerID int `gorm:"index"`
17 | Path string
18 | Extension string
19 | ContentType string
20 | }
21 |
22 | func NewUpload(filename, contentType string, tickerID int) Upload {
23 | now := time.Now()
24 | uuid := uuid2.New()
25 | ext := filepath.Ext(filename)[1:]
26 | // First version we use a date based directory structure
27 | path := fmt.Sprintf("%d/%d", now.Year(), now.Month())
28 |
29 | return Upload{
30 | Path: path,
31 | UUID: uuid.String(),
32 | TickerID: tickerID,
33 | Extension: ext,
34 | ContentType: contentType,
35 | }
36 | }
37 |
38 | func (u *Upload) FileName() string {
39 | return fmt.Sprintf("%s.%s", u.UUID, u.Extension)
40 | }
41 |
42 | func (u *Upload) FullPath(uploadPath string) string {
43 | return fmt.Sprintf("%s/%s/%s", uploadPath, u.Path, u.FileName())
44 | }
45 |
46 | func (u *Upload) URL(uploadPath string) string {
47 | return MediaURL(u.FileName(), uploadPath)
48 | }
49 |
50 | func MediaURL(name string, uploadPath string) string {
51 | return fmt.Sprintf("%s/media/%s", uploadPath, name)
52 | }
53 |
--------------------------------------------------------------------------------
/internal/storage/upload_test.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | var upload = NewUpload("image.jpg", "image/jpeg", 1)
10 |
11 | func TestUploadFilename(t *testing.T) {
12 | fileName := upload.FileName()
13 |
14 | assert.Contains(t, fileName, ".jpg")
15 | }
16 |
17 | func TestUploadFullPath(t *testing.T) {
18 | fullPath := upload.FullPath("/uploads")
19 | assert.Contains(t, fullPath, "/uploads")
20 | }
21 |
22 | func TestUploadURL(t *testing.T) {
23 | url := upload.URL("/uploads")
24 | assert.Contains(t, url, "/uploads/media")
25 | }
26 |
--------------------------------------------------------------------------------
/internal/storage/user.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "strconv"
7 | "time"
8 |
9 | "golang.org/x/crypto/bcrypt"
10 | "gorm.io/gorm"
11 | )
12 |
13 | type User struct {
14 | ID int `gorm:"primaryKey"`
15 | CreatedAt time.Time
16 | UpdatedAt time.Time
17 | LastLogin time.Time
18 | Email string `gorm:"uniqueIndex;not null"`
19 | EncryptedPassword string `gorm:"not null"`
20 | IsSuperAdmin bool
21 | Tickers []Ticker `gorm:"many2many:ticker_users;"`
22 | }
23 |
24 | func NewUser(email, password string) (User, error) {
25 | user := User{
26 | IsSuperAdmin: false,
27 | Email: email,
28 | EncryptedPassword: "",
29 | }
30 |
31 | pw, err := hashPassword(password)
32 | if err != nil {
33 | return user, err
34 | }
35 |
36 | user.EncryptedPassword = pw
37 |
38 | return user, nil
39 | }
40 |
41 | // BeforeSave is a gorm hook that is called before saving a user
42 | // It checks if the email and encrypted password are set
43 | func (u *User) BeforeSave(tx *gorm.DB) error {
44 | if u.Email == "" {
45 | return errors.New("email is required")
46 | }
47 |
48 | if u.EncryptedPassword == "" {
49 | return errors.New("encrypted password is required")
50 | }
51 |
52 | return nil
53 | }
54 |
55 | func (u *User) Authenticate(password string) bool {
56 | err := bcrypt.CompareHashAndPassword([]byte(u.EncryptedPassword), []byte(password))
57 | return err == nil
58 | }
59 |
60 | func (u *User) UpdatePassword(password string) {
61 | pw, err := hashPassword(password)
62 | if err != nil {
63 | return
64 | }
65 |
66 | u.EncryptedPassword = pw
67 | }
68 |
69 | func (u *User) AsMap() map[string]interface{} {
70 | return map[string]interface{}{
71 | "id": u.ID,
72 | "created_at": u.CreatedAt,
73 | "updated_at": u.UpdatedAt,
74 | "last_login": u.LastLogin,
75 | "email": u.Email,
76 | "encrypted_password": u.EncryptedPassword,
77 | "is_super_admin": u.IsSuperAdmin,
78 | }
79 | }
80 |
81 | func hashPassword(password string) (string, error) {
82 | pw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
83 | if err != nil {
84 | return "", err
85 | }
86 | return string(pw), nil
87 | }
88 |
89 | type UserFilter struct {
90 | Email *string
91 | IsSuperAdmin *bool
92 | OrderBy string
93 | Sort string
94 | }
95 |
96 | func NewUserFilter(req *http.Request) UserFilter {
97 | filter := UserFilter{
98 | OrderBy: "id",
99 | Sort: "desc",
100 | }
101 |
102 | if req == nil {
103 | return filter
104 | }
105 |
106 | if req.URL.Query().Get("order_by") != "" {
107 | opts := []string{"id", "created_at", "last_login", "email", "is_super_admin"}
108 | for _, opt := range opts {
109 | if req.URL.Query().Get("order_by") == opt {
110 | filter.OrderBy = req.URL.Query().Get("order_by")
111 | break
112 | }
113 | }
114 | }
115 |
116 | if req.URL.Query().Get("sort") == "asc" {
117 | filter.Sort = "asc"
118 | } else {
119 | filter.Sort = "desc"
120 | }
121 |
122 | email := req.URL.Query().Get("email")
123 | isSuperAdmin := req.URL.Query().Get("is_super_admin")
124 | if email != "" {
125 | filter.Email = &email
126 | }
127 |
128 | if isSuperAdmin != "" {
129 | isSuperAdminBool, _ := strconv.ParseBool(isSuperAdmin)
130 | filter.IsSuperAdmin = &isSuperAdminBool
131 | }
132 |
133 | return filter
134 | }
135 |
--------------------------------------------------------------------------------
/internal/storage/user_test.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "net/http/httptest"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | const (
11 | Password = "password"
12 | TooLongPassword = "swusp-dud-gust-grong-yuz-swuft-plaft-glact-skast-swem-yen-kom-tut-prisp-gont"
13 | )
14 |
15 | func TestUserAuthenticate(t *testing.T) {
16 | user, err := NewUser("louis@systemli.org", Password)
17 | assert.Nil(t, err)
18 |
19 | assert.False(t, user.Authenticate("wrong"))
20 | assert.True(t, user.Authenticate(Password))
21 | }
22 |
23 | func TestUserUpdatePassword(t *testing.T) {
24 | user, err := NewUser("louis@systemli.org", Password)
25 | assert.Nil(t, err)
26 |
27 | oldEncPassword := user.EncryptedPassword
28 | user.UpdatePassword("newPassword")
29 | assert.NotEqual(t, oldEncPassword, user.EncryptedPassword)
30 |
31 | user.UpdatePassword(TooLongPassword)
32 | assert.NotEqual(t, oldEncPassword, user.EncryptedPassword)
33 | }
34 |
35 | func TestNewUser(t *testing.T) {
36 | _, err := NewUser("user@systemli.org", Password)
37 | assert.Nil(t, err)
38 |
39 | _, err = NewUser("user@systemli.org", TooLongPassword)
40 | assert.NotNil(t, err)
41 | }
42 |
43 | func TestNewUserFilter(t *testing.T) {
44 | filter := NewUserFilter(nil)
45 | assert.Nil(t, filter.Email)
46 | assert.Nil(t, filter.IsSuperAdmin)
47 |
48 | req := httptest.NewRequest("GET", "/", nil)
49 | filter = NewUserFilter(req)
50 | assert.Nil(t, filter.Email)
51 | assert.Nil(t, filter.IsSuperAdmin)
52 |
53 | req = httptest.NewRequest("GET", "/?email=user@example.org&is_super_admin=true", nil)
54 | filter = NewUserFilter(req)
55 | assert.Equal(t, "user@example.org", *filter.Email)
56 | assert.True(t, *filter.IsSuperAdmin)
57 |
58 | req = httptest.NewRequest("GET", "/?order_by=created_at&sort=asc", nil)
59 | filter = NewUserFilter(req)
60 | assert.Nil(t, filter.Email)
61 | assert.Nil(t, filter.IsSuperAdmin)
62 | assert.Equal(t, "created_at", filter.OrderBy)
63 | assert.Equal(t, "asc", filter.Sort)
64 | }
65 |
--------------------------------------------------------------------------------
/internal/storage/utils.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "github.com/sirupsen/logrus"
5 | "github.com/systemli/ticker/internal/logger"
6 | "gorm.io/driver/mysql"
7 | "gorm.io/driver/postgres"
8 | "gorm.io/driver/sqlite"
9 | "gorm.io/gorm"
10 | )
11 |
12 | func OpenGormDB(dbType, dsn string, log *logrus.Logger) (*gorm.DB, error) {
13 | d := dialector(dbType, dsn)
14 | db, err := gorm.Open(d, &gorm.Config{
15 | Logger: logger.NewGormLogger(log),
16 | })
17 | if err != nil {
18 | return nil, err
19 | }
20 |
21 | return db, nil
22 | }
23 |
24 | func dialector(dbType, dsn string) gorm.Dialector {
25 | switch dbType {
26 | case "sqlite":
27 | return sqlite.Open(dsn)
28 | case "mysql":
29 | return mysql.Open(dsn)
30 | case "postgres":
31 | return postgres.Open(dsn)
32 | default:
33 | return nil
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/internal/storage/utils_test.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestOpenGormDB(t *testing.T) {
10 | _, err := OpenGormDB("sqlite", "file::memory:?cache=shared", nil)
11 | assert.NoError(t, err)
12 | }
13 |
14 | func TestDialector(t *testing.T) {
15 | var tests = []struct {
16 | dbType string
17 | dsn string
18 | shouldFail bool
19 | }{
20 | {"sqlite", "file::memory:?cache=shared", false},
21 | {"mysql", "user:password@tcp(localhost:5555)/dbname?charset=utf8mb4&parseTime=True&loc=Local", false},
22 | {"postgres", "host=myhost user=gorm password=gorm dbname=gorm port=9920 sslmode=disable TimeZone=Asia/Shanghai", false},
23 | {"", "", true},
24 | }
25 |
26 | for _, test := range tests {
27 | d := dialector(test.dbType, test.dsn)
28 | if test.shouldFail {
29 | assert.Nil(t, d)
30 | } else {
31 | assert.NotNil(t, d)
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/internal/util/file.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "io"
5 | "net/http"
6 | )
7 |
8 | //DetectContentType detects the ContentType from the first 512 bytes of the given io.Reader.
9 | func DetectContentType(r io.Reader) string {
10 | // Only the first 512 bytes are used to sniff the content type.
11 | buffer := make([]byte, 512)
12 |
13 | _, err := r.Read(buffer)
14 | if err != nil {
15 | return "application/octet-stream"
16 | }
17 |
18 | return http.DetectContentType(buffer)
19 | }
20 |
--------------------------------------------------------------------------------
/internal/util/file_test.go:
--------------------------------------------------------------------------------
1 | package util_test
2 |
3 | import (
4 | "os"
5 | "strings"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/assert"
9 |
10 | "github.com/systemli/ticker/internal/util"
11 | )
12 |
13 | func TestDetectContentTypeImage(t *testing.T) {
14 | file, err := os.Open("../../testdata/gopher.jpg")
15 | if err != nil {
16 | t.Fail()
17 | }
18 |
19 | assert.Equal(t, "image/jpeg", util.DetectContentType(file))
20 | }
21 |
22 | func TestDetectContentTypeOther(t *testing.T) {
23 | r := strings.NewReader("content")
24 |
25 | assert.Equal(t, "application/octet-stream", util.DetectContentType(r))
26 | }
27 |
--------------------------------------------------------------------------------
/internal/util/hashtag.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import "strings"
4 |
5 | func ExtractHashtags(text string) []string {
6 | var hashtags []string
7 |
8 | for _, word := range strings.Fields(text) {
9 | if strings.HasPrefix(word, "#") {
10 | hashtags = append(hashtags, word)
11 | }
12 | }
13 |
14 | return hashtags
15 | }
16 |
--------------------------------------------------------------------------------
/internal/util/hashtag_test.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "github.com/stretchr/testify/assert"
5 | "testing"
6 | )
7 |
8 | func TestExtractHashtags(t *testing.T) {
9 | testCases := []struct {
10 | text string
11 | expected []string
12 | }{
13 | {
14 | text: "Hello #world",
15 | expected: []string{"#world"},
16 | },
17 | {
18 | text: "Hello #world #foo",
19 | expected: []string{"#world", "#foo"},
20 | },
21 | {
22 | text: "Hello world",
23 | expected: []string{},
24 | },
25 | }
26 |
27 | for _, tc := range testCases {
28 | hashtags := ExtractHashtags(tc.text)
29 | assert.Equal(t, len(tc.expected), len(hashtags))
30 | for i, hashtag := range hashtags {
31 | assert.Equal(t, hashtag, tc.expected[i])
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/internal/util/image.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "image"
5 | "image/png"
6 | "io"
7 |
8 | "github.com/disintegration/imaging"
9 | )
10 |
11 | func ResizeImage(file io.Reader, maxDimension int) (image.Image, error) {
12 | img, err := imaging.Decode(file, imaging.AutoOrientation(true))
13 | if err != nil {
14 | return img, err
15 | }
16 | if img.Bounds().Dx() > maxDimension {
17 | img = imaging.Resize(img, maxDimension, 0, imaging.Linear)
18 | }
19 | if img.Bounds().Dy() > maxDimension {
20 | img = imaging.Resize(img, 0, maxDimension, imaging.Linear)
21 | }
22 |
23 | return img, nil
24 | }
25 |
26 | func SaveImage(img image.Image, path string) error {
27 | opts := []imaging.EncodeOption{
28 | imaging.JPEGQuality(60),
29 | imaging.PNGCompressionLevel(png.BestCompression),
30 | }
31 |
32 | return imaging.Save(img, path, opts...)
33 | }
34 |
--------------------------------------------------------------------------------
/internal/util/image_test.go:
--------------------------------------------------------------------------------
1 | package util_test
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "os"
7 | "testing"
8 | "time"
9 |
10 | "github.com/disintegration/imaging"
11 | "github.com/stretchr/testify/assert"
12 |
13 | "github.com/systemli/ticker/internal/util"
14 | )
15 |
16 | func TestResizeImage(t *testing.T) {
17 | file, err := os.Open("../../testdata/gopher.jpg")
18 | if err != nil {
19 | t.Fail()
20 | return
21 | }
22 |
23 | img, err := util.ResizeImage(file, 100)
24 | if err != nil {
25 | t.Fail()
26 | return
27 | }
28 |
29 | assert.Equal(t, 63, img.Bounds().Dy())
30 | assert.Equal(t, 100, img.Bounds().Dx())
31 |
32 | r := bytes.NewReader([]byte{})
33 | img, err = util.ResizeImage(r, 100)
34 | if err == nil {
35 | t.Fail()
36 | }
37 | }
38 |
39 | func TestSaveImage(t *testing.T) {
40 | img, err := imaging.Open("../../testdata/gopher.jpg")
41 | if err != nil {
42 | t.Fail()
43 | return
44 | }
45 |
46 | err = util.SaveImage(img, fmt.Sprintf("%s/%d.jpg", os.TempDir(), time.Now().Nanosecond()))
47 | if err != nil {
48 | t.Fail()
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/internal/util/slice.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | // Contains returns true when s is in slice.
4 | func Contains(slice []int, s int) bool {
5 | for _, value := range slice {
6 | if value == s {
7 | return true
8 | }
9 | }
10 | return false
11 | }
12 |
13 | // Append appends the integer when it not already in the slice.
14 | func Append(slice []int, s int) []int {
15 | if Contains(slice, s) {
16 | return slice
17 | }
18 |
19 | return append(slice, s)
20 | }
21 |
22 | // Remove element from slice.
23 | func Remove(slice []int, s int) []int {
24 | for i := range slice {
25 | if slice[i] == s {
26 | slice = append(slice[:i], slice[i+1:]...)
27 | }
28 | }
29 |
30 | return slice
31 | }
32 |
33 | // ContainsString returns true when s in slice.
34 | func ContainsString(list []string, s string) bool {
35 | for _, b := range list {
36 | if b == s {
37 | return true
38 | }
39 | }
40 | return false
41 | }
42 |
--------------------------------------------------------------------------------
/internal/util/slice_test.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestContains(t *testing.T) {
10 | slice := []int{1, 2}
11 |
12 | assert.True(t, Contains(slice, 1))
13 | assert.False(t, Contains(slice, 3))
14 | }
15 |
16 | func TestAppend(t *testing.T) {
17 | slice := []int{1, 2}
18 |
19 | assert.Equal(t, slice, Append(slice, 2))
20 | assert.Equal(t, []int{1, 2, 3}, Append(slice, 3))
21 | }
22 |
23 | func TestRemove(t *testing.T) {
24 | slice := []int{1, 2}
25 |
26 | assert.Equal(t, slice, Remove(slice, 3))
27 | assert.Equal(t, []int{1}, Remove(slice, 2))
28 | assert.Equal(t, []int{}, Remove([]int{}, 2))
29 | }
30 |
31 | func TestContainsString(t *testing.T) {
32 | slice := []string{"a", "b"}
33 |
34 | assert.True(t, ContainsString(slice, "a"))
35 | assert.False(t, ContainsString(slice, "c"))
36 | }
37 |
--------------------------------------------------------------------------------
/internal/util/url.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import "regexp"
4 |
5 | // ExtractURLs extracts URLs from a text.
6 | func ExtractURLs(text string) []string {
7 | urlRegex := regexp.MustCompile(`(https?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(),]|%[0-9a-fA-F][0-9a-fA-F]|#)+)`)
8 | return urlRegex.FindAllString(text, -1)
9 | }
10 |
--------------------------------------------------------------------------------
/internal/util/url_test.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestExtractURL(t *testing.T) {
10 | testCases := []struct {
11 | text string
12 | expected []string
13 | }{
14 | {
15 | "This is a text with a URL https://example.com",
16 | []string{"https://example.com"},
17 | },
18 | {
19 | "This is a text with a URL https://example.com and another URL http://example.org",
20 | []string{"https://example.com", "http://example.org"},
21 | },
22 | {
23 | "This is a text without a URL",
24 | []string{},
25 | },
26 | {
27 | "This is a text with a URL https://www.systemli.org/en/contact/",
28 | []string{"https://www.systemli.org/en/contact/"},
29 | },
30 | {
31 | "This is a text with a URL https://www.systemli.org/en/contact/?key=value",
32 | []string{"https://www.systemli.org/en/contact/?key=value"},
33 | },
34 | {
35 | "This is a text with a URL https://www.systemli.org/en/contact/?key=value#fragment",
36 | []string{"https://www.systemli.org/en/contact/?key=value#fragment"},
37 | },
38 | }
39 |
40 | for _, tc := range testCases {
41 | urls := ExtractURLs(tc.text)
42 | assert.Equal(t, len(tc.expected), len(urls))
43 | for i, url := range tc.expected {
44 | assert.Equal(t, url, urls[i])
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "github.com/systemli/ticker/cmd"
4 |
5 | func main() {
6 | cmd.Execute()
7 | }
8 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: Systemli Ticker
2 | site_author: Systemli
3 | repo_url: https://github.com/systemli/ticker
4 |
5 | nav:
6 | - Home: index.md
7 | - Configuration: configuration.md
8 | - API: api.md
9 | - Quick Install Guide: quick-install.md
10 |
11 | theme:
12 | name: material
13 | logo: assets/logo.png
14 | favicon: assets/logo.png
15 | palette:
16 | primary: "blue"
17 | accent: "blue"
18 |
19 | features:
20 | - navigation.instant
21 | - navigation.tabs
22 | - navigation.tabs.sticky
23 |
24 | plugins:
25 | - search
26 | - render_swagger
27 |
28 | extra:
29 | generator: false
30 | social:
31 | - icon: fontawesome/solid/globe
32 | link: https://www.systemli.org
33 | - icon: fontawesome/brands/github
34 | link: https://github.com/systemli
35 |
36 | markdown_extensions:
37 | - admonition
38 | - pymdownx.betterem
39 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | mkdocs-render-swagger-plugin==0.0.3
2 |
--------------------------------------------------------------------------------
/sonar-project.properties:
--------------------------------------------------------------------------------
1 | sonar.projectKey=systemli_ticker
2 | sonar.organization=systemli
3 | sonar.links.homepage=https://github.com/systemli/ticker
4 | sonar.sourceEncoding=UTF-8
5 |
6 | sonar.sources=.
7 | sonar.exclusions=**/mock_*.go
8 |
9 | sonar.tests=.
10 | sonar.test.inclusions=**/*_test.go
11 |
12 | sonar.go.coverage.reportPaths=coverage.txt
13 |
--------------------------------------------------------------------------------
/testdata/config_invalid.txt:
--------------------------------------------------------------------------------
1 | - key1: value1
2 | key2: value2 # Invalid indentation
3 | - key3: value3
4 |
--------------------------------------------------------------------------------
/testdata/config_valid.yml:
--------------------------------------------------------------------------------
1 | listen: "127.0.0.1:8888"
2 |
--------------------------------------------------------------------------------
/testdata/gopher-dance.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/systemli/ticker/e99bb9693759e805bd80364878a10d58e4f329ec/testdata/gopher-dance.gif
--------------------------------------------------------------------------------
/testdata/gopher.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/systemli/ticker/e99bb9693759e805bd80364878a10d58e4f329ec/testdata/gopher.jpg
--------------------------------------------------------------------------------