├── .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 [![Integration](https://github.com/systemli/ticker/workflows/Integration/badge.svg)](https://github.com/systemli/ticker/actions) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=systemli_ticker&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=systemli_ticker) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=systemli_ticker&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=systemli_ticker) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=systemli_ticker&metric=coverage)](https://sonarcloud.io/summary/new_code?id=systemli_ticker) [![Docker Automated build](https://img.shields.io/docker/automated/systemli/ticker.svg)](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 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](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 --------------------------------------------------------------------------------