├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── check-semgrep.yml │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── .golangci.yml ├── Dockerfile ├── LICENSE ├── README-CN.md ├── README.md ├── SECURITY.md ├── build.config.sh ├── cmd ├── admin │ ├── add.go │ ├── admin.go │ ├── delete.go │ └── show.go ├── flags │ ├── config.go │ ├── server.go │ └── vars.go ├── root.go ├── root │ ├── add.go │ ├── delete.go │ ├── root.go │ └── show.go ├── self-update.go ├── server.go ├── setting │ ├── set.go │ ├── setting.go │ └── show.go ├── user │ ├── ban.go │ ├── delete.go │ ├── search.go │ ├── unban.go │ └── user.go └── version.go ├── go.mod ├── go.sum ├── helm ├── .helmignore ├── Chart.yaml ├── templates │ ├── _helpers.tpl │ ├── configmap.yaml │ ├── ingress.yaml │ ├── service-postgresql.yaml │ ├── service-synctv.yaml │ ├── statefulset-postgresql.yaml │ └── statefulset-synctv.yaml └── values.yaml ├── internal ├── bootstrap │ ├── config.go │ ├── db.go │ ├── gin.go │ ├── init.go │ ├── log.go │ ├── op.go │ ├── provider.go │ ├── rtmp.go │ ├── setting.go │ ├── sqlite.go │ ├── sqlite_cgo.go │ ├── sysNotify.go │ ├── update.go │ └── vendor.go ├── cache │ ├── alist.go │ ├── bilibili.go │ ├── cache.go │ ├── cache0.go │ └── emby.go ├── captcha │ └── captcha.go ├── conf │ ├── config.go │ ├── db.go │ ├── jwt.go │ ├── log.go │ ├── oauth2.go │ ├── reatLimit.go │ ├── server.go │ └── var.go ├── db │ ├── db.go │ ├── member.go │ ├── movie.go │ ├── room.go │ ├── setting.go │ ├── update.go │ ├── user.go │ ├── vendorBackend.go │ └── vendorRecord.go ├── email │ ├── email.go │ ├── emailtemplate │ │ ├── captcha.mjml │ │ ├── embed.go │ │ ├── retrieve_password.mjml │ │ └── test.mjml │ └── smtp.go ├── model │ ├── current.go │ ├── member.go │ ├── movie.go │ ├── oauth2.go │ ├── room.go │ ├── setting.go │ ├── user.go │ ├── vendorBackend.go │ └── vendorRecord.go ├── op │ ├── client.go │ ├── current.go │ ├── hub.go │ ├── message.go │ ├── movie.go │ ├── movies.go │ ├── op.go │ ├── room.go │ ├── rooms.go │ ├── user.go │ └── users.go ├── provider │ ├── aggregation.go │ ├── aggregations │ │ ├── aggregations.go │ │ └── rainbow.go │ ├── plugins │ │ ├── client.go │ │ ├── example │ │ │ ├── .gitignore │ │ │ ├── example_authing │ │ │ │ └── example_authing.go │ │ │ ├── example_feishu-sso │ │ │ │ └── example_feishu-sso.go │ │ │ └── example_gitee │ │ │ │ └── example_gitee.go │ │ ├── plugin.go │ │ └── server.go │ ├── provider.go │ └── providers │ │ ├── baidu-netdisk.go │ │ ├── baidu.go │ │ ├── casdoor.go │ │ ├── discord.go │ │ ├── gitee.go │ │ ├── github.go │ │ ├── gitlab.go │ │ ├── google.go │ │ ├── logto.go │ │ ├── microsoft.go │ │ ├── providers.go │ │ ├── qq.go │ │ └── xiaomi.go ├── rtmp │ └── rtmp.go ├── settings │ ├── bool.go │ ├── floate64.go │ ├── int64.go │ ├── setting.go │ ├── string.go │ └── var.go ├── sysNotify │ ├── signal.go │ ├── signal_windows.go │ └── sysNotify.go ├── sysnotify │ ├── signal.go │ ├── signal_windows.go │ └── sysnotify.go ├── vendor │ ├── alist.go │ ├── bilibili.go │ ├── emby.go │ └── vendor.go └── version │ ├── update.go │ ├── version.go │ └── version_test.go ├── main.go ├── proto ├── message │ ├── message.go │ ├── message.pb.go │ └── message.proto └── provider │ ├── plugin.pb.go │ ├── plugin.proto │ └── plugin_grpc.pb.go ├── public ├── dist │ └── .gitkeep └── public.go ├── script ├── build.config.sh ├── docker-compose.yml ├── entrypoint.sh ├── install.sh └── proto.sh ├── server ├── handlers │ ├── admin.go │ ├── danmu.go │ ├── init.go │ ├── member.go │ ├── movie.go │ ├── proxy │ │ ├── buffer.go │ │ ├── cache.go │ │ ├── m3u8.go │ │ ├── proxy.go │ │ ├── readseeker.go │ │ └── slice.go │ ├── public.go │ ├── room.go │ ├── root.go │ ├── user.go │ ├── vendors │ │ ├── vendorAlist │ │ │ ├── alist.go │ │ │ ├── list.go │ │ │ ├── login.go │ │ │ └── me.go │ │ ├── vendorBilibili │ │ │ ├── bilibili.go │ │ │ ├── login.go │ │ │ ├── me.go │ │ │ └── parse.go │ │ ├── vendorEmby │ │ │ ├── emby.go │ │ │ ├── list.go │ │ │ ├── login.go │ │ │ └── me.go │ │ ├── vendoralist │ │ │ ├── alist.go │ │ │ ├── list.go │ │ │ ├── login.go │ │ │ └── me.go │ │ ├── vendorbilibili │ │ │ ├── bilibili.go │ │ │ ├── danmu.go │ │ │ ├── login.go │ │ │ ├── me.go │ │ │ └── parse.go │ │ ├── vendoremby │ │ │ ├── emby.go │ │ │ ├── list.go │ │ │ ├── login.go │ │ │ └── me.go │ │ └── vendors.go │ └── websocket.go ├── middlewares │ ├── auth.go │ ├── cors.go │ ├── init.go │ ├── log.go │ └── rateLimit.go ├── model │ ├── admin.go │ ├── api.go │ ├── auth.go │ ├── decode.go │ ├── member.go │ ├── movie.go │ ├── room.go │ ├── user.go │ └── vendor.go ├── oauth2 │ ├── auth.go │ ├── bind.go │ ├── init.go │ ├── model.go │ ├── oauth2.go │ ├── render.go │ └── templates │ │ ├── redirect.html │ │ └── token.html ├── router.go └── static │ └── static.go └── utils ├── crypto.go ├── crypto_test.go ├── fastJSONSerializer └── fastJSONSerializer.go ├── m3u8 └── m3u8.go ├── smtp ├── format.go └── smtpool.go ├── utils.go ├── utils_test.go └── websocket.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "gomod" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/workflows/check-semgrep.yml: -------------------------------------------------------------------------------- 1 | name: Check-Semgrep 2 | 3 | on: 4 | workflow_call: 5 | workflow_dispatch: 6 | schedule: 7 | - cron: 0 0 * * * 8 | push: 9 | branches: 10 | - "**" 11 | tags: 12 | - "v*.*.*" 13 | paths-ignore: 14 | - "**/*.md" 15 | - "**/*.yaml" 16 | pull_request: 17 | branches: 18 | - "**" 19 | paths-ignore: 20 | - "**/*.md" 21 | - "**/*.yaml" 22 | 23 | jobs: 24 | semgrep: 25 | name: Scan 26 | runs-on: ubuntu-24.04 27 | container: 28 | image: semgrep/semgrep:latest 29 | continue-on-error: true 30 | if: (github.actor != 'dependabot[bot]') 31 | steps: 32 | - uses: actions/checkout@v4 33 | with: 34 | submodules: true 35 | - run: semgrep ci 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_call: 5 | workflow_dispatch: 6 | push: 7 | branches: 8 | - "**" 9 | tags: 10 | - "v*.*.*" 11 | paths-ignore: 12 | - "**/*.md" 13 | - "**/*.yaml" 14 | pull_request: 15 | branches: 16 | - "**" 17 | paths-ignore: 18 | - "**/*.md" 19 | - "**/*.yaml" 20 | 21 | jobs: 22 | golangci-lint: 23 | name: Lint 24 | runs-on: ubuntu-24.04 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | submodules: true 30 | 31 | - name: Setup Go 32 | uses: actions/setup-go@v5 33 | with: 34 | go-version-file: "go.mod" 35 | 36 | - name: Go test 37 | run: | 38 | go test -v -timeout 30s -count=1 ./... 39 | 40 | - name: Run Linter 41 | uses: golangci/golangci-lint-action@v8 42 | with: 43 | version: latest 44 | args: --color always 45 | 46 | - name: Run Fix Linter 47 | uses: golangci/golangci-lint-action@v8 48 | if: ${{ failure() }} 49 | with: 50 | install-mode: none 51 | args: --fix --color always 52 | 53 | - name: Auto Fix Diff Content 54 | if: ${{ failure() }} 55 | run: | 56 | git diff --color=always 57 | exit 1 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /log 2 | /public/dist/* 3 | !*.gitkeep 4 | .DS_Store 5 | /build 6 | *.db 7 | .vscode 8 | *.local 9 | /cross 10 | synctv 11 | synctv.* 12 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "vendors"] 2 | path = vendors 3 | url = https://github.com/synctv-org/vendors 4 | [submodule "synctv-web"] 5 | path = synctv-web 6 | url = https://github.com/synctv-org/synctv-web 7 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | run: 4 | go: "1.24" 5 | relative-path-mode: gomod 6 | modules-download-mode: readonly 7 | 8 | issues: 9 | max-issues-per-linter: 0 10 | max-same-issues: 0 11 | 12 | linters: 13 | default: none 14 | enable: 15 | - asasalint 16 | - asciicheck 17 | - bidichk 18 | - bodyclose 19 | - canonicalheader 20 | - containedctx 21 | - copyloopvar 22 | - durationcheck 23 | - errcheck 24 | - errchkjson 25 | - errname 26 | - errorlint 27 | - exptostd 28 | - fatcontext 29 | - forbidigo 30 | - ginkgolinter 31 | - gocheckcompilerdirectives 32 | - gocritic 33 | - gocyclo 34 | - goprintffuncname 35 | - gosec 36 | - govet 37 | - iface 38 | - importas 39 | - inamedparam 40 | - ineffassign 41 | - intrange 42 | - loggercheck 43 | - mirror 44 | - misspell 45 | - musttag 46 | - nakedret 47 | - noctx 48 | - nolintlint 49 | - nosprintfhostport 50 | - perfsprint 51 | - prealloc 52 | - predeclared 53 | - promlinter 54 | - protogetter 55 | - reassign 56 | - revive 57 | - rowserrcheck 58 | - sloglint 59 | - spancheck 60 | - sqlclosecheck 61 | - staticcheck 62 | - testpackage 63 | - thelper 64 | - tparallel 65 | - unconvert 66 | - unparam 67 | - unused 68 | - usestdlibvars 69 | - usetesting 70 | - wastedassign 71 | - whitespace 72 | exclusions: 73 | generated: lax 74 | presets: 75 | - comments 76 | - common-false-positives 77 | - legacy 78 | - std-error-handling 79 | paths: 80 | - third_party$ 81 | - builtin$ 82 | - examples$ 83 | settings: 84 | copyloopvar: 85 | check-alias: true 86 | cyclop: 87 | max-complexity: 15 88 | errcheck: 89 | check-type-assertions: true 90 | forbidigo: 91 | analyze-types: true 92 | prealloc: 93 | for-loops: true 94 | staticcheck: 95 | dot-import-whitelist: [] 96 | http-status-code-whitelist: [] 97 | usestdlibvars: 98 | time-month: true 99 | time-layout: true 100 | crypto-hash: true 101 | default-rpc-path: true 102 | sql-isolation-level: true 103 | tls-signature-scheme: true 104 | constant-kind: true 105 | usetesting: 106 | os-temp-dir: true 107 | gosec: 108 | excludes: 109 | - G404 110 | 111 | formatters: 112 | enable: 113 | - gci 114 | - gofmt 115 | - gofumpt 116 | - golines 117 | exclusions: 118 | generated: lax 119 | paths: 120 | - third_party$ 121 | - builtin$ 122 | - examples$ 123 | settings: 124 | gofmt: 125 | rewrite-rules: 126 | - pattern: "interface{}" 127 | replacement: "any" 128 | - pattern: "a[b:len(a)]" 129 | replacement: "a[b:]" 130 | gofumpt: 131 | extra-rules: true 132 | golines: 133 | shorten-comments: true 134 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS builder 2 | 3 | ARG VERSION 4 | 5 | WORKDIR /synctv 6 | 7 | COPY ./ ./ 8 | 9 | RUN apk add --no-cache bash curl git g++ 10 | 11 | RUN curl -sL \ 12 | https://raw.githubusercontent.com/zijiren233/go-build-action/refs/tags/v1/build.sh | \ 13 | bash -s -- \ 14 | --version=${VERSION} \ 15 | --bin-name-no-suffix 16 | 17 | FROM alpine:latest 18 | 19 | ENV PUID=0 PGID=0 UMASK=022 20 | 21 | COPY --from=builder /synctv/build/synctv /usr/local/bin/synctv 22 | 23 | RUN apk add --no-cache bash ca-certificates su-exec tzdata && \ 24 | rm -rf /var/cache/apk/* 25 | 26 | COPY script/entrypoint.sh /entrypoint.sh 27 | 28 | RUN chmod +x /entrypoint.sh && \ 29 | mkdir -p /root/.synctv 30 | 31 | WORKDIR /root/.synctv 32 | 33 | EXPOSE 8080/tcp 34 | 35 | VOLUME [ "/root/.synctv" ] 36 | 37 | ENTRYPOINT [ "/entrypoint.sh" ] 38 | 39 | CMD [ "server" ] 40 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 0.x.x | :white_check_mark: | 11 | | dev | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | If you believe you have found a security vulnerability, please report it immediately by creating a new issue in the issue tracker. 16 | 17 | We take all security vulnerabilities seriously and will respond to your report as quickly as possible. 18 | 19 | Thank you for helping to keep our project secure! 20 | -------------------------------------------------------------------------------- /build.config.sh: -------------------------------------------------------------------------------- 1 | source script/build.config.sh -------------------------------------------------------------------------------- /cmd/admin/add.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | "github.com/synctv-org/synctv/internal/bootstrap" 9 | "github.com/synctv-org/synctv/internal/db" 10 | ) 11 | 12 | var ErrMissingUserID = errors.New("missing user id") 13 | 14 | var AddCmd = &cobra.Command{ 15 | Use: "add", 16 | Short: "add admin by user id", 17 | Long: `add admin by user id`, 18 | PreRunE: func(cmd *cobra.Command, _ []string) error { 19 | return bootstrap.New().Add( 20 | bootstrap.InitStdLog, 21 | bootstrap.InitConfig, 22 | bootstrap.InitDatabase, 23 | ).Run(cmd.Context()) 24 | }, 25 | RunE: func(_ *cobra.Command, args []string) error { 26 | if len(args) == 0 { 27 | return ErrMissingUserID 28 | } 29 | u, err := db.GetUserByID(args[0]) 30 | if err != nil { 31 | log.Errorf("get user failed: %s", err) 32 | return nil 33 | } 34 | if err := db.AddAdmin(u); err != nil { 35 | log.Errorf("add admin failed: %s", err) 36 | return nil 37 | } 38 | log.Infof("add admin success: %s\n", u.Username) 39 | return nil 40 | }, 41 | } 42 | 43 | func init() { 44 | AdminCmd.AddCommand(AddCmd) 45 | } 46 | -------------------------------------------------------------------------------- /cmd/admin/admin.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var AdminCmd = &cobra.Command{ 8 | Use: "admin", 9 | Short: "admin", 10 | Long: `you must first shut down the server, otherwise the changes will not take effect.`, 11 | } 12 | -------------------------------------------------------------------------------- /cmd/admin/delete.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | "github.com/synctv-org/synctv/internal/bootstrap" 9 | "github.com/synctv-org/synctv/internal/db" 10 | ) 11 | 12 | var RemoveCmd = &cobra.Command{ 13 | Use: "remove", 14 | Short: "remove", 15 | Long: `remove admin`, 16 | PreRunE: func(cmd *cobra.Command, _ []string) error { 17 | return bootstrap.New().Add( 18 | bootstrap.InitStdLog, 19 | bootstrap.InitConfig, 20 | bootstrap.InitDatabase, 21 | ).Run(cmd.Context()) 22 | }, 23 | RunE: func(_ *cobra.Command, args []string) error { 24 | if len(args) == 0 { 25 | return errors.New("missing user id") 26 | } 27 | u, err := db.GetUserByID(args[0]) 28 | if err != nil { 29 | log.Errorf("get user failed: %s", err) 30 | return nil 31 | } 32 | if err := db.RemoveAdmin(u); err != nil { 33 | log.Errorf("remove admin failed: %s", err) 34 | return nil 35 | } 36 | log.Infof("remove admin success: %s\n", u.Username) 37 | return nil 38 | }, 39 | } 40 | 41 | func init() { 42 | AdminCmd.AddCommand(RemoveCmd) 43 | } 44 | -------------------------------------------------------------------------------- /cmd/admin/show.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "github.com/spf13/cobra" 6 | "github.com/synctv-org/synctv/internal/bootstrap" 7 | "github.com/synctv-org/synctv/internal/db" 8 | ) 9 | 10 | var ShowCmd = &cobra.Command{ 11 | Use: "show", 12 | Short: "show admin", 13 | Long: `show admin`, 14 | PreRunE: func(cmd *cobra.Command, _ []string) error { 15 | return bootstrap.New().Add( 16 | bootstrap.InitStdLog, 17 | bootstrap.InitConfig, 18 | bootstrap.InitDatabase, 19 | ).Run(cmd.Context()) 20 | }, 21 | RunE: func(_ *cobra.Command, _ []string) error { 22 | admins, err := db.GetAdmins() 23 | if err != nil { 24 | log.Errorf("get admins failed: %s\n", err.Error()) 25 | } 26 | for _, admin := range admins { 27 | log.Infof("id: %s\tusername: %s\n", admin.ID, admin.Username) 28 | } 29 | return nil 30 | }, 31 | } 32 | 33 | func init() { 34 | AdminCmd.AddCommand(ShowCmd) 35 | } 36 | -------------------------------------------------------------------------------- /cmd/flags/config.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | type GlobalFlags struct { 4 | Dev bool `env:"DEV"` 5 | LogStd bool `env:"LOG_STD"` 6 | GitHubBaseURL string `env:"GITHUB_BASE_URL"` 7 | DataDir string `env:"DATA_DIR"` 8 | ForceAutoMigrate bool `env:"FORCE_AUTO_MIGRATE"` 9 | } 10 | -------------------------------------------------------------------------------- /cmd/flags/server.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | type ServerFlags struct { 4 | SkipConfig bool `env:"SKIP_CONFIG"` 5 | SkipEnvConfig bool `env:"SKIP_ENV_CONFIG"` 6 | DisableUpdateCheck bool `env:"DISABLE_UPDATE_CHECK"` 7 | DisableWeb bool `env:"DISABLE_WEB"` 8 | WebPath string `env:"WEB_PATH"` 9 | DisableLogColor bool `env:"DISABLE_LOG_COLOR"` 10 | } 11 | -------------------------------------------------------------------------------- /cmd/flags/vars.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | var ( 4 | // Global 5 | EnvNoPrefix bool 6 | SkipEnvFlag bool 7 | Global GlobalFlags 8 | 9 | // Server 10 | Server ServerFlags 11 | ) 12 | 13 | const ( 14 | EnvPrefix = "SYNCTV_" 15 | ) 16 | -------------------------------------------------------------------------------- /cmd/root/add.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | "github.com/synctv-org/synctv/internal/bootstrap" 9 | "github.com/synctv-org/synctv/internal/db" 10 | ) 11 | 12 | var AddCmd = &cobra.Command{ 13 | Use: "add", 14 | Short: "add root by user id", 15 | Long: `add root by user id`, 16 | PreRunE: func(cmd *cobra.Command, _ []string) error { 17 | return bootstrap.New().Add( 18 | bootstrap.InitStdLog, 19 | bootstrap.InitConfig, 20 | bootstrap.InitDatabase, 21 | ).Run(cmd.Context()) 22 | }, 23 | RunE: func(_ *cobra.Command, args []string) error { 24 | if len(args) == 0 { 25 | return errors.New("missing user id") 26 | } 27 | u, err := db.GetUserByID(args[0]) 28 | if err != nil { 29 | log.Errorf("get user failed: %s", err) 30 | return nil 31 | } 32 | if err := db.AddRoot(u); err != nil { 33 | log.Errorf("add root failed: %s", err) 34 | return nil 35 | } 36 | log.Infof("add root success: %s\n", u.Username) 37 | return nil 38 | }, 39 | } 40 | 41 | func init() { 42 | RootCmd.AddCommand(AddCmd) 43 | } 44 | -------------------------------------------------------------------------------- /cmd/root/delete.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | "github.com/synctv-org/synctv/internal/bootstrap" 9 | "github.com/synctv-org/synctv/internal/db" 10 | ) 11 | 12 | var RemoveCmd = &cobra.Command{ 13 | Use: "remove", 14 | Short: "remove", 15 | Long: `remove root`, 16 | PreRunE: func(cmd *cobra.Command, _ []string) error { 17 | return bootstrap.New().Add( 18 | bootstrap.InitStdLog, 19 | bootstrap.InitConfig, 20 | bootstrap.InitDatabase, 21 | ).Run(cmd.Context()) 22 | }, 23 | RunE: func(_ *cobra.Command, args []string) error { 24 | if len(args) == 0 { 25 | return errors.New("missing user id") 26 | } 27 | u, err := db.GetUserByID(args[0]) 28 | if err != nil { 29 | log.Errorf("get user failed: %s", err) 30 | return nil 31 | } 32 | if err := db.RemoveRoot(u); err != nil { 33 | log.Errorf("remove root failed: %s", err) 34 | return nil 35 | } 36 | log.Infof("remove root success: %s\n", u.Username) 37 | return nil 38 | }, 39 | } 40 | 41 | func init() { 42 | RootCmd.AddCommand(RemoveCmd) 43 | } 44 | -------------------------------------------------------------------------------- /cmd/root/root.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | var RootCmd = &cobra.Command{ 6 | Use: "root", 7 | Short: "root", 8 | Long: `you must first shut down the server, otherwise the changes will not take effect.`, 9 | } 10 | -------------------------------------------------------------------------------- /cmd/root/show.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "github.com/spf13/cobra" 6 | "github.com/synctv-org/synctv/internal/bootstrap" 7 | "github.com/synctv-org/synctv/internal/db" 8 | ) 9 | 10 | var ShowCmd = &cobra.Command{ 11 | Use: "show", 12 | Short: "show root", 13 | Long: `show root`, 14 | PreRunE: func(cmd *cobra.Command, _ []string) error { 15 | return bootstrap.New().Add( 16 | bootstrap.InitStdLog, 17 | bootstrap.InitConfig, 18 | bootstrap.InitDatabase, 19 | ).Run(cmd.Context()) 20 | }, 21 | RunE: func(_ *cobra.Command, _ []string) error { 22 | roots := db.GetRoots() 23 | for _, root := range roots { 24 | log.Infof("id: %s\tusername: %s\n", root.ID, root.Username) 25 | } 26 | return nil 27 | }, 28 | } 29 | 30 | func init() { 31 | RootCmd.AddCommand(ShowCmd) 32 | } 33 | -------------------------------------------------------------------------------- /cmd/self-update.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | "github.com/synctv-org/synctv/cmd/flags" 9 | "github.com/synctv-org/synctv/internal/bootstrap" 10 | "github.com/synctv-org/synctv/internal/version" 11 | ) 12 | 13 | const SelfUpdateLong = `self-update command will update synctv-server binary to latest version. 14 | 15 | Version check in: https://github.com/synctv-org/synctv/releases/latest 16 | 17 | If use '--dev' flag, will update to latest dev version always.` 18 | 19 | var SelfUpdateCmd = &cobra.Command{ 20 | Use: "self-update", 21 | Short: "self-update", 22 | Long: SelfUpdateLong, 23 | PreRunE: func(cmd *cobra.Command, _ []string) error { 24 | return bootstrap.New().Add( 25 | bootstrap.InitStdLog, 26 | ).Run(cmd.Context()) 27 | }, 28 | RunE: SelfUpdate, 29 | } 30 | 31 | func SelfUpdate(cmd *cobra.Command, _ []string) error { 32 | v, err := version.NewVersionInfo(version.WithBaseURL(flags.Global.GitHubBaseURL)) 33 | if err != nil { 34 | log.Errorf("get version info error: %v", err) 35 | return fmt.Errorf("get version info error: %w", err) 36 | } 37 | return v.SelfUpdate(cmd.Context()) 38 | } 39 | 40 | func init() { 41 | RootCmd.AddCommand(SelfUpdateCmd) 42 | } 43 | -------------------------------------------------------------------------------- /cmd/setting/set.go: -------------------------------------------------------------------------------- 1 | package setting 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | "github.com/synctv-org/synctv/internal/bootstrap" 9 | "github.com/synctv-org/synctv/internal/settings" 10 | ) 11 | 12 | var SetCmd = &cobra.Command{ 13 | Use: "set", 14 | Short: "set setting", 15 | Long: `set setting`, 16 | PreRunE: func(cmd *cobra.Command, _ []string) error { 17 | return bootstrap.New().Add( 18 | bootstrap.InitStdLog, 19 | bootstrap.InitConfig, 20 | bootstrap.InitDatabase, 21 | bootstrap.InitSetting, 22 | ).Run(cmd.Context()) 23 | }, 24 | RunE: func(_ *cobra.Command, args []string) error { 25 | if len(args) != 2 { 26 | return errors.New("args length must be 2") 27 | } 28 | s, ok := settings.Settings[args[0]] 29 | if !ok { 30 | return errors.New("setting not found") 31 | } 32 | err := s.SetString(args[1]) 33 | if err != nil { 34 | log.Errorf("set setting %s error: %v\n", args[0], err) 35 | } 36 | log.Infof("set setting success:\n%s: %v\n", args[0], s.Interface()) 37 | return nil 38 | }, 39 | } 40 | 41 | func init() { 42 | SettingCmd.AddCommand(SetCmd) 43 | } 44 | -------------------------------------------------------------------------------- /cmd/setting/setting.go: -------------------------------------------------------------------------------- 1 | package setting 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | var SettingCmd = &cobra.Command{ 6 | Use: "setting", 7 | Short: "setting", 8 | Long: `you must first shut down the server, otherwise the changes will not take effect.`, 9 | } 10 | -------------------------------------------------------------------------------- /cmd/setting/show.go: -------------------------------------------------------------------------------- 1 | package setting 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/synctv-org/synctv/internal/bootstrap" 8 | "github.com/synctv-org/synctv/internal/model" 9 | "github.com/synctv-org/synctv/internal/settings" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | var ShowCmd = &cobra.Command{ 14 | Use: "show", 15 | Short: "show setting", 16 | Long: `show setting`, 17 | PreRunE: func(cmd *cobra.Command, _ []string) error { 18 | return bootstrap.New().Add( 19 | bootstrap.InitStdLog, 20 | bootstrap.InitConfig, 21 | bootstrap.InitDatabase, 22 | bootstrap.InitSetting, 23 | ).Run(cmd.Context()) 24 | }, 25 | RunE: func(_ *cobra.Command, _ []string) error { 26 | m := make(map[model.SettingGroup]map[string]any) 27 | for g, s := range settings.GroupSettings { 28 | if _, ok := m[g]; !ok { 29 | m[g] = make(map[string]any) 30 | } 31 | for _, v := range s { 32 | m[g][v.Name()] = v.Interface() 33 | } 34 | } 35 | return yaml.NewEncoder(os.Stdout).Encode(m) 36 | }, 37 | } 38 | 39 | func init() { 40 | SettingCmd.AddCommand(ShowCmd) 41 | } 42 | -------------------------------------------------------------------------------- /cmd/user/ban.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | "github.com/synctv-org/synctv/internal/bootstrap" 9 | "github.com/synctv-org/synctv/internal/db" 10 | ) 11 | 12 | var BanCmd = &cobra.Command{ 13 | Use: "ban", 14 | Short: "ban user with user id", 15 | Long: "ban user with user id", 16 | PreRunE: func(cmd *cobra.Command, _ []string) error { 17 | return bootstrap.New().Add( 18 | bootstrap.InitStdLog, 19 | bootstrap.InitConfig, 20 | bootstrap.InitDatabase, 21 | ).Run(cmd.Context()) 22 | }, 23 | RunE: func(_ *cobra.Command, args []string) error { 24 | if len(args) == 0 { 25 | return errors.New("missing user id") 26 | } 27 | u, err := db.GetUserByID(args[0]) 28 | if err != nil { 29 | log.Errorf("get user failed: %s\n", err) 30 | return nil 31 | } 32 | err = db.BanUser(u) 33 | if err != nil { 34 | log.Errorf("ban user failed: %s\n", err) 35 | return nil 36 | } 37 | log.Infof("ban user success: %s\n", u.Username) 38 | return nil 39 | }, 40 | } 41 | 42 | func init() { 43 | UserCmd.AddCommand(BanCmd) 44 | } 45 | -------------------------------------------------------------------------------- /cmd/user/delete.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | "github.com/synctv-org/synctv/internal/bootstrap" 9 | "github.com/synctv-org/synctv/internal/db" 10 | ) 11 | 12 | var DeleteCmd = &cobra.Command{ 13 | Use: "delete", 14 | Short: "delete", 15 | Long: `delete user`, 16 | PreRunE: func(cmd *cobra.Command, _ []string) error { 17 | return bootstrap.New().Add( 18 | bootstrap.InitStdLog, 19 | bootstrap.InitConfig, 20 | bootstrap.InitDatabase, 21 | ).Run(cmd.Context()) 22 | }, 23 | RunE: func(_ *cobra.Command, args []string) error { 24 | if len(args) == 0 { 25 | return errors.New("missing user id") 26 | } 27 | u, err := db.LoadAndDeleteUserByID(args[0]) 28 | if err != nil { 29 | log.Errorf("delete user failed: %s\n", err) 30 | return nil 31 | } 32 | log.Infof("delete user success: %s\n", u.Username) 33 | return nil 34 | }, 35 | } 36 | 37 | func init() { 38 | UserCmd.AddCommand(DeleteCmd) 39 | } 40 | -------------------------------------------------------------------------------- /cmd/user/search.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | "github.com/synctv-org/synctv/internal/bootstrap" 9 | "github.com/synctv-org/synctv/internal/db" 10 | ) 11 | 12 | var SearchCmd = &cobra.Command{ 13 | Use: "search", 14 | Short: "search user by id or username", 15 | Long: `search user by id or username`, 16 | PreRunE: func(cmd *cobra.Command, _ []string) error { 17 | return bootstrap.New().Add( 18 | bootstrap.InitStdLog, 19 | bootstrap.InitConfig, 20 | bootstrap.InitDatabase, 21 | ).Run(cmd.Context()) 22 | }, 23 | RunE: func(_ *cobra.Command, args []string) error { 24 | if len(args) == 0 { 25 | return errors.New("missing user id or username") 26 | } 27 | us, err := db.GetUserByIDOrUsernameLike(args[0]) 28 | if err != nil { 29 | return err 30 | } 31 | if len(us) == 0 { 32 | log.Infof("user not found") 33 | return nil 34 | } 35 | for _, u := range us { 36 | log.Infof( 37 | "id: %s\tusername: %s\tcreated_at: %s\trole: %s\n", 38 | u.ID, 39 | u.Username, 40 | u.CreatedAt, 41 | u.Role, 42 | ) 43 | } 44 | return nil 45 | }, 46 | } 47 | 48 | func init() { 49 | UserCmd.AddCommand(SearchCmd) 50 | } 51 | -------------------------------------------------------------------------------- /cmd/user/unban.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | "github.com/synctv-org/synctv/internal/bootstrap" 9 | "github.com/synctv-org/synctv/internal/db" 10 | ) 11 | 12 | var UnbanCmd = &cobra.Command{ 13 | Use: "unban", 14 | Short: "unban user with user id", 15 | Long: "unban user with user id", 16 | PreRunE: func(cmd *cobra.Command, _ []string) error { 17 | return bootstrap.New().Add( 18 | bootstrap.InitStdLog, 19 | bootstrap.InitConfig, 20 | bootstrap.InitDatabase, 21 | ).Run(cmd.Context()) 22 | }, 23 | RunE: func(_ *cobra.Command, args []string) error { 24 | if len(args) == 0 { 25 | return errors.New("missing user id") 26 | } 27 | u, err := db.GetUserByID(args[0]) 28 | if err != nil { 29 | log.Errorf("get user failed: %s\n", err) 30 | return nil 31 | } 32 | err = db.UnbanUser(u) 33 | if err != nil { 34 | log.Errorf("unban user failed: %s", err) 35 | return nil 36 | } 37 | log.Infof("unban user success: %s\n", u.Username) 38 | return nil 39 | }, 40 | } 41 | 42 | func init() { 43 | UserCmd.AddCommand(UnbanCmd) 44 | } 45 | -------------------------------------------------------------------------------- /cmd/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | var UserCmd = &cobra.Command{ 6 | Use: "user", 7 | Short: "user", 8 | Long: `you must first shut down the server, otherwise the changes will not take effect.`, 9 | } 10 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "runtime/debug" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/synctv-org/synctv/internal/version" 10 | ) 11 | 12 | //nolint:forbidigo 13 | var VersionCmd = &cobra.Command{ 14 | Use: "version", 15 | Short: "Print the version number of Sync TV Server", 16 | Long: `All software has versions. This is Sync TV Server's`, 17 | Run: func(_ *cobra.Command, _ []string) { 18 | fmt.Printf("synctv %s\n", version.Version) 19 | fmt.Printf("- git/commit: %s\n", version.GitCommit) 20 | fmt.Printf("- os/platform: %s\n", runtime.GOOS) 21 | fmt.Printf("- os/arch: %s\n", runtime.GOARCH) 22 | fmt.Printf("- go/version: %s\n", runtime.Version()) 23 | fmt.Printf("- go/compiler: %s\n", runtime.Compiler) 24 | fmt.Printf("- go/numcpu: %d\n", runtime.NumCPU()) 25 | info, ok := debug.ReadBuildInfo() 26 | if ok { 27 | fmt.Printf("- go/buildsettings: %v\n", info.Settings) 28 | } 29 | }, 30 | } 31 | 32 | func init() { 33 | RootCmd.AddCommand(VersionCmd) 34 | } 35 | -------------------------------------------------------------------------------- /helm/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /helm/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: synctv 3 | description: A Helm chart for deploying Synctv application with PostgreSQL StatefulSet 4 | 5 | type: application 6 | 7 | appVersion: 0.9.13 8 | version: 0.9.13 9 | -------------------------------------------------------------------------------- /helm/templates/_helpers.tpl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synctv-org/synctv/39c28e645884f9a6724b5ac995e7308524a2481a/helm/templates/_helpers.tpl -------------------------------------------------------------------------------- /helm/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ .Values.synctv.envConfigName }} 5 | data: 6 | DATABASE_TYPE: postgres 7 | DATABASE_HOST: synctv-postgresql 8 | DATABASE_PORT: "5432" 9 | DATABASE_USER: postgres 10 | DATABASE_PASSWORD: synctv 11 | DATABASE_NAME: postgres 12 | -------------------------------------------------------------------------------- /helm/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | apiVersion: networking.k8s.io/v1 3 | kind: Ingress 4 | metadata: 5 | name: synctv 6 | {{- with .Values.ingress.annotations }} 7 | annotations: 8 | {{- toYaml . | nindent 4 }} 9 | {{- end }} 10 | spec: 11 | {{- with .Values.ingress.className }} 12 | ingressClassName: {{ . }} 13 | {{- end }} 14 | rules: 15 | {{- range .Values.ingress.hosts }} 16 | - host: {{ .host | quote }} 17 | http: 18 | paths: 19 | - path: / 20 | pathType: Prefix 21 | backend: 22 | service: 23 | name: synctv 24 | port: 25 | number: 8080 26 | {{- end }} 27 | tls: 28 | {{- range .Values.ingress.hosts }} 29 | - hosts: 30 | - {{ .host | quote }} 31 | secretName: {{ .secretName }} 32 | {{- end }} 33 | {{- end }} 34 | -------------------------------------------------------------------------------- /helm/templates/service-postgresql.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.postgresql.enabled -}} 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: synctv-postgresql 6 | labels: 7 | app: synctv-postgresql 8 | spec: 9 | ports: 10 | - port: {{ .Values.postgresql.service.port }} 11 | targetPort: {{ .Values.postgresql.service.port }} 12 | selector: 13 | app: synctv-postgresql 14 | {{- end }} 15 | -------------------------------------------------------------------------------- /helm/templates/service-synctv.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: synctv 5 | labels: 6 | app: synctv 7 | spec: 8 | ports: 9 | - port: 8080 10 | targetPort: 8080 11 | selector: 12 | app: synctv 13 | -------------------------------------------------------------------------------- /helm/templates/statefulset-postgresql.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.postgresql.enabled -}} 2 | apiVersion: apps/v1 3 | kind: StatefulSet 4 | metadata: 5 | name: synctv-postgresql 6 | labels: 7 | app: synctv-postgresql 8 | spec: 9 | replicas: {{ .Values.postgresql.replicaCount }} 10 | selector: 11 | matchLabels: 12 | app: synctv-postgresql 13 | template: 14 | metadata: 15 | labels: 16 | app: synctv-postgresql 17 | spec: 18 | containers: 19 | - name: postgresql 20 | image: {{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }} 21 | imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }} 22 | ports: 23 | - containerPort: {{ .Values.postgresql.service.port }} 24 | env: 25 | - name: POSTGRES_PASSWORD 26 | value: {{ .Values.postgresql.password | quote }} 27 | volumeMounts: 28 | - name: postgresql-storage 29 | mountPath: /var/lib/postgresql/data 30 | volumeClaimTemplates: 31 | - metadata: 32 | name: postgresql-storage 33 | spec: 34 | {{- with .Values.postgresql.storage.storageClass }} 35 | storageClassName: {{ . }} 36 | {{- end }} 37 | accessModes: ["ReadWriteOnce"] 38 | resources: 39 | requests: 40 | storage: {{ .Values.postgresql.storage.size }} 41 | {{- end }} 42 | -------------------------------------------------------------------------------- /helm/templates/statefulset-synctv.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: StatefulSet 3 | metadata: 4 | name: synctv 5 | labels: 6 | app: synctv 7 | spec: 8 | selector: 9 | matchLabels: 10 | app: synctv 11 | template: 12 | metadata: 13 | labels: 14 | app: synctv 15 | spec: 16 | affinity: 17 | podAffinity: 18 | requiredDuringSchedulingIgnoredDuringExecution: 19 | - labelSelector: 20 | matchExpressions: 21 | - key: app 22 | operator: In 23 | values: 24 | - synctv-postgresql 25 | topologyKey: kubernetes.io/hostname 26 | containers: 27 | - name: synctv 28 | image: {{ .Values.synctv.image.repository }}:{{ .Values.synctv.image.tag | default .Chart.AppVersion }} 29 | imagePullPolicy: {{ .Values.synctv.image.pullPolicy }} 30 | ports: 31 | - containerPort: 8080 32 | envFrom: 33 | - configMapRef: 34 | name: {{ .Values.synctv.envConfigName }} 35 | volumeMounts: 36 | - name: synctv-storage 37 | mountPath: /root/.synctv 38 | volumeClaimTemplates: 39 | - metadata: 40 | name: synctv-storage 41 | spec: 42 | {{- with .Values.synctv.storage.storageClass }} 43 | storageClassName: {{ . }} 44 | {{- end }} 45 | accessModes: ["ReadWriteOnce"] 46 | resources: 47 | requests: 48 | storage: {{ .Values.synctv.storage.size }} 49 | -------------------------------------------------------------------------------- /helm/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for synctv. 2 | synctv: 3 | image: 4 | repository: synctvorg/synctv 5 | tag: "" 6 | pullPolicy: IfNotPresent 7 | envConfigName: synctv-env 8 | storage: 9 | size: 1Gi 10 | storageClass: "" 11 | 12 | postgresql: 13 | enabled: true 14 | replicaCount: 1 15 | service: 16 | port: 5432 17 | image: 18 | repository: postgres 19 | tag: 16-alpine 20 | pullPolicy: IfNotPresent 21 | password: synctv 22 | storage: 23 | size: 1Gi 24 | storageClass: "" 25 | 26 | ingress: 27 | enabled: true 28 | className: nginx 29 | annotations: {} 30 | -------------------------------------------------------------------------------- /internal/bootstrap/gin.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/synctv-org/synctv/cmd/flags" 8 | "github.com/synctv-org/synctv/utils" 9 | ) 10 | 11 | func InitGinMode(_ context.Context) error { 12 | if flags.Global.Dev { 13 | gin.SetMode(gin.DebugMode) 14 | } else { 15 | gin.SetMode(gin.ReleaseMode) 16 | } 17 | if utils.ForceColor() { 18 | gin.ForceConsoleColor() 19 | } else { 20 | gin.DisableConsoleColor() 21 | } 22 | 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /internal/bootstrap/init.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Conf func(*Bootstrap) 8 | 9 | func WithTask(f ...Func) Conf { 10 | return func(b *Bootstrap) { 11 | b.task = append(b.task, f...) 12 | } 13 | } 14 | 15 | type Bootstrap struct { 16 | task []Func 17 | } 18 | 19 | func New(conf ...Conf) *Bootstrap { 20 | b := &Bootstrap{} 21 | for _, c := range conf { 22 | c(b) 23 | } 24 | return b 25 | } 26 | 27 | type Func func(context.Context) error 28 | 29 | func (b *Bootstrap) Add(f ...Func) *Bootstrap { 30 | b.task = append(b.task, f...) 31 | return b 32 | } 33 | 34 | func (b *Bootstrap) Run(ctx context.Context) error { 35 | for _, f := range b.task { 36 | if err := f(ctx); err != nil { 37 | return err 38 | } 39 | } 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/bootstrap/log.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "runtime" 10 | "time" 11 | 12 | "github.com/natefinch/lumberjack" 13 | "github.com/sirupsen/logrus" 14 | "github.com/synctv-org/synctv/cmd/flags" 15 | "github.com/synctv-org/synctv/internal/conf" 16 | "github.com/synctv-org/synctv/utils" 17 | "github.com/zijiren233/go-colorable" 18 | ) 19 | 20 | func setLog(l *logrus.Logger) { 21 | if flags.Global.Dev { 22 | l.SetLevel(logrus.DebugLevel) 23 | l.SetReportCaller(true) 24 | } else { 25 | l.SetLevel(logrus.InfoLevel) 26 | l.SetReportCaller(false) 27 | } 28 | } 29 | 30 | var logCallerIgnoreFuncs = map[string]struct{}{ 31 | "github.com/synctv-org/synctv/server/middlewares.logColor": {}, 32 | } 33 | 34 | func InitLog(_ context.Context) (err error) { 35 | setLog(logrus.StandardLogger()) 36 | forceColor := utils.ForceColor() 37 | if conf.Conf.Log.Enable { 38 | l := &lumberjack.Logger{ 39 | Filename: conf.Conf.Log.FilePath, 40 | MaxSize: conf.Conf.Log.MaxSize, 41 | MaxBackups: conf.Conf.Log.MaxBackups, 42 | MaxAge: conf.Conf.Log.MaxAge, 43 | Compress: conf.Conf.Log.Compress, 44 | } 45 | if err := l.Rotate(); err != nil { 46 | logrus.Fatalf("log: rotate log file error: %v", err) 47 | } 48 | var w io.Writer 49 | if forceColor { 50 | w = colorable.NewNonColorableWriter(l) 51 | } else { 52 | w = l 53 | } 54 | if flags.Global.Dev || flags.Global.LogStd { 55 | logrus.SetOutput(io.MultiWriter(os.Stdout, w)) 56 | logrus.Infof("log: enable log to stdout and file: %s", conf.Conf.Log.FilePath) 57 | } else { 58 | logrus.SetOutput(w) 59 | logrus.Infof("log: disable log to stdout, only log to file: %s", conf.Conf.Log.FilePath) 60 | } 61 | } 62 | switch conf.Conf.Log.LogFormat { 63 | case "json": 64 | logrus.SetFormatter(&logrus.JSONFormatter{ 65 | TimestampFormat: time.DateTime, 66 | CallerPrettyfier: func(f *runtime.Frame) (function, file string) { 67 | if _, ok := logCallerIgnoreFuncs[f.Function]; ok { 68 | return "", "" 69 | } 70 | return f.Function, fmt.Sprintf("%s:%d", f.File, f.Line) 71 | }, 72 | }) 73 | default: 74 | if conf.Conf.Log.LogFormat != "text" { 75 | logrus.Warnf("unknown log format: %s, use default: text", conf.Conf.Log.LogFormat) 76 | } 77 | logrus.SetFormatter(&logrus.TextFormatter{ 78 | ForceColors: forceColor, 79 | DisableColors: !forceColor, 80 | ForceQuote: flags.Global.Dev, 81 | DisableQuote: !flags.Global.Dev, 82 | DisableSorting: false, 83 | FullTimestamp: true, 84 | TimestampFormat: time.DateTime, 85 | QuoteEmptyFields: true, 86 | CallerPrettyfier: func(f *runtime.Frame) (function, file string) { 87 | if _, ok := logCallerIgnoreFuncs[f.Function]; ok { 88 | return "", "" 89 | } 90 | return f.Function, fmt.Sprintf("%s:%d", f.File, f.Line) 91 | }, 92 | }) 93 | } 94 | log.SetOutput(logrus.StandardLogger().Writer()) 95 | return nil 96 | } 97 | 98 | func InitStdLog(_ context.Context) error { 99 | logrus.StandardLogger().SetOutput(os.Stdout) 100 | log.SetOutput(os.Stdout) 101 | setLog(logrus.StandardLogger()) 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /internal/bootstrap/op.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/synctv-org/synctv/internal/op" 7 | ) 8 | 9 | func InitOp(_ context.Context) error { 10 | return op.Init(4096) 11 | } 12 | -------------------------------------------------------------------------------- /internal/bootstrap/rtmp.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | log "github.com/sirupsen/logrus" 9 | "github.com/synctv-org/synctv/internal/op" 10 | "github.com/synctv-org/synctv/internal/rtmp" 11 | "github.com/synctv-org/synctv/internal/settings" 12 | rtmps "github.com/zijiren233/livelib/server" 13 | ) 14 | 15 | func InitRtmp(_ context.Context) error { 16 | s := rtmps.NewRtmpServer(auth) 17 | rtmp.Init(s) 18 | return nil 19 | } 20 | 21 | func auth(reqAppName, reqChannelName string, isPublisher bool) (*rtmps.Channel, error) { 22 | roomE, err := op.LoadOrInitRoomByID(reqAppName) 23 | if err != nil { 24 | log.Errorf("rtmp: get room by id error: %v", err) 25 | return nil, err 26 | } 27 | room := roomE.Value() 28 | 29 | if err := validateRoom(room); err != nil { 30 | return nil, err 31 | } 32 | 33 | if isPublisher { 34 | return handlePublisher(reqAppName, reqChannelName, room) 35 | } 36 | 37 | return handlePlayer(reqAppName, reqChannelName, room) 38 | } 39 | 40 | func validateRoom(room *op.Room) error { 41 | if room.IsBanned() { 42 | return fmt.Errorf("rtmp: room %s is banned", room.ID) 43 | } 44 | if room.IsPending() { 45 | return fmt.Errorf("rtmp: room %s is pending, need admin approval", room.ID) 46 | } 47 | return nil 48 | } 49 | 50 | func handlePublisher(reqAppName, reqChannelName string, room *op.Room) (*rtmps.Channel, error) { 51 | channelName, err := rtmp.AuthRtmpPublish(reqChannelName) 52 | if err != nil { 53 | log.Errorf("rtmp: publish auth to %s error: %v", reqAppName, err) 54 | return nil, err 55 | } 56 | log.Infof("rtmp: publisher login success: %s/%s", reqAppName, channelName) 57 | return room.GetChannel(channelName) 58 | } 59 | 60 | func handlePlayer(reqAppName, reqChannelName string, room *op.Room) (*rtmps.Channel, error) { 61 | if !settings.RtmpPlayer.Get() { 62 | err := errors.New("rtmp player is not enabled") 63 | log.Warnf("rtmp: dial to %s/%s error: %s", reqAppName, reqChannelName, err) 64 | return nil, err 65 | } 66 | return room.GetChannel(reqChannelName) 67 | } 68 | -------------------------------------------------------------------------------- /internal/bootstrap/setting.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/synctv-org/synctv/internal/db" 7 | "github.com/synctv-org/synctv/internal/model" 8 | "github.com/synctv-org/synctv/internal/settings" 9 | ) 10 | 11 | func InitSetting(_ context.Context) error { 12 | return initAndFixSettings() 13 | } 14 | 15 | func settingEqual(s *model.Setting, b settings.Setting) bool { 16 | return s.Type == b.Type() && s.Group == b.Group() && s.Name == b.Name() 17 | } 18 | 19 | func initAndFixSettings() error { 20 | settingsCache, err := db.GetSettingItemsToMap() 21 | if err != nil { 22 | return err 23 | } 24 | var setting *model.Setting 25 | 26 | for { 27 | b, ok := settings.PopNeedInit() 28 | if !ok { 29 | return nil 30 | } 31 | 32 | if sc, ok := settingsCache[b.Name()]; ok && settingEqual(sc, b) { 33 | setting = sc 34 | } else { 35 | setting = &model.Setting{ 36 | Name: b.Name(), 37 | Value: b.DefaultString(), 38 | Type: b.Type(), 39 | Group: b.Group(), 40 | } 41 | err := db.FirstOrCreateSettingItemValue(setting) 42 | if err != nil { 43 | return err 44 | } 45 | } 46 | err = b.Init(setting.Value) 47 | if err != nil { 48 | // auto fix 49 | err = b.SetString(b.DefaultString()) 50 | if err != nil { 51 | return err 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/bootstrap/sqlite.go: -------------------------------------------------------------------------------- 1 | //go:build !cgo 2 | // +build !cgo 3 | 4 | package bootstrap 5 | 6 | import ( 7 | "github.com/glebarez/sqlite" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | func openSqlite(dsn string) gorm.Dialector { 12 | return sqlite.Open(dsn) 13 | } 14 | -------------------------------------------------------------------------------- /internal/bootstrap/sqlite_cgo.go: -------------------------------------------------------------------------------- 1 | //go:build cgo 2 | // +build cgo 3 | 4 | package bootstrap 5 | 6 | import ( 7 | "gorm.io/driver/sqlite" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | func openSqlite(dsn string) gorm.Dialector { 12 | return sqlite.Open(dsn) 13 | } 14 | -------------------------------------------------------------------------------- /internal/bootstrap/sysNotify.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "context" 5 | 6 | sysnotify "github.com/synctv-org/synctv/internal/sysnotify" 7 | ) 8 | 9 | func InitSysNotify(_ context.Context) error { 10 | sysnotify.Init() 11 | return nil 12 | } 13 | -------------------------------------------------------------------------------- /internal/bootstrap/update.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | log "github.com/sirupsen/logrus" 8 | sysnotify "github.com/synctv-org/synctv/internal/sysnotify" 9 | "github.com/synctv-org/synctv/internal/version" 10 | ) 11 | 12 | func InitCheckUpdate(ctx context.Context) error { 13 | v, err := version.NewVersionInfo() 14 | if err != nil { 15 | log.Fatalf("get version info error: %v", err) 16 | } 17 | 18 | go func() { 19 | execFile, err := version.ExecutableFile() 20 | if err != nil { 21 | execFile = "synctv" 22 | } 23 | 24 | var ( 25 | need bool 26 | latest string 27 | url string 28 | ) 29 | need, latest, url, err = check(ctx, v) 30 | if err != nil { 31 | log.Errorf("check update error: %v", err) 32 | } else if need { 33 | log.Infof("new version (%s) available: %s", latest, url) 34 | log.Infof("run '%s self-update' to auto update", execFile) 35 | } 36 | 37 | err = sysnotify.RegisterSysNotifyTask(0, sysnotify.NewSysNotifyTask( 38 | "check-update", 39 | sysnotify.NotifyTypeEXIT, 40 | func() error { 41 | if need { 42 | log.Infof("new version (%s) available: %s", latest, url) 43 | log.Infof("run '%s self-update' to auto update", execFile) 44 | } 45 | return nil 46 | }, 47 | )) 48 | if err != nil { 49 | log.Errorf("register sys notify task error: %v", err) 50 | } 51 | 52 | t := time.NewTicker(time.Hour * 6) 53 | defer t.Stop() 54 | for range t.C { 55 | func() { 56 | defer func() { 57 | if err := recover(); err != nil { 58 | log.Errorf("check update panic: %v", err) 59 | } 60 | }() 61 | need, latest, url, err = check(ctx, v) 62 | if err != nil { 63 | log.Errorf("check update error: %v", err) 64 | } 65 | }() 66 | } 67 | }() 68 | 69 | return nil 70 | } 71 | 72 | func check(ctx context.Context, v *version.Info) (need bool, latest, url string, err error) { 73 | l, err := v.CheckLatest(ctx) 74 | if err != nil { 75 | return false, "", "", err 76 | } 77 | latest = l 78 | b, err := v.NeedUpdate(ctx) 79 | if err != nil { 80 | return false, "", "", err 81 | } 82 | need = b 83 | if b { 84 | u, err := v.LatestBinaryURL(ctx) 85 | if err != nil { 86 | return false, "", "", err 87 | } 88 | url = u 89 | } 90 | return need, latest, url, nil 91 | } 92 | -------------------------------------------------------------------------------- /internal/bootstrap/vendor.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/synctv-org/synctv/internal/vendor" 7 | ) 8 | 9 | func InitVendorBackend(ctx context.Context) error { 10 | return vendor.Init(ctx) 11 | } 12 | -------------------------------------------------------------------------------- /internal/captcha/captcha.go: -------------------------------------------------------------------------------- 1 | package captcha 2 | 3 | import ( 4 | "github.com/mojocn/base64Captcha" 5 | ) 6 | 7 | var Captcha *base64Captcha.Captcha 8 | 9 | func init() { 10 | Captcha = base64Captcha.NewCaptcha( 11 | base64Captcha.DefaultDriverDigit, 12 | base64Captcha.DefaultMemStore, 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /internal/conf/config.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "github.com/synctv-org/synctv/utils" 5 | ) 6 | 7 | //nolint:tagliatelle 8 | type Config struct { 9 | // Log 10 | Log LogConfig `yaml:"log"` 11 | 12 | // Server 13 | Server ServerConfig `yaml:"server"` 14 | 15 | // Jwt 16 | Jwt JwtConfig `yaml:"jwt"` 17 | 18 | // Database 19 | Database DatabaseConfig `yaml:"database"` 20 | 21 | // Oauth2Plugins 22 | Oauth2Plugins Oauth2Plugins `yaml:"oauth2_plugins"` 23 | 24 | // RateLimit 25 | RateLimit RateLimitConfig `yaml:"rate_limit"` 26 | } 27 | 28 | func (c *Config) Save(file string) error { 29 | return utils.WriteYaml(file, c) 30 | } 31 | 32 | func DefaultConfig() *Config { 33 | return &Config{ 34 | // Log 35 | Log: DefaultLogConfig(), 36 | 37 | // Server 38 | Server: DefaultServerConfig(), 39 | 40 | // Jwt 41 | Jwt: DefaultJwtConfig(), 42 | 43 | // Database 44 | Database: DefaultDatabaseConfig(), 45 | 46 | // OAuth2 47 | Oauth2Plugins: DefaultOauth2Plugins(), 48 | 49 | // RateLimit 50 | RateLimit: DefaultRateLimitConfig(), 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/conf/db.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | type DatabaseType string 4 | 5 | const ( 6 | DatabaseTypeSqlite3 DatabaseType = "sqlite3" 7 | DatabaseTypeMysql DatabaseType = "mysql" 8 | DatabaseTypePostgres DatabaseType = "postgres" 9 | ) 10 | 11 | //nolint:tagliatelle 12 | type DatabaseConfig struct { 13 | Type DatabaseType `env:"DATABASE_TYPE" hc:"support sqlite3, mysql, postgres" lc:"default: sqlite3" yaml:"type"` 14 | Host string `env:"DATABASE_HOST" hc:"when type is not sqlite3, and port is 0, it will use unix socket file" yaml:"host"` 15 | Port uint16 `env:"DATABASE_PORT" yaml:"port"` 16 | User string `env:"DATABASE_USER" yaml:"user"` 17 | Password string `env:"DATABASE_PASSWORD" yaml:"password"` 18 | Name string `env:"DATABASE_NAME" hc:"when type is sqlite3, it will use sqlite db file or memory" lc:"default: synctv" yaml:"name"` 19 | SslMode string `env:"DATABASE_SSL_MODE" hc:"mysql: true, false, skip-verify, preferred, postgres: disable, require, verify-ca, verify-full" yaml:"ssl_mode"` 20 | 21 | CustomDSN string `env:"DATABASE_CUSTOM_DSN" hc:"when not empty, it will ignore other config" yaml:"custom_dsn"` 22 | 23 | MaxIdleConns int `env:"DATABASE_MAX_IDLE_CONNS" hc:"sqlite3 does not support setting connection parameters" yaml:"max_idle_conns"` 24 | MaxOpenConns int `env:"DATABASE_MAX_OPEN_CONNS" yaml:"max_open_conns"` 25 | ConnMaxLifetime string `env:"DATABASE_CONN_MAX_LIFETIME" yaml:"conn_max_lifetime"` 26 | ConnMaxIdleTime string `env:"DATABASE_CONN_MAX_IDLE_TIME" yaml:"conn_max_idle_time"` 27 | } 28 | 29 | func DefaultDatabaseConfig() DatabaseConfig { 30 | return DatabaseConfig{ 31 | Type: DatabaseTypeSqlite3, 32 | Host: "", 33 | Name: "synctv", 34 | 35 | MaxIdleConns: 4, 36 | MaxOpenConns: 64, 37 | ConnMaxLifetime: "2h", 38 | ConnMaxIdleTime: "30m", 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/conf/jwt.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "github.com/synctv-org/synctv/utils" 5 | ) 6 | 7 | type JwtConfig struct { 8 | Secret string `env:"JWT_SECRET" yaml:"secret"` 9 | Expire string `env:"JWT_EXPIRE" yaml:"expire"` 10 | } 11 | 12 | func DefaultJwtConfig() JwtConfig { 13 | return JwtConfig{ 14 | Secret: utils.RandString(32), 15 | Expire: "48h", 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /internal/conf/log.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | //nolint:tagliatelle 4 | type LogConfig struct { 5 | Enable bool `env:"LOG_ENABLE" yaml:"enable"` 6 | LogFormat string `env:"LOG_FORMAT" yaml:"log_format" hc:"can be set: text | json"` 7 | FilePath string `env:"LOG_FILE_PATH" yaml:"file_path" hc:"if it is a relative path, the data-dir directory will be used."` 8 | MaxSize int `env:"LOG_MAX_SIZE" yaml:"max_size" hc:"max size per log file" cm:"mb"` 9 | MaxBackups int `env:"LOG_MAX_BACKUPS" yaml:"max_backups"` 10 | MaxAge int `env:"LOG_MAX_AGE" yaml:"max_age"` 11 | Compress bool `env:"LOG_COMPRESS" yaml:"compress"` 12 | } 13 | 14 | func DefaultLogConfig() LogConfig { 15 | return LogConfig{ 16 | Enable: true, 17 | LogFormat: "text", 18 | FilePath: "log/log.log", 19 | MaxSize: 10, 20 | MaxBackups: 10, 21 | MaxAge: 28, 22 | Compress: false, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/conf/oauth2.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | //nolint:tagliatelle 4 | type Oauth2Plugins []struct { 5 | PluginFile string `yaml:"plugin_file"` 6 | Args []string `yaml:"args"` 7 | } 8 | 9 | func DefaultOauth2Plugins() Oauth2Plugins { 10 | return nil 11 | } 12 | -------------------------------------------------------------------------------- /internal/conf/reatLimit.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | //nolint:tagliatelle 4 | type RateLimitConfig struct { 5 | Enable bool `env:"SERVER_RATE_LIMIT_ENABLE" lc:"default: false" yaml:"enable"` 6 | Period string `env:"SERVER_RATE_LIMIT_PERIOD" yaml:"period"` 7 | Limit int64 `env:"SERVER_RATE_LIMIT_LIMIT" yaml:"limit"` 8 | TrustForwardHeader bool `env:"SERVER_RATE_LIMIT_TRUST_FORWARD_HEADER" lc:"default: false" yaml:"trust_forward_header" hc:"configure the limiter to trust X-Real-IP and X-Forwarded-For headers. Please be advised that using this option could be insecure (ie: spoofed) if your reverse proxy is not configured properly to forward a trustworthy client IP."` 9 | TrustedClientIPHeader string `env:"SERVER_RATE_LIMIT_TRUSTED_CLIENT_IP_HEADER" yaml:"trusted_client_ip_header" hc:"configure the limiter to use a custom header to obtain user IP. Please be advised that using this option could be insecure (ie: spoofed) if your reverse proxy is not configured properly to forward a trustworthy client IP."` 10 | } 11 | 12 | func DefaultRateLimitConfig() RateLimitConfig { 13 | return RateLimitConfig{ 14 | Enable: false, 15 | Period: "1m", 16 | Limit: 300, 17 | TrustForwardHeader: false, 18 | TrustedClientIPHeader: "", 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /internal/conf/server.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | //nolint:tagliatelle 4 | type ServerConfig struct { 5 | HTTP HTTPServerConfig `yaml:"http"` 6 | RTMP RTMPServerConfig `yaml:"rtmp"` 7 | ProxyCachePath string `yaml:"proxy_cache_path" env:"SERVER_PROXY_CACHE_PATH" hc:"proxy cache path storage path, empty means use memory cache"` 8 | ProxyCacheSize string `yaml:"proxy_cache_size" env:"SERVER_PROXY_CACHE_SIZE" hc:"proxy cache max size, example: 1MB 1GB, default 1GB"` 9 | } 10 | 11 | //nolint:tagliatelle 12 | type HTTPServerConfig struct { 13 | Listen string `env:"SERVER_LISTEN" yaml:"listen"` 14 | Port uint16 `env:"SERVER_PORT" yaml:"port"` 15 | 16 | CertPath string `env:"SERVER_CERT_PATH" yaml:"cert_path"` 17 | KeyPath string `env:"SERVER_KEY_PATH" yaml:"key_path"` 18 | } 19 | 20 | type RTMPServerConfig struct { 21 | Enable bool `env:"RTMP_ENABLE" yaml:"enable"` 22 | Listen string `env:"RTMP_LISTEN" yaml:"listen" lc:"default use http listen"` 23 | Port uint16 `env:"RTMP_PORT" yaml:"port" lc:"default use server port"` 24 | } 25 | 26 | func DefaultServerConfig() ServerConfig { 27 | return ServerConfig{ 28 | HTTP: HTTPServerConfig{ 29 | Listen: "0.0.0.0", 30 | Port: 8080, 31 | CertPath: "", 32 | KeyPath: "", 33 | }, 34 | RTMP: RTMPServerConfig{ 35 | Enable: true, 36 | Port: 0, 37 | }, 38 | ProxyCachePath: "", 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/conf/var.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | var Conf *Config 4 | -------------------------------------------------------------------------------- /internal/db/setting.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/synctv-org/synctv/internal/model" 5 | "gorm.io/gorm/clause" 6 | ) 7 | 8 | const ( 9 | ErrSettingNotFound = "setting" 10 | ) 11 | 12 | func GetSettingItems() ([]*model.Setting, error) { 13 | var items []*model.Setting 14 | return items, db.Find(&items).Error 15 | } 16 | 17 | func GetSettingItemsToMap() (map[string]*model.Setting, error) { 18 | items, err := GetSettingItems() 19 | if err != nil { 20 | return nil, err 21 | } 22 | m := make(map[string]*model.Setting, len(items)) 23 | for _, item := range items { 24 | m[item.Name] = item 25 | } 26 | return m, nil 27 | } 28 | 29 | func GetSettingItemByName(name string) (*model.Setting, error) { 30 | var item model.Setting 31 | err := db.Where("name = ?", name).First(&item).Error 32 | if err != nil { 33 | return nil, err 34 | } 35 | return &item, nil 36 | } 37 | 38 | func SaveSettingItem(item *model.Setting) error { 39 | result := db.Clauses(clause.OnConflict{ 40 | UpdateAll: true, 41 | }).Create(item) 42 | return HandleUpdateResult(result, ErrSettingNotFound) 43 | } 44 | 45 | func DeleteSettingItem(item *model.Setting) error { 46 | result := db.Where("name = ?", item.Name).Delete(&model.Setting{}) 47 | return HandleUpdateResult(result, ErrSettingNotFound) 48 | } 49 | 50 | func DeleteSettingItemByName(name string) error { 51 | result := db.Where("name = ?", name).Delete(&model.Setting{}) 52 | return HandleUpdateResult(result, ErrSettingNotFound) 53 | } 54 | 55 | func GetSettingItemValue(name string) (string, error) { 56 | var value string 57 | err := db.Model(&model.Setting{}).Where("name = ?", name).Select("value").Take(&value).Error 58 | if err != nil { 59 | return "", err 60 | } 61 | return value, nil 62 | } 63 | 64 | func FirstOrCreateSettingItemValue(s *model.Setting) error { 65 | return db. 66 | Where("name = ?", s.Name). 67 | Attrs(model.Setting{ 68 | Value: s.Value, 69 | Type: s.Type, 70 | Group: s.Group, 71 | }).FirstOrCreate(s).Error 72 | } 73 | 74 | func UpdateSettingItemValue(name, value string) error { 75 | result := db.Model(&model.Setting{}).Where("name = ?", name).Update("value", value) 76 | return HandleUpdateResult(result, ErrSettingNotFound) 77 | } 78 | -------------------------------------------------------------------------------- /internal/db/vendorBackend.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/synctv-org/synctv/internal/model" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | func GetAllVendorBackend() ([]*model.VendorBackend, error) { 11 | var backends []*model.VendorBackend 12 | err := db.Find(&backends).Error 13 | return backends, HandleNotFound(err, "backends") 14 | } 15 | 16 | func CreateVendorBackend(backend *model.VendorBackend) error { 17 | return db.Create(backend).Error 18 | } 19 | 20 | func updateVendorBackendEnabled(endpoint string, enabled bool) error { 21 | result := db.Model(&model.VendorBackend{}). 22 | Where("backend_endpoint = ?", endpoint). 23 | Update("enabled", enabled) 24 | return HandleUpdateResult(result, "vendor backend") 25 | } 26 | 27 | func EnableVendorBackend(endpoint string) error { 28 | return updateVendorBackendEnabled(endpoint, true) 29 | } 30 | 31 | func EnableVendorBackends(endpoints []string) error { 32 | result := db.Model(&model.VendorBackend{}). 33 | Where("backend_endpoint IN ?", endpoints). 34 | Update("enabled", true) 35 | return HandleUpdateResult(result, "vendor backends") 36 | } 37 | 38 | func DisableVendorBackend(endpoint string) error { 39 | return updateVendorBackendEnabled(endpoint, false) 40 | } 41 | 42 | func DisableVendorBackends(endpoints []string) error { 43 | result := db.Model(&model.VendorBackend{}). 44 | Where("backend_endpoint IN ?", endpoints). 45 | Update("enabled", false) 46 | return HandleUpdateResult(result, "vendor backends") 47 | } 48 | 49 | func DeleteVendorBackend(endpoint string) error { 50 | result := db.Where("backend_endpoint = ?", endpoint).Delete(&model.VendorBackend{}) 51 | return HandleUpdateResult(result, "vendor backend") 52 | } 53 | 54 | func DeleteVendorBackends(endpoints []string) error { 55 | result := db.Where("backend_endpoint IN ?", endpoints).Delete(&model.VendorBackend{}) 56 | return HandleUpdateResult(result, "vendor backends") 57 | } 58 | 59 | func GetVendorBackend(endpoint string) (*model.VendorBackend, error) { 60 | var backend model.VendorBackend 61 | err := db.Where("backend_endpoint = ?", endpoint).First(&backend).Error 62 | return &backend, HandleNotFound(err, "backend") 63 | } 64 | 65 | func CreateOrSaveVendorBackend(backend *model.VendorBackend) (*model.VendorBackend, error) { 66 | return backend, Transactional(func(tx *gorm.DB) error { 67 | var existingBackend model.VendorBackend 68 | err := tx.Where("backend_endpoint = ?", backend.Backend.Endpoint). 69 | First(&existingBackend). 70 | Error 71 | if errors.Is(err, gorm.ErrRecordNotFound) { 72 | return tx.Create(backend).Error 73 | } else if err != nil { 74 | return err 75 | } 76 | result := tx.Model(&existingBackend).Omit("created_at").Updates(backend) 77 | return HandleUpdateResult(result, "vendor backend") 78 | }) 79 | } 80 | 81 | func SaveVendorBackend(backend *model.VendorBackend) error { 82 | result := db.Omit("created_at").Save(backend) 83 | return HandleUpdateResult(result, "vendor backend") 84 | } 85 | -------------------------------------------------------------------------------- /internal/email/emailtemplate/captcha.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | .indent div { 4 | text-indent: 2em; 5 | } 6 | .code div { 7 | text-shadow: 0 0 11px #bdbdff; 8 | } 9 | .footer div { 10 | text-shadow: 0 0 5px #fef0df; 11 | } 12 | iframe { 13 | border:none 14 | } 15 | 16 | 17 | 18 | 19 | SyncTV 20 | 21 | 22 | 24 | 25 | 验证码: 26 | 你的验证码为: 27 | {{ .Captcha }} 28 | 该验证码有效期为5分钟,如果您并没有访问过我们的网站,或没有进行上述操作,请忽略这封邮件。 29 | 30 | 31 | 32 | 33 | Copyright {{ .Year }} SyncTV All 35 | Rights Reserved. 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /internal/email/emailtemplate/embed.go: -------------------------------------------------------------------------------- 1 | package emailtemplate 2 | 3 | import _ "embed" 4 | 5 | var ( 6 | //go:embed test.mjml 7 | TestMjml []byte 8 | 9 | //go:embed captcha.mjml 10 | CaptchaMjml []byte 11 | 12 | //go:embed retrieve_password.mjml 13 | RetrievePasswordMjml []byte 14 | ) 15 | -------------------------------------------------------------------------------- /internal/email/emailtemplate/retrieve_password.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | .indent div { 4 | text-indent: 2em; 5 | } 6 | .code div { 7 | text-shadow: 0 0 11px #bdbdff; 8 | } 9 | .footer div { 10 | text-shadow: 0 0 5px #fef0df; 11 | } 12 | iframe { 13 | border:none 14 | } 15 | 16 | 17 | 18 | 19 | SyncTV 20 | 21 | 22 | 24 | 25 | 忘记密码? 26 | Hi! 你在 SyncTV 中提交了重置密码的请求 27 | 你的验证码为: 28 | {{ .Captcha }} 29 | 前往站点修改 30 | 该验证码有效期为5分钟,如果您并没有访问过我们的网站,或没有进行上述操作,请忽略这封邮件。 31 | 32 | 33 | 34 | 35 | Copyright {{ .Year }} SyncTV All 37 | Rights Reserved. 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /internal/email/emailtemplate/test.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | .indent div { 4 | text-indent: 2em; 5 | } 6 | .code div { 7 | text-shadow: 0 0 11px #bdbdff; 8 | } 9 | .footer div { 10 | text-shadow: 0 0 5px #fef0df; 11 | } 12 | iframe { 13 | border:none 14 | } 15 | 16 | 17 | 18 | 19 | SyncTV 20 | 21 | 22 | 24 | 25 | 测试邮件: 26 | Dear {{ .Username }}. 27 | 这是一封测试邮件。This is a test email. 28 | 29 | 30 | 31 | 32 | Copyright {{ .Year }} SyncTV All 35 | Rights Reserved. 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /internal/model/current.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | type Current struct { 6 | Movie CurrentMovie `json:"movie"` 7 | Status Status `json:"status"` 8 | } 9 | 10 | type CurrentMovie struct { 11 | ID string `json:"id,omitempty"` 12 | IsLive bool `json:"isLive,omitempty"` 13 | SubPath string `json:"subPath,omitempty"` 14 | } 15 | 16 | type Status struct { 17 | LastUpdate time.Time `json:"lastUpdate,omitempty"` 18 | CurrentTime float64 `json:"currentTime,omitempty"` 19 | PlaybackRate float64 `json:"playbackRate,omitempty"` 20 | IsPlaying bool `json:"isPlaying,omitempty"` 21 | } 22 | 23 | func NewStatus() Status { 24 | return Status{ 25 | CurrentTime: 0, 26 | PlaybackRate: 1.0, 27 | LastUpdate: time.Now(), 28 | } 29 | } 30 | 31 | func (c *Current) UpdateStatus() Status { 32 | if c.Movie.IsLive { 33 | c.Status.LastUpdate = time.Now() 34 | return c.Status 35 | } 36 | if c.Status.IsPlaying { 37 | c.Status.CurrentTime += time.Since(c.Status.LastUpdate).Seconds() * c.Status.PlaybackRate 38 | } 39 | c.Status.LastUpdate = time.Now() 40 | return c.Status 41 | } 42 | 43 | func (c *Current) setLiveStatus() Status { 44 | c.Status.IsPlaying = true 45 | c.Status.PlaybackRate = 1.0 46 | c.Status.CurrentTime = 0 47 | c.Status.LastUpdate = time.Now() 48 | return c.Status 49 | } 50 | 51 | func (c *Current) SetStatus(playing bool, seek, rate, timeDiff float64) Status { 52 | if c.Movie.IsLive { 53 | return c.setLiveStatus() 54 | } 55 | c.Status.IsPlaying = playing 56 | c.Status.PlaybackRate = rate 57 | if playing { 58 | c.Status.CurrentTime = seek + (timeDiff * rate) 59 | } else { 60 | c.Status.CurrentTime = seek 61 | } 62 | c.Status.LastUpdate = time.Now() 63 | return c.Status 64 | } 65 | 66 | func (c *Current) SetSeekRate(seek, rate, timeDiff float64) Status { 67 | if c.Movie.IsLive { 68 | return c.setLiveStatus() 69 | } 70 | if c.Status.IsPlaying { 71 | c.Status.CurrentTime = seek + (timeDiff * rate) 72 | } else { 73 | c.Status.CurrentTime = seek 74 | } 75 | c.Status.PlaybackRate = rate 76 | c.Status.LastUpdate = time.Now() 77 | return c.Status 78 | } 79 | 80 | func (c *Current) SetSeek(seek, timeDiff float64) Status { 81 | if c.Movie.IsLive { 82 | return c.setLiveStatus() 83 | } 84 | if c.Status.IsPlaying { 85 | c.Status.CurrentTime = seek + (timeDiff * c.Status.PlaybackRate) 86 | } else { 87 | c.Status.CurrentTime = seek 88 | } 89 | c.Status.LastUpdate = time.Now() 90 | return c.Status 91 | } 92 | -------------------------------------------------------------------------------- /internal/model/oauth2.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type UserProvider struct { 8 | Provider string `gorm:"primarykey;type:varchar(32);uniqueIndex:idx_provider_user_id"` 9 | ProviderUserID string `gorm:"primarykey;type:varchar(64)"` 10 | CreatedAt time.Time 11 | UpdatedAt time.Time 12 | UserID string `gorm:"not null;type:char(32);uniqueIndex:idx_provider_user_id"` 13 | } 14 | -------------------------------------------------------------------------------- /internal/model/setting.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | type SettingType string 6 | 7 | const ( 8 | SettingTypeBool SettingType = "bool" 9 | SettingTypeInt64 SettingType = "int64" 10 | SettingTypeFloat64 SettingType = "float64" 11 | SettingTypeString SettingType = "string" 12 | ) 13 | 14 | type SettingGroup = string 15 | 16 | const ( 17 | SettingGroupRoom SettingGroup = "room" 18 | SettingGroupUser SettingGroup = "user" 19 | SettingGroupProxy SettingGroup = "proxy" 20 | SettingGroupRtmp SettingGroup = "rtmp" 21 | SettingGroupDatabase SettingGroup = "database" 22 | SettingGroupServer SettingGroup = "server" 23 | SettingGroupOauth2 SettingGroup = "oauth2" 24 | SettingGroupEmail SettingGroup = "email" 25 | ) 26 | 27 | type Setting struct { 28 | Name string `gorm:"primaryKey;type:varchar(256)"` 29 | UpdatedAt time.Time 30 | Value string `gorm:"not null;type:text"` 31 | Type SettingType `gorm:"not null;default:string"` 32 | Group SettingGroup `gorm:"not null"` 33 | } 34 | -------------------------------------------------------------------------------- /internal/model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "math/rand/v2" 6 | "time" 7 | 8 | "github.com/synctv-org/synctv/utils" 9 | "github.com/zijiren233/stream" 10 | "golang.org/x/crypto/bcrypt" 11 | "gorm.io/gorm" 12 | ) 13 | 14 | type Role uint8 15 | 16 | const ( 17 | RoleBanned Role = 1 18 | RolePending Role = 2 19 | RoleUser Role = 3 20 | RoleAdmin Role = 4 21 | RoleRoot Role = 5 22 | ) 23 | 24 | func (r Role) String() string { 25 | switch r { 26 | case RoleBanned: 27 | return "banned" 28 | case RolePending: 29 | return "pending" 30 | case RoleUser: 31 | return "user" 32 | case RoleAdmin: 33 | return "admin" 34 | case RoleRoot: 35 | return "root" 36 | default: 37 | return "unknown" 38 | } 39 | } 40 | 41 | type User struct { 42 | ID string `gorm:"primaryKey;type:char(32)" json:"id"` 43 | CreatedAt time.Time 44 | UpdatedAt time.Time 45 | Username string `gorm:"not null;uniqueIndex;type:varchar(32)"` 46 | Email EmptyNullString `gorm:"type:varchar(64);uniqueIndex:,where:email IS NOT NULL"` 47 | HashedPassword []byte `gorm:"not null"` 48 | BilibiliVendor *BilibiliVendor `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` 49 | Movies []*Movie `gorm:"foreignKey:CreatorID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"` 50 | UserProviders []*UserProvider `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` 51 | RoomMembers []*RoomMember `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` 52 | Rooms []*Room `gorm:"foreignKey:CreatorID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` 53 | AlistVendor []*AlistVendor `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` 54 | EmbyVendor []*EmbyVendor `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` 55 | Role Role `gorm:"not null;default:2"` 56 | RegisteredByProvider bool `gorm:"not null;default:false"` 57 | RegisteredByEmail bool `gorm:"not null;default:false"` 58 | autoAddUsernameSuffix bool 59 | } 60 | 61 | func (u *User) EnableAutoAddUsernameSuffix() { 62 | u.autoAddUsernameSuffix = true 63 | } 64 | 65 | func (u *User) DisableAutoAddUsernameSuffix() { 66 | u.autoAddUsernameSuffix = false 67 | } 68 | 69 | func (u *User) CheckPassword(password string) bool { 70 | return bcrypt.CompareHashAndPassword(u.HashedPassword, stream.StringToBytes(password)) == nil 71 | } 72 | 73 | func (u *User) BeforeCreate(tx *gorm.DB) error { 74 | if u.autoAddUsernameSuffix { 75 | var existingUser User 76 | err := tx.Select("username").Where("username = ?", u.Username).First(&existingUser).Error 77 | if err == nil { 78 | u.Username = fmt.Sprintf("%s#%d", u.Username, rand.IntN(9999)) 79 | } 80 | } 81 | if u.ID == "" { 82 | u.ID = utils.SortUUID() 83 | } 84 | return nil 85 | } 86 | 87 | func (u *User) IsRoot() bool { 88 | return u.Role == RoleRoot 89 | } 90 | 91 | func (u *User) IsAdmin() bool { 92 | return u.Role == RoleAdmin || u.IsRoot() 93 | } 94 | 95 | func (u *User) IsUser() bool { 96 | return u.Role == RoleUser || u.IsAdmin() 97 | } 98 | 99 | func (u *User) IsPending() bool { 100 | return u.Role == RolePending 101 | } 102 | 103 | func (u *User) IsBanned() bool { 104 | return u.Role == RoleBanned 105 | } 106 | -------------------------------------------------------------------------------- /internal/op/client.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "io" 5 | "sync" 6 | "sync/atomic" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | "github.com/gorilla/websocket" 11 | "github.com/synctv-org/synctv/internal/model" 12 | pb "github.com/synctv-org/synctv/proto/message" 13 | ) 14 | 15 | type Client struct { 16 | u *User 17 | r *Room 18 | h *Hub 19 | c chan Message 20 | conn *websocket.Conn 21 | connID string 22 | wg sync.WaitGroup 23 | timeOut time.Duration 24 | closed uint32 25 | rtcJoined atomic.Bool 26 | } 27 | 28 | func newClient(user *User, room *Room, h *Hub, conn *websocket.Conn) *Client { 29 | return &Client{ 30 | connID: uuid.New().String(), 31 | r: room, 32 | u: user, 33 | h: h, 34 | c: make(chan Message, 128), 35 | conn: conn, 36 | timeOut: 10 * time.Second, 37 | } 38 | } 39 | 40 | func (c *Client) ConnID() string { 41 | return c.connID 42 | } 43 | 44 | func (c *Client) RTCJoined() bool { 45 | return c.rtcJoined.Load() 46 | } 47 | 48 | func (c *Client) SetRTCJoined(joined bool) { 49 | c.rtcJoined.Store(joined) 50 | } 51 | 52 | func (c *Client) User() *User { 53 | return c.u 54 | } 55 | 56 | func (c *Client) Room() *Room { 57 | return c.r 58 | } 59 | 60 | func (c *Client) Broadcast(msg Message, conf ...BroadcastConf) error { 61 | return c.h.Broadcast(msg, conf...) 62 | } 63 | 64 | func (c *Client) SendChatMessage(message string) error { 65 | if !c.u.HasRoomPermission(c.r, model.PermissionSendChatMessage) { 66 | return model.ErrNoPermission 67 | } 68 | return c.Broadcast(&pb.Message{ 69 | Type: pb.MessageType_CHAT, 70 | Timestamp: time.Now().UnixMilli(), 71 | Sender: &pb.Sender{ 72 | UserId: c.u.ID, 73 | Username: c.u.Username, 74 | }, 75 | Payload: &pb.Message_ChatContent{ 76 | ChatContent: message, 77 | }, 78 | }) 79 | } 80 | 81 | func (c *Client) Send(msg Message) error { 82 | c.wg.Add(1) 83 | defer c.wg.Done() 84 | if c.Closed() { 85 | return ErrAlreadyClosed 86 | } 87 | c.c <- msg 88 | return nil 89 | } 90 | 91 | func (c *Client) Close() error { 92 | if !atomic.CompareAndSwapUint32(&c.closed, 0, 1) { 93 | return ErrAlreadyClosed 94 | } 95 | c.wg.Wait() 96 | close(c.c) 97 | return nil 98 | } 99 | 100 | func (c *Client) Closed() bool { 101 | return atomic.LoadUint32(&c.closed) == 1 102 | } 103 | 104 | func (c *Client) GetReadChan() <-chan Message { 105 | return c.c 106 | } 107 | 108 | func (c *Client) NextWriter(messageType int) (io.WriteCloser, error) { 109 | return c.conn.NextWriter(messageType) 110 | } 111 | 112 | func (c *Client) NextReader() (int, io.Reader, error) { 113 | return c.conn.NextReader() 114 | } 115 | 116 | func (c *Client) SetStatus(playing bool, seek, rate, timeDiff float64) error { 117 | status, err := c.u.SetRoomCurrentStatus(c.r, playing, seek, rate, timeDiff) 118 | if err != nil { 119 | return err 120 | } 121 | return c.Broadcast(&pb.Message{ 122 | Type: pb.MessageType_STATUS, 123 | Sender: &pb.Sender{ 124 | Username: c.User().Username, 125 | UserId: c.User().ID, 126 | }, 127 | Payload: &pb.Message_PlaybackStatus{ 128 | PlaybackStatus: &pb.Status{ 129 | IsPlaying: status.IsPlaying, 130 | CurrentTime: status.CurrentTime, 131 | PlaybackRate: status.PlaybackRate, 132 | }, 133 | }, 134 | }, WithIgnoreConnID(c.ConnID())) 135 | } 136 | -------------------------------------------------------------------------------- /internal/op/current.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "sync" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/synctv-org/synctv/internal/db" 8 | "github.com/synctv-org/synctv/internal/model" 9 | ) 10 | 11 | type current struct { 12 | roomID string 13 | current model.Current 14 | lock sync.RWMutex 15 | } 16 | 17 | func newCurrent(roomID string, c *model.Current) *current { 18 | if c == nil { 19 | return ¤t{ 20 | roomID: roomID, 21 | current: model.Current{ 22 | Status: model.NewStatus(), 23 | }, 24 | } 25 | } 26 | return ¤t{ 27 | roomID: roomID, 28 | current: *c, 29 | } 30 | } 31 | 32 | func (c *current) Current() model.Current { 33 | c.lock.RLock() 34 | defer c.lock.RUnlock() 35 | c.current.UpdateStatus() 36 | return c.current 37 | } 38 | 39 | func (c *current) CurrentMovie() model.CurrentMovie { 40 | c.lock.RLock() 41 | defer c.lock.RUnlock() 42 | return c.current.Movie 43 | } 44 | 45 | func (c *current) SetMovie(movie model.CurrentMovie, play bool) { 46 | c.lock.Lock() 47 | defer c.lock.Unlock() 48 | defer func() { 49 | if err := db.SetRoomCurrent(c.roomID, &c.current); err != nil { 50 | log.Errorf("set room current failed: %v", err) 51 | } 52 | }() 53 | 54 | c.current.Movie = movie 55 | c.current.SetSeek(0, 0) 56 | c.current.Status.IsPlaying = play 57 | } 58 | 59 | func (c *current) Status() model.Status { 60 | c.lock.RLock() 61 | defer c.lock.RUnlock() 62 | c.current.UpdateStatus() 63 | return c.current.Status 64 | } 65 | 66 | func (c *current) SetStatus(playing bool, seek, rate, timeDiff float64) *model.Status { 67 | c.lock.Lock() 68 | defer c.lock.Unlock() 69 | defer func() { 70 | if err := db.SetRoomCurrent(c.roomID, &c.current); err != nil { 71 | log.Errorf("set room current failed: %v", err) 72 | } 73 | }() 74 | 75 | s := c.current.SetStatus(playing, seek, rate, timeDiff) 76 | return &s 77 | } 78 | 79 | func (c *current) SetSeekRate(seek, rate, timeDiff float64) *model.Status { 80 | c.lock.Lock() 81 | defer c.lock.Unlock() 82 | defer func() { 83 | if err := db.SetRoomCurrent(c.roomID, &c.current); err != nil { 84 | log.Errorf("set room current failed: %v", err) 85 | } 86 | }() 87 | 88 | s := c.current.SetSeekRate(seek, rate, timeDiff) 89 | return &s 90 | } 91 | -------------------------------------------------------------------------------- /internal/op/message.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/gorilla/websocket" 7 | ) 8 | 9 | type Message interface { 10 | MessageType() int 11 | String() string 12 | Encode(w io.Writer) error 13 | } 14 | 15 | type PingMessage struct{} 16 | 17 | func (pm *PingMessage) MessageType() int { 18 | return websocket.PingMessage 19 | } 20 | 21 | func (pm *PingMessage) String() string { 22 | return "Ping" 23 | } 24 | 25 | func (pm *PingMessage) Encode(_ io.Writer) error { 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/op/op.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "time" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/zijiren233/gencontainer/synccache" 8 | ) 9 | 10 | func Init(_ int) error { 11 | roomCache = synccache.NewSyncCache( 12 | time.Minute*5, 13 | synccache.WithDeletedCallback[string](func(v *Room) { 14 | log.WithFields(log.Fields{ 15 | "rid": v.ID, 16 | "rn": v.Name, 17 | }).Debugf("room ttl expired, closing") 18 | v.close() 19 | }), 20 | ) 21 | userCache = synccache.NewSyncCache[string, *User](time.Minute * 5) 22 | 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /internal/provider/aggregation.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | type AggregationProviderInterface interface { 4 | ExtractProvider(provider OAuth2Provider) (Interface, error) 5 | Provider() OAuth2Provider 6 | Providers() []OAuth2Provider 7 | } 8 | 9 | func ExtractProviders( 10 | p AggregationProviderInterface, 11 | providers ...OAuth2Provider, 12 | ) ([]Interface, error) { 13 | if len(providers) == 0 { 14 | providers = p.Providers() 15 | } 16 | pi := make([]Interface, len(providers)) 17 | for i, provider := range providers { 18 | pi2, err := p.ExtractProvider(provider) 19 | if err != nil { 20 | return nil, err 21 | } 22 | pi[i] = pi2 23 | } 24 | return pi, nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/provider/aggregations/aggregations.go: -------------------------------------------------------------------------------- 1 | package aggregations 2 | 3 | import ( 4 | "github.com/synctv-org/synctv/internal/provider" 5 | ) 6 | 7 | var allAggregation []provider.AggregationProviderInterface 8 | 9 | func addAggregation(ps ...provider.AggregationProviderInterface) { 10 | allAggregation = append(allAggregation, ps...) 11 | } 12 | 13 | func AllAggregation() []provider.AggregationProviderInterface { 14 | return allAggregation 15 | } 16 | -------------------------------------------------------------------------------- /internal/provider/plugins/client.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/synctv-org/synctv/internal/provider" 7 | providerpb "github.com/synctv-org/synctv/proto/provider" 8 | ) 9 | 10 | type GRPCClient struct{ client providerpb.Oauth2PluginClient } 11 | 12 | var _ provider.Interface = (*GRPCClient)(nil) 13 | 14 | func (c *GRPCClient) Init(o provider.Oauth2Option) { 15 | opt := providerpb.InitReq{ 16 | ClientId: o.ClientID, 17 | ClientSecret: o.ClientSecret, 18 | RedirectUrl: o.RedirectURL, 19 | } 20 | _, _ = c.client.Init(context.Background(), &opt) 21 | } 22 | 23 | func (c *GRPCClient) Provider() provider.OAuth2Provider { 24 | resp, err := c.client.Provider(context.Background(), &providerpb.Enpty{}) 25 | if err != nil { 26 | return "" 27 | } 28 | return resp.GetName() 29 | } 30 | 31 | func (c *GRPCClient) NewAuthURL(ctx context.Context, state string) (string, error) { 32 | resp, err := c.client.NewAuthURL(ctx, &providerpb.NewAuthURLReq{State: state}) 33 | if err != nil { 34 | return "", err 35 | } 36 | return resp.GetUrl(), nil 37 | } 38 | 39 | func (c *GRPCClient) GetUserInfo(ctx context.Context, code string) (*provider.UserInfo, error) { 40 | resp, err := c.client.GetUserInfo(ctx, &providerpb.GetUserInfoReq{ 41 | Code: code, 42 | }) 43 | if err != nil { 44 | return nil, err 45 | } 46 | return &provider.UserInfo{ 47 | Username: resp.GetUsername(), 48 | ProviderUserID: resp.GetProviderUserId(), 49 | }, nil 50 | } 51 | -------------------------------------------------------------------------------- /internal/provider/plugins/example/.gitignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/example_* 3 | !/.gitignore -------------------------------------------------------------------------------- /internal/provider/plugins/example/example_authing/example_authing.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | 10 | plugin "github.com/hashicorp/go-plugin" 11 | "github.com/synctv-org/synctv/internal/provider" 12 | "github.com/synctv-org/synctv/internal/provider/plugins" 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | // Linux/Mac/Windows: 17 | // CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build 18 | // ./internal/provider/plugins/example/example_authing/example_authing.go CGO_ENABLED=0 GOOS=dawin 19 | // GOARCH=amd64 go build ./internal/provider/plugins/example/example_authing/example_authing.go 20 | // CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build 21 | // ./internal/provider/plugins/example/example_authing/example_authing.go 22 | // 23 | // mv gitee {data-dir}/plugins/oauth2/authing 24 | // 25 | // Authing:https://console.authing.cn/ 26 | // 27 | // config.yaml: 28 | // 29 | // oauth2_plugins: 30 | // - plugin_file: plugins/oauth2/authing 31 | // args: ["认证配置-认证地址(只需要你自定义的那个部分)"] 32 | type AuthingProvider struct { 33 | config oauth2.Config 34 | } 35 | 36 | func newAuthingProvider(authURL string) provider.Interface { 37 | return &AuthingProvider{ 38 | config: oauth2.Config{ 39 | Scopes: []string{"profile"}, 40 | Endpoint: oauth2.Endpoint{ 41 | AuthURL: fmt.Sprintf( 42 | "https://%s.authing.cn/oauth/auth", 43 | authURL, 44 | ), // 授权码(authorization_code)获取接口 45 | TokenURL: fmt.Sprintf("https://%s.authing.cn/oauth/token", authURL), // Token端点 46 | }, 47 | }, 48 | } 49 | } 50 | 51 | func (p *AuthingProvider) Init(c provider.Oauth2Option) { 52 | p.config.ClientID = c.ClientID 53 | p.config.ClientSecret = c.ClientSecret 54 | p.config.RedirectURL = c.RedirectURL 55 | } 56 | 57 | func (p *AuthingProvider) Provider() provider.OAuth2Provider { 58 | return "authing" // 插件名 59 | } 60 | 61 | func (p *AuthingProvider) NewAuthURL(_ context.Context, state string) (string, error) { 62 | return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline), nil 63 | } 64 | 65 | func (p *AuthingProvider) GetUserInfo( 66 | ctx context.Context, 67 | code string, 68 | ) (*provider.UserInfo, error) { 69 | tk, err := p.config.Exchange(ctx, code) 70 | if err != nil { 71 | return nil, err 72 | } 73 | client := p.config.Client(ctx, tk) 74 | req, err := http.NewRequestWithContext( 75 | ctx, 76 | http.MethodGet, 77 | "https://core.authing.cn/oauth/me", 78 | nil, 79 | ) // 身份端点 80 | if err != nil { 81 | return nil, err 82 | } 83 | resp, err := client.Do(req) 84 | if err != nil { 85 | return nil, err 86 | } 87 | defer resp.Body.Close() 88 | ui := AuthingUserInfo{} 89 | err = json.NewDecoder(resp.Body).Decode(&ui) 90 | if err != nil { 91 | return nil, err 92 | } 93 | return &provider.UserInfo{ 94 | Username: ui.Name, 95 | ProviderUserID: ui.UnionID, 96 | }, nil 97 | } 98 | 99 | type AuthingUserInfo struct { 100 | UnionID string `json:"sub"` // Authing用户ID 101 | Name string `json:"name"` // Authing用户名 102 | } 103 | 104 | func main() { 105 | args := os.Args 106 | pluginMap := map[string]plugin.Plugin{ 107 | "Provider": &plugins.ProviderPlugin{Impl: newAuthingProvider(args[1])}, 108 | } 109 | plugin.Serve(&plugin.ServeConfig{ 110 | HandshakeConfig: plugins.HandshakeConfig, 111 | Plugins: pluginMap, 112 | GRPCServer: plugin.DefaultGRPCServer, 113 | }) 114 | } 115 | -------------------------------------------------------------------------------- /internal/provider/plugins/example/example_gitee/example_gitee.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "strconv" 8 | 9 | plugin "github.com/hashicorp/go-plugin" 10 | "github.com/synctv-org/synctv/internal/provider" 11 | "github.com/synctv-org/synctv/internal/provider/plugins" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | // go build -o gitee ./internal/provider/plugins/gitee.go 16 | // 17 | // mv gitee {data-dir}/plugins/oauth2/gitee 18 | // 19 | // config.yaml: 20 | // 21 | // oauth2_plugins: 22 | // - plugin_file: plugins/oauth2/gitee 23 | type GiteeProvider struct { 24 | config oauth2.Config 25 | } 26 | 27 | func newGiteeProvider() provider.Interface { 28 | return &GiteeProvider{ 29 | config: oauth2.Config{ 30 | Scopes: []string{"user_info"}, 31 | Endpoint: oauth2.Endpoint{ 32 | AuthURL: "https://gitee.com/oauth/authorize", 33 | TokenURL: "https://gitee.com/oauth/token", 34 | }, 35 | }, 36 | } 37 | } 38 | 39 | func (p *GiteeProvider) Init(c provider.Oauth2Option) { 40 | p.config.ClientID = c.ClientID 41 | p.config.ClientSecret = c.ClientSecret 42 | p.config.RedirectURL = c.RedirectURL 43 | } 44 | 45 | func (p *GiteeProvider) Provider() provider.OAuth2Provider { 46 | return "gitee" 47 | } 48 | 49 | func (p *GiteeProvider) NewAuthURL(_ context.Context, state string) (string, error) { 50 | return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline), nil 51 | } 52 | 53 | func (p *GiteeProvider) GetToken(ctx context.Context, code string) (*oauth2.Token, error) { 54 | return p.config.Exchange(ctx, code) 55 | } 56 | 57 | func (p *GiteeProvider) RefreshToken(ctx context.Context, tk string) (*oauth2.Token, error) { 58 | return p.config.TokenSource(ctx, &oauth2.Token{RefreshToken: tk}).Token() 59 | } 60 | 61 | func (p *GiteeProvider) GetUserInfo(ctx context.Context, code string) (*provider.UserInfo, error) { 62 | tk, err := p.GetToken(ctx, code) 63 | if err != nil { 64 | return nil, err 65 | } 66 | client := p.config.Client(ctx, tk) 67 | req, err := http.NewRequestWithContext( 68 | ctx, 69 | http.MethodGet, 70 | "https://gitee.com/api/v5/user", 71 | nil, 72 | ) 73 | if err != nil { 74 | return nil, err 75 | } 76 | resp, err := client.Do(req) 77 | if err != nil { 78 | return nil, err 79 | } 80 | defer resp.Body.Close() 81 | ui := giteeUserInfo{} 82 | err = json.NewDecoder(resp.Body).Decode(&ui) 83 | if err != nil { 84 | return nil, err 85 | } 86 | return &provider.UserInfo{ 87 | Username: ui.Login, 88 | ProviderUserID: strconv.FormatUint(ui.ID, 10), 89 | }, nil 90 | } 91 | 92 | type giteeUserInfo struct { 93 | ID uint64 `json:"id"` 94 | Login string `json:"login"` 95 | } 96 | 97 | func main() { 98 | pluginMap := map[string]plugin.Plugin{ 99 | "Provider": &plugins.ProviderPlugin{Impl: newGiteeProvider()}, 100 | } 101 | plugin.Serve(&plugin.ServeConfig{ 102 | HandshakeConfig: plugins.HandshakeConfig, 103 | Plugins: pluginMap, 104 | GRPCServer: plugin.DefaultGRPCServer, 105 | }) 106 | } 107 | -------------------------------------------------------------------------------- /internal/provider/plugins/plugin.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os/exec" 7 | 8 | "github.com/hashicorp/go-hclog" 9 | "github.com/hashicorp/go-plugin" 10 | "github.com/synctv-org/synctv/internal/provider" 11 | "github.com/synctv-org/synctv/internal/provider/providers" 12 | "github.com/synctv-org/synctv/internal/sysnotify" 13 | providerpb "github.com/synctv-org/synctv/proto/provider" 14 | "google.golang.org/grpc" 15 | ) 16 | 17 | func InitProviderPlugins(name string, arg []string, logger hclog.Logger) error { 18 | client := NewProviderPlugin(name, arg, logger) 19 | err := sysnotify.RegisterSysNotifyTask( 20 | 0, 21 | sysnotify.NewSysNotifyTask("plugin", sysnotify.NotifyTypeEXIT, func() error { 22 | client.Kill() 23 | return nil 24 | }), 25 | ) 26 | if err != nil { 27 | return err 28 | } 29 | c, err := client.Client() 30 | if err != nil { 31 | return err 32 | } 33 | i, err := c.Dispense("Provider") 34 | if err != nil { 35 | return err 36 | } 37 | provider, ok := i.(provider.Interface) 38 | if !ok { 39 | return fmt.Errorf("%s not implement ProviderInterface", name) 40 | } 41 | providers.RegisterProvider(provider) 42 | return nil 43 | } 44 | 45 | var HandshakeConfig = plugin.HandshakeConfig{ 46 | ProtocolVersion: 1, 47 | MagicCookieKey: "BASIC_PLUGIN", 48 | MagicCookieValue: "hello", 49 | } 50 | 51 | var pluginMap = map[string]plugin.Plugin{ 52 | "Provider": &ProviderPlugin{}, 53 | } 54 | 55 | type ProviderPlugin struct { 56 | plugin.Plugin 57 | Impl provider.Interface 58 | } 59 | 60 | func (p *ProviderPlugin) GRPCServer(_ *plugin.GRPCBroker, s *grpc.Server) error { 61 | providerpb.RegisterOauth2PluginServer(s, &GRPCServer{Impl: p.Impl}) 62 | return nil 63 | } 64 | 65 | func (p *ProviderPlugin) GRPCClient( 66 | _ context.Context, 67 | _ *plugin.GRPCBroker, 68 | c *grpc.ClientConn, 69 | ) (any, error) { 70 | return &GRPCClient{client: providerpb.NewOauth2PluginClient(c)}, nil 71 | } 72 | 73 | func NewProviderPlugin(name string, arg []string, logger hclog.Logger) *plugin.Client { 74 | return plugin.NewClient(&plugin.ClientConfig{ 75 | HandshakeConfig: HandshakeConfig, 76 | Plugins: pluginMap, 77 | Cmd: exec.Command(name, arg...), 78 | AllowedProtocols: []plugin.Protocol{ 79 | plugin.ProtocolGRPC, 80 | }, 81 | Logger: logger, 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /internal/provider/plugins/server.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/synctv-org/synctv/internal/provider" 7 | providerpb "github.com/synctv-org/synctv/proto/provider" 8 | ) 9 | 10 | type GRPCServer struct { 11 | providerpb.UnimplementedOauth2PluginServer 12 | Impl provider.Interface 13 | } 14 | 15 | func (s *GRPCServer) Init(_ context.Context, req *providerpb.InitReq) (*providerpb.Enpty, error) { 16 | opt := provider.Oauth2Option{ 17 | ClientID: req.GetClientId(), 18 | ClientSecret: req.GetClientSecret(), 19 | RedirectURL: req.GetRedirectUrl(), 20 | } 21 | s.Impl.Init(opt) 22 | return &providerpb.Enpty{}, nil 23 | } 24 | 25 | func (s *GRPCServer) Provider( 26 | _ context.Context, 27 | _ *providerpb.Enpty, 28 | ) (*providerpb.ProviderResp, error) { 29 | return &providerpb.ProviderResp{Name: s.Impl.Provider()}, nil 30 | } 31 | 32 | func (s *GRPCServer) NewAuthURL( 33 | ctx context.Context, 34 | req *providerpb.NewAuthURLReq, 35 | ) (*providerpb.NewAuthURLResp, error) { 36 | s2, err := s.Impl.NewAuthURL(ctx, req.GetState()) 37 | if err != nil { 38 | return nil, err 39 | } 40 | return &providerpb.NewAuthURLResp{Url: s2}, nil 41 | } 42 | 43 | func (s *GRPCServer) GetUserInfo( 44 | ctx context.Context, 45 | req *providerpb.GetUserInfoReq, 46 | ) (*providerpb.GetUserInfoResp, error) { 47 | userInfo, err := s.Impl.GetUserInfo(ctx, req.GetCode()) 48 | if err != nil { 49 | return nil, err 50 | } 51 | resp := &providerpb.GetUserInfoResp{ 52 | Username: userInfo.Username, 53 | ProviderUserId: userInfo.ProviderUserID, 54 | } 55 | return resp, nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/provider/provider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type OAuth2Provider = string 8 | 9 | type UserInfo struct { 10 | Username string 11 | ProviderUserID string 12 | } 13 | 14 | type Oauth2Option struct { 15 | ClientID string 16 | ClientSecret string 17 | RedirectURL string 18 | } 19 | 20 | type Provider interface { 21 | Init(opt Oauth2Option) 22 | Provider() OAuth2Provider 23 | } 24 | 25 | type RegistSetting interface { 26 | RegistSetting(group string) 27 | } 28 | 29 | type Interface interface { 30 | Provider 31 | NewAuthURL(ctx context.Context, state string) (string, error) 32 | GetUserInfo(ctx context.Context, code string) (*UserInfo, error) 33 | } 34 | -------------------------------------------------------------------------------- /internal/provider/providers/baidu-netdisk.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | 9 | json "github.com/json-iterator/go" 10 | "github.com/synctv-org/synctv/internal/provider" 11 | "golang.org/x/oauth2" 12 | ) 13 | 14 | // https://pan.baidu.com/union/apply 15 | type BaiduNetDiskProvider struct { 16 | config oauth2.Config 17 | } 18 | 19 | func newBaiduNetDiskProvider() provider.Interface { 20 | return &BaiduNetDiskProvider{ 21 | config: oauth2.Config{ 22 | Scopes: []string{"basic", "netdisk"}, 23 | Endpoint: oauth2.Endpoint{ 24 | AuthURL: "https://openapi.baidu.com/oauth/2.0/authorize", 25 | TokenURL: "https://openapi.baidu.com/oauth/2.0/token", 26 | }, 27 | }, 28 | } 29 | } 30 | 31 | func (p *BaiduNetDiskProvider) Init(c provider.Oauth2Option) { 32 | p.config.ClientID = c.ClientID 33 | p.config.ClientSecret = c.ClientSecret 34 | p.config.RedirectURL = c.RedirectURL 35 | } 36 | 37 | func (p *BaiduNetDiskProvider) Provider() provider.OAuth2Provider { 38 | return "baidu-netdisk" 39 | } 40 | 41 | func (p *BaiduNetDiskProvider) NewAuthURL(_ context.Context, state string) (string, error) { 42 | return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline), nil 43 | } 44 | 45 | func (p *BaiduNetDiskProvider) GetToken(ctx context.Context, code string) (*oauth2.Token, error) { 46 | return p.config.Exchange(ctx, code) 47 | } 48 | 49 | func (p *BaiduNetDiskProvider) RefreshToken(ctx context.Context, tk string) (*oauth2.Token, error) { 50 | return p.config.TokenSource(ctx, &oauth2.Token{RefreshToken: tk}).Token() 51 | } 52 | 53 | func (p *BaiduNetDiskProvider) GetUserInfo( 54 | ctx context.Context, 55 | code string, 56 | ) (*provider.UserInfo, error) { 57 | tk, err := p.GetToken(ctx, code) 58 | if err != nil { 59 | return nil, err 60 | } 61 | client := p.config.Client(ctx, tk) 62 | req, err := http.NewRequestWithContext( 63 | ctx, 64 | http.MethodGet, 65 | "https://pan.baidu.com/rest/2.0/xpan/nas?method=uinfo&access_token="+tk.AccessToken, 66 | nil, 67 | ) 68 | if err != nil { 69 | return nil, err 70 | } 71 | resp, err := client.Do(req) 72 | if err != nil { 73 | return nil, err 74 | } 75 | defer resp.Body.Close() 76 | ui := baiduNetDiskProviderUserInfo{} 77 | err = json.NewDecoder(resp.Body).Decode(&ui) 78 | if err != nil { 79 | return nil, err 80 | } 81 | if ui.Errno != 0 { 82 | return nil, fmt.Errorf("baidu oauth2 get user info error: %s", ui.Errmsg) 83 | } 84 | return &provider.UserInfo{ 85 | Username: ui.BaiduName, 86 | ProviderUserID: strconv.FormatUint(ui.Uk, 10), 87 | }, nil 88 | } 89 | 90 | //nolint:tagliatelle 91 | type baiduNetDiskProviderUserInfo struct { 92 | BaiduName string `json:"baidu_name"` 93 | Errmsg string `json:"errmsg"` 94 | Errno int `json:"errno"` 95 | Uk uint64 `json:"uk"` 96 | } 97 | 98 | func init() { 99 | RegisterProvider(newBaiduNetDiskProvider()) 100 | } 101 | -------------------------------------------------------------------------------- /internal/provider/providers/baidu.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | json "github.com/json-iterator/go" 8 | "github.com/synctv-org/synctv/internal/provider" 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | // https://pan.baidu.com/union/apply 13 | type BaiduProvider struct { 14 | config oauth2.Config 15 | } 16 | 17 | func newBaiduProvider() provider.Interface { 18 | return &BaiduProvider{ 19 | config: oauth2.Config{ 20 | Scopes: []string{"basic"}, 21 | Endpoint: oauth2.Endpoint{ 22 | AuthURL: "https://openapi.baidu.com/oauth/2.0/authorize", 23 | TokenURL: "https://openapi.baidu.com/oauth/2.0/token", 24 | }, 25 | }, 26 | } 27 | } 28 | 29 | func (p *BaiduProvider) Init(c provider.Oauth2Option) { 30 | p.config.ClientID = c.ClientID 31 | p.config.ClientSecret = c.ClientSecret 32 | p.config.RedirectURL = c.RedirectURL 33 | } 34 | 35 | func (p *BaiduProvider) Provider() provider.OAuth2Provider { 36 | return "baidu" 37 | } 38 | 39 | func (p *BaiduProvider) NewAuthURL(_ context.Context, state string) (string, error) { 40 | return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline), nil 41 | } 42 | 43 | func (p *BaiduProvider) GetToken(ctx context.Context, code string) (*oauth2.Token, error) { 44 | return p.config.Exchange(ctx, code) 45 | } 46 | 47 | func (p *BaiduProvider) RefreshToken(ctx context.Context, tk string) (*oauth2.Token, error) { 48 | return p.config.TokenSource(ctx, &oauth2.Token{RefreshToken: tk}).Token() 49 | } 50 | 51 | func (p *BaiduProvider) GetUserInfo(ctx context.Context, code string) (*provider.UserInfo, error) { 52 | tk, err := p.GetToken(ctx, code) 53 | if err != nil { 54 | return nil, err 55 | } 56 | client := p.config.Client(ctx, tk) 57 | req, err := http.NewRequestWithContext( 58 | ctx, 59 | http.MethodGet, 60 | "https://openapi.baidu.com/rest/2.0/passport/users/getLoggedInUser?access_token="+tk.AccessToken, 61 | nil, 62 | ) 63 | if err != nil { 64 | return nil, err 65 | } 66 | resp, err := client.Do(req) 67 | if err != nil { 68 | return nil, err 69 | } 70 | defer resp.Body.Close() 71 | ui := baiduProviderUserInfo{} 72 | err = json.NewDecoder(resp.Body).Decode(&ui) 73 | if err != nil { 74 | return nil, err 75 | } 76 | return &provider.UserInfo{ 77 | Username: ui.Uname, 78 | ProviderUserID: ui.Openid, 79 | }, nil 80 | } 81 | 82 | type baiduProviderUserInfo struct { 83 | Uname string `json:"uname"` 84 | Openid string `json:"openid"` 85 | } 86 | 87 | func init() { 88 | RegisterProvider(newBaiduProvider()) 89 | } 90 | -------------------------------------------------------------------------------- /internal/provider/providers/casdoor.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | 10 | "github.com/synctv-org/synctv/internal/provider" 11 | "github.com/synctv-org/synctv/internal/settings" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | // https://door.casdoor.com/.well-known/openid-configuration 16 | type casdoorProvider struct { 17 | config oauth2.Config 18 | endpoint string 19 | } 20 | 21 | func newCasdoorProvider() provider.Interface { 22 | return &casdoorProvider{ 23 | config: oauth2.Config{ 24 | Scopes: []string{"profile", "email", "phone", "name", "openid"}, 25 | }, 26 | } 27 | } 28 | 29 | func (p *casdoorProvider) Init(opt provider.Oauth2Option) { 30 | p.config.ClientID = opt.ClientID 31 | p.config.ClientSecret = opt.ClientSecret 32 | p.config.RedirectURL = opt.RedirectURL 33 | } 34 | 35 | func (p *casdoorProvider) NewAuthURL(_ context.Context, state string) (string, error) { 36 | return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline), nil 37 | } 38 | 39 | func (p *casdoorProvider) GetToken(ctx context.Context, code string) (*oauth2.Token, error) { 40 | return p.config.Exchange(ctx, code) 41 | } 42 | 43 | func (p *casdoorProvider) RefreshToken(ctx context.Context, token string) (*oauth2.Token, error) { 44 | return p.config.TokenSource(ctx, &oauth2.Token{RefreshToken: token}).Token() 45 | } 46 | 47 | func (p *casdoorProvider) GetUserInfo( 48 | ctx context.Context, 49 | code string, 50 | ) (*provider.UserInfo, error) { 51 | tk, err := p.GetToken(ctx, code) 52 | if err != nil { 53 | return nil, err 54 | } 55 | client := p.config.Client(ctx, tk) 56 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.endpoint+"/api/userinfo", nil) 57 | if err != nil { 58 | return nil, err 59 | } 60 | resp, err := client.Do(req) 61 | if err != nil { 62 | return nil, err 63 | } 64 | defer resp.Body.Close() 65 | var ui casdoorUserInfo 66 | err = json.NewDecoder(resp.Body).Decode(&ui) 67 | if err != nil { 68 | return nil, err 69 | } 70 | un := ui.PreferredUsername 71 | if un == "" { 72 | un = ui.Name 73 | } 74 | return &provider.UserInfo{ 75 | ProviderUserID: ui.Sub, 76 | Username: un, 77 | }, nil 78 | } 79 | 80 | type casdoorUserInfo struct { 81 | Sub string `json:"sub"` 82 | PreferredUsername string `json:"preferred_username"` 83 | Name string `json:"name"` 84 | Email string `json:"email"` 85 | Phone string `json:"phone"` 86 | } 87 | 88 | func (p *casdoorProvider) RegistSetting(group string) { 89 | settings.NewStringSetting( 90 | group+"_endpoint", "", group, 91 | settings.WithAfterInitString(func(_ settings.StringSetting, s string) { 92 | p.endpoint = s 93 | p.config.Endpoint = oauth2.Endpoint{ 94 | AuthURL: s + "/login/oauth/authorize", 95 | TokenURL: s + "/api/login/oauth/access_token", 96 | } 97 | }), 98 | settings.WithBeforeSetString(func(_ settings.StringSetting, s string) (string, error) { 99 | u, err := url.Parse(s) 100 | if err != nil { 101 | return "", err 102 | } 103 | return fmt.Sprintf("%s://%s", u.Scheme, u.Host), nil 104 | }), 105 | settings.WithAfterSetString(func(_ settings.StringSetting, s string) { 106 | p.endpoint = s 107 | p.config.Endpoint = oauth2.Endpoint{ 108 | AuthURL: s + "/login/oauth/authorize", 109 | TokenURL: s + "/api/login/oauth/access_token", 110 | } 111 | }), 112 | ) 113 | } 114 | 115 | func (p *casdoorProvider) Provider() provider.OAuth2Provider { 116 | return "casdoor" 117 | } 118 | 119 | func init() { 120 | RegisterProvider(newCasdoorProvider()) 121 | } 122 | -------------------------------------------------------------------------------- /internal/provider/providers/discord.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | json "github.com/json-iterator/go" 8 | "github.com/synctv-org/synctv/internal/provider" 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | type DiscordProvider struct { 13 | config oauth2.Config 14 | } 15 | 16 | func newDiscordProvider() provider.Interface { 17 | return &DiscordProvider{ 18 | config: oauth2.Config{ 19 | Scopes: []string{"identify"}, 20 | Endpoint: oauth2.Endpoint{ 21 | AuthURL: "https://discord.com/oauth2/authorize", 22 | TokenURL: "https://discord.com/api/oauth2/token", 23 | }, 24 | }, 25 | } 26 | } 27 | 28 | func (p *DiscordProvider) Init(c provider.Oauth2Option) { 29 | p.config.ClientID = c.ClientID 30 | p.config.ClientSecret = c.ClientSecret 31 | p.config.RedirectURL = c.RedirectURL 32 | } 33 | 34 | func (p *DiscordProvider) Provider() provider.OAuth2Provider { 35 | return "discord" 36 | } 37 | 38 | func (p *DiscordProvider) NewAuthURL(_ context.Context, state string) (string, error) { 39 | return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline), nil 40 | } 41 | 42 | func (p *DiscordProvider) GetToken(ctx context.Context, code string) (*oauth2.Token, error) { 43 | return p.config.Exchange(ctx, code) 44 | } 45 | 46 | func (p *DiscordProvider) RefreshToken(ctx context.Context, tk string) (*oauth2.Token, error) { 47 | return p.config.TokenSource(ctx, &oauth2.Token{RefreshToken: tk}).Token() 48 | } 49 | 50 | func (p *DiscordProvider) GetUserInfo( 51 | ctx context.Context, 52 | code string, 53 | ) (*provider.UserInfo, error) { 54 | tk, err := p.config.Exchange(ctx, code) 55 | if err != nil { 56 | return nil, err 57 | } 58 | client := p.config.Client(ctx, tk) 59 | req, err := http.NewRequestWithContext( 60 | ctx, 61 | http.MethodGet, 62 | "https://discord.com/api/v10/oauth2/@me", 63 | nil, 64 | ) 65 | if err != nil { 66 | return nil, err 67 | } 68 | resp, err := client.Do(req) 69 | if err != nil { 70 | return nil, err 71 | } 72 | defer resp.Body.Close() 73 | ui := discordUserInfo{} 74 | err = json.NewDecoder(resp.Body).Decode(&ui) 75 | if err != nil { 76 | return nil, err 77 | } 78 | return &provider.UserInfo{ 79 | Username: ui.Data.Name, 80 | ProviderUserID: ui.Data.ID, 81 | }, nil 82 | } 83 | 84 | type discordUserInfo struct { 85 | Data struct { 86 | ID string `json:"id"` 87 | Name string `json:"username"` 88 | } `json:"user"` 89 | } 90 | 91 | func init() { 92 | RegisterProvider(newDiscordProvider()) 93 | } 94 | -------------------------------------------------------------------------------- /internal/provider/providers/gitee.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strconv" 7 | 8 | json "github.com/json-iterator/go" 9 | "github.com/synctv-org/synctv/internal/provider" 10 | "golang.org/x/oauth2" 11 | ) 12 | 13 | type GiteeProvider struct { 14 | config oauth2.Config 15 | } 16 | 17 | func newGiteeProvider() provider.Interface { 18 | return &GiteeProvider{ 19 | config: oauth2.Config{ 20 | Scopes: []string{"user_info"}, 21 | Endpoint: oauth2.Endpoint{ 22 | AuthURL: "https://gitee.com/oauth/authorize", 23 | TokenURL: "https://gitee.com/oauth/token", 24 | }, 25 | }, 26 | } 27 | } 28 | 29 | func (p *GiteeProvider) Init(c provider.Oauth2Option) { 30 | p.config.ClientID = c.ClientID 31 | p.config.ClientSecret = c.ClientSecret 32 | p.config.RedirectURL = c.RedirectURL 33 | } 34 | 35 | func (p *GiteeProvider) Provider() provider.OAuth2Provider { 36 | return "gitee" 37 | } 38 | 39 | func (p *GiteeProvider) NewAuthURL(_ context.Context, state string) (string, error) { 40 | return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline), nil 41 | } 42 | 43 | func (p *GiteeProvider) GetToken(ctx context.Context, code string) (*oauth2.Token, error) { 44 | return p.config.Exchange(ctx, code) 45 | } 46 | 47 | func (p *GiteeProvider) RefreshToken(ctx context.Context, tk string) (*oauth2.Token, error) { 48 | return p.config.TokenSource(ctx, &oauth2.Token{RefreshToken: tk}).Token() 49 | } 50 | 51 | func (p *GiteeProvider) GetUserInfo(ctx context.Context, code string) (*provider.UserInfo, error) { 52 | tk, err := p.GetToken(ctx, code) 53 | if err != nil { 54 | return nil, err 55 | } 56 | client := p.config.Client(ctx, tk) 57 | req, err := http.NewRequestWithContext( 58 | ctx, 59 | http.MethodGet, 60 | "https://gitee.com/api/v5/user", 61 | nil, 62 | ) 63 | if err != nil { 64 | return nil, err 65 | } 66 | resp, err := client.Do(req) 67 | if err != nil { 68 | return nil, err 69 | } 70 | defer resp.Body.Close() 71 | ui := giteeUserInfo{} 72 | err = json.NewDecoder(resp.Body).Decode(&ui) 73 | if err != nil { 74 | return nil, err 75 | } 76 | return &provider.UserInfo{ 77 | Username: ui.Login, 78 | ProviderUserID: strconv.FormatUint(ui.ID, 10), 79 | }, nil 80 | } 81 | 82 | type giteeUserInfo struct { 83 | Login string `json:"login"` 84 | ID uint64 `json:"id"` 85 | } 86 | 87 | func init() { 88 | RegisterProvider(newGiteeProvider()) 89 | } 90 | -------------------------------------------------------------------------------- /internal/provider/providers/github.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strconv" 7 | 8 | json "github.com/json-iterator/go" 9 | "github.com/synctv-org/synctv/internal/provider" 10 | "golang.org/x/oauth2" 11 | "golang.org/x/oauth2/github" 12 | ) 13 | 14 | type GithubProvider struct { 15 | config oauth2.Config 16 | } 17 | 18 | func newGithubProvider() provider.Interface { 19 | return &GithubProvider{ 20 | config: oauth2.Config{ 21 | Scopes: []string{"user"}, 22 | Endpoint: github.Endpoint, 23 | }, 24 | } 25 | } 26 | 27 | func (p *GithubProvider) Init(c provider.Oauth2Option) { 28 | p.config.ClientID = c.ClientID 29 | p.config.ClientSecret = c.ClientSecret 30 | p.config.RedirectURL = c.RedirectURL 31 | } 32 | 33 | func (p *GithubProvider) Provider() provider.OAuth2Provider { 34 | return "github" 35 | } 36 | 37 | func (p *GithubProvider) NewAuthURL(_ context.Context, state string) (string, error) { 38 | return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline), nil 39 | } 40 | 41 | func (p *GithubProvider) GetToken(ctx context.Context, code string) (*oauth2.Token, error) { 42 | return p.config.Exchange(ctx, code) 43 | } 44 | 45 | func (p *GithubProvider) RefreshToken(ctx context.Context, tk string) (*oauth2.Token, error) { 46 | return p.config.TokenSource(ctx, &oauth2.Token{RefreshToken: tk}).Token() 47 | } 48 | 49 | func (p *GithubProvider) GetUserInfo(ctx context.Context, code string) (*provider.UserInfo, error) { 50 | tk, err := p.config.Exchange(ctx, code) 51 | if err != nil { 52 | return nil, err 53 | } 54 | client := p.config.Client(ctx, tk) 55 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil) 56 | if err != nil { 57 | return nil, err 58 | } 59 | resp, err := client.Do(req) 60 | if err != nil { 61 | return nil, err 62 | } 63 | defer resp.Body.Close() 64 | ui := githubUserInfo{} 65 | err = json.NewDecoder(resp.Body).Decode(&ui) 66 | if err != nil { 67 | return nil, err 68 | } 69 | return &provider.UserInfo{ 70 | Username: ui.Login, 71 | ProviderUserID: strconv.FormatUint(ui.ID, 10), 72 | }, nil 73 | } 74 | 75 | type githubUserInfo struct { 76 | Login string `json:"login"` 77 | ID uint64 `json:"id"` 78 | } 79 | 80 | func init() { 81 | RegisterProvider(newGithubProvider()) 82 | } 83 | -------------------------------------------------------------------------------- /internal/provider/providers/gitlab.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/synctv-org/synctv/internal/provider" 8 | "golang.org/x/oauth2" 9 | "golang.org/x/oauth2/gitlab" 10 | ) 11 | 12 | type GitlabProvider struct { 13 | config oauth2.Config 14 | } 15 | 16 | func newGitlabProvider() provider.Interface { 17 | return &GitlabProvider{ 18 | config: oauth2.Config{ 19 | Scopes: []string{"read_user"}, 20 | Endpoint: gitlab.Endpoint, 21 | }, 22 | } 23 | } 24 | 25 | func (g *GitlabProvider) Init(c provider.Oauth2Option) { 26 | g.config.ClientID = c.ClientID 27 | g.config.ClientSecret = c.ClientSecret 28 | g.config.RedirectURL = c.RedirectURL 29 | } 30 | 31 | func (g *GitlabProvider) Provider() provider.OAuth2Provider { 32 | return "gitlab" 33 | } 34 | 35 | func (g *GitlabProvider) NewAuthURL(_ context.Context, state string) (string, error) { 36 | return g.config.AuthCodeURL(state, oauth2.AccessTypeOnline), nil 37 | } 38 | 39 | func (g *GitlabProvider) GetToken(ctx context.Context, code string) (*oauth2.Token, error) { 40 | return g.config.Exchange(ctx, code) 41 | } 42 | 43 | func (g *GitlabProvider) RefreshToken(ctx context.Context, tk string) (*oauth2.Token, error) { 44 | return g.config.TokenSource(ctx, &oauth2.Token{RefreshToken: tk}).Token() 45 | } 46 | 47 | func (g *GitlabProvider) GetUserInfo(ctx context.Context, code string) (*provider.UserInfo, error) { 48 | tk, err := g.GetToken(ctx, code) 49 | if err != nil { 50 | return nil, err 51 | } 52 | client := g.config.Client(ctx, tk) 53 | req, err := http.NewRequestWithContext( 54 | ctx, 55 | http.MethodGet, 56 | "https://gitlab.com/api/v4/user", 57 | nil, 58 | ) 59 | if err != nil { 60 | return nil, err 61 | } 62 | resp, err := client.Do(req) 63 | if err != nil { 64 | return nil, err 65 | } 66 | defer resp.Body.Close() 67 | return nil, FormatNotImplementedError("gitlab") 68 | } 69 | 70 | func init() { 71 | RegisterProvider(newGitlabProvider()) 72 | } 73 | -------------------------------------------------------------------------------- /internal/provider/providers/google.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | json "github.com/json-iterator/go" 8 | "github.com/synctv-org/synctv/internal/provider" 9 | "golang.org/x/oauth2" 10 | "golang.org/x/oauth2/google" 11 | ) 12 | 13 | type GoogleProvider struct { 14 | config oauth2.Config 15 | } 16 | 17 | func newGoogleProvider() provider.Interface { 18 | return &GoogleProvider{ 19 | config: oauth2.Config{ 20 | Scopes: []string{"profile"}, 21 | Endpoint: google.Endpoint, 22 | }, 23 | } 24 | } 25 | 26 | func (g *GoogleProvider) Init(c provider.Oauth2Option) { 27 | g.config.ClientID = c.ClientID 28 | g.config.ClientSecret = c.ClientSecret 29 | g.config.RedirectURL = c.RedirectURL 30 | } 31 | 32 | func (g *GoogleProvider) Provider() provider.OAuth2Provider { 33 | return "google" 34 | } 35 | 36 | func (g *GoogleProvider) NewAuthURL(_ context.Context, state string) (string, error) { 37 | return g.config.AuthCodeURL(state, oauth2.AccessTypeOnline), nil 38 | } 39 | 40 | func (g *GoogleProvider) GetToken(ctx context.Context, code string) (*oauth2.Token, error) { 41 | return g.config.Exchange(ctx, code) 42 | } 43 | 44 | func (g *GoogleProvider) RefreshToken(ctx context.Context, tk string) (*oauth2.Token, error) { 45 | return g.config.TokenSource(ctx, &oauth2.Token{RefreshToken: tk}).Token() 46 | } 47 | 48 | func (g *GoogleProvider) GetUserInfo(ctx context.Context, code string) (*provider.UserInfo, error) { 49 | tk, err := g.GetToken(ctx, code) 50 | if err != nil { 51 | return nil, err 52 | } 53 | client := g.config.Client(ctx, tk) 54 | req, err := http.NewRequestWithContext( 55 | ctx, 56 | http.MethodGet, 57 | "https://www.googleapis.com/oauth2/v2/userinfo", 58 | nil, 59 | ) 60 | if err != nil { 61 | return nil, err 62 | } 63 | resp, err := client.Do(req) 64 | if err != nil { 65 | return nil, err 66 | } 67 | defer resp.Body.Close() 68 | ui := googleUserInfo{} 69 | err = json.NewDecoder(resp.Body).Decode(&ui) 70 | if err != nil { 71 | return nil, err 72 | } 73 | return &provider.UserInfo{ 74 | Username: ui.Name, 75 | ProviderUserID: ui.ID, 76 | }, nil 77 | } 78 | 79 | func init() { 80 | RegisterProvider(newGoogleProvider()) 81 | } 82 | 83 | type googleUserInfo struct { 84 | ID string `json:"id"` 85 | Name string `json:"name"` 86 | } 87 | -------------------------------------------------------------------------------- /internal/provider/providers/logto.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | 10 | "github.com/synctv-org/synctv/internal/provider" 11 | "github.com/synctv-org/synctv/internal/settings" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | // https://openapi.logto.io/authentication 16 | type logtoProvider struct { 17 | config oauth2.Config 18 | endpoint string 19 | } 20 | 21 | func newLogtoProvider() provider.Interface { 22 | return &logtoProvider{ 23 | config: oauth2.Config{ 24 | Scopes: []string{"profile", "email", "phone", "name", "openid"}, 25 | }, 26 | } 27 | } 28 | 29 | func (p *logtoProvider) Init(opt provider.Oauth2Option) { 30 | p.config.ClientID = opt.ClientID 31 | p.config.ClientSecret = opt.ClientSecret 32 | p.config.RedirectURL = opt.RedirectURL 33 | } 34 | 35 | func (p *logtoProvider) NewAuthURL(_ context.Context, state string) (string, error) { 36 | return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline), nil 37 | } 38 | 39 | func (p *logtoProvider) GetToken(ctx context.Context, code string) (*oauth2.Token, error) { 40 | return p.config.Exchange(ctx, code) 41 | } 42 | 43 | func (p *logtoProvider) RefreshToken(ctx context.Context, token string) (*oauth2.Token, error) { 44 | return p.config.TokenSource(ctx, &oauth2.Token{RefreshToken: token}).Token() 45 | } 46 | 47 | func (p *logtoProvider) GetUserInfo(ctx context.Context, code string) (*provider.UserInfo, error) { 48 | tk, err := p.GetToken(ctx, code) 49 | if err != nil { 50 | return nil, err 51 | } 52 | client := p.config.Client(ctx, tk) 53 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.endpoint+"/oidc/me", nil) 54 | if err != nil { 55 | return nil, err 56 | } 57 | resp, err := client.Do(req) 58 | if err != nil { 59 | return nil, err 60 | } 61 | defer resp.Body.Close() 62 | var ui logtoUserInfo 63 | err = json.NewDecoder(resp.Body).Decode(&ui) 64 | if err != nil { 65 | return nil, err 66 | } 67 | un := ui.Username 68 | if un == "" { 69 | un = ui.Name 70 | } 71 | return &provider.UserInfo{ 72 | ProviderUserID: ui.Sub, 73 | Username: un, 74 | }, nil 75 | } 76 | 77 | type logtoUserInfo struct { 78 | Sub string `json:"sub"` 79 | Username string `json:"username"` 80 | PrimaryEmail string `json:"primaryEmail"` 81 | PrimaryPhone string `json:"primaryPhone"` 82 | Name string `json:"name"` 83 | Email string `json:"email"` 84 | } 85 | 86 | func (p *logtoProvider) RegistSetting(group string) { 87 | settings.NewStringSetting( 88 | group+"_endpoint", "", group, 89 | settings.WithAfterInitString(func(_ settings.StringSetting, s string) { 90 | p.endpoint = s 91 | p.config.Endpoint = oauth2.Endpoint{ 92 | AuthURL: s + "/oidc/auth", 93 | TokenURL: s + "/oidc/token", 94 | } 95 | }), 96 | settings.WithBeforeSetString(func(_ settings.StringSetting, s string) (string, error) { 97 | u, err := url.Parse(s) 98 | if err != nil { 99 | return "", err 100 | } 101 | return fmt.Sprintf("%s://%s", u.Scheme, u.Host), nil 102 | }), 103 | settings.WithAfterSetString(func(_ settings.StringSetting, s string) { 104 | p.endpoint = s 105 | p.config.Endpoint = oauth2.Endpoint{ 106 | AuthURL: s + "/oidc/auth", 107 | TokenURL: s + "/oidc/token", 108 | } 109 | }), 110 | ) 111 | } 112 | 113 | func (p *logtoProvider) Provider() provider.OAuth2Provider { 114 | return "logto" 115 | } 116 | 117 | func init() { 118 | RegisterProvider(newLogtoProvider()) 119 | } 120 | -------------------------------------------------------------------------------- /internal/provider/providers/microsoft.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | json "github.com/json-iterator/go" 8 | "github.com/synctv-org/synctv/internal/provider" 9 | "golang.org/x/oauth2" 10 | "golang.org/x/oauth2/microsoft" 11 | ) 12 | 13 | type MicrosoftProvider struct { 14 | config oauth2.Config 15 | } 16 | 17 | func newMicrosoftProvider() provider.Interface { 18 | return &MicrosoftProvider{ 19 | config: oauth2.Config{ 20 | Scopes: []string{"user.read"}, 21 | Endpoint: microsoft.LiveConnectEndpoint, 22 | }, 23 | } 24 | } 25 | 26 | func (p *MicrosoftProvider) Init(c provider.Oauth2Option) { 27 | p.config.ClientID = c.ClientID 28 | p.config.ClientSecret = c.ClientSecret 29 | p.config.RedirectURL = c.RedirectURL 30 | } 31 | 32 | func (p *MicrosoftProvider) Provider() provider.OAuth2Provider { 33 | return "microsoft" 34 | } 35 | 36 | func (p *MicrosoftProvider) NewAuthURL(_ context.Context, state string) (string, error) { 37 | return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline), nil 38 | } 39 | 40 | func (p *MicrosoftProvider) GetToken(ctx context.Context, code string) (*oauth2.Token, error) { 41 | return p.config.Exchange(ctx, code) 42 | } 43 | 44 | func (p *MicrosoftProvider) RefreshToken(ctx context.Context, tk string) (*oauth2.Token, error) { 45 | return p.config.TokenSource(ctx, &oauth2.Token{RefreshToken: tk}).Token() 46 | } 47 | 48 | func (p *MicrosoftProvider) GetUserInfo( 49 | ctx context.Context, 50 | code string, 51 | ) (*provider.UserInfo, error) { 52 | tk, err := p.GetToken(ctx, code) 53 | if err != nil { 54 | return nil, err 55 | } 56 | client := p.config.Client(ctx, tk) 57 | req, err := http.NewRequestWithContext( 58 | ctx, 59 | http.MethodGet, 60 | "https://graph.microsoft.com/v1.0/me", 61 | nil, 62 | ) 63 | if err != nil { 64 | return nil, err 65 | } 66 | resp, err := client.Do(req) 67 | if err != nil { 68 | return nil, err 69 | } 70 | defer resp.Body.Close() 71 | ui := microsoftUserInfo{} 72 | err = json.NewDecoder(resp.Body).Decode(&ui) 73 | if err != nil { 74 | return nil, err 75 | } 76 | return &provider.UserInfo{ 77 | Username: ui.DisplayName, 78 | ProviderUserID: ui.ID, 79 | }, nil 80 | } 81 | 82 | type microsoftUserInfo struct { 83 | ID string `json:"id"` 84 | DisplayName string `json:"displayName"` 85 | } 86 | 87 | func init() { 88 | RegisterProvider(newMicrosoftProvider()) 89 | } 90 | -------------------------------------------------------------------------------- /internal/provider/providers/providers.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "github.com/synctv-org/synctv/internal/provider" 5 | "github.com/zijiren233/gencontainer/rwmap" 6 | ) 7 | 8 | var ( 9 | enabledProviders rwmap.RWMap[provider.OAuth2Provider, struct{}] 10 | allProviders rwmap.RWMap[provider.OAuth2Provider, provider.Interface] 11 | ) 12 | 13 | func InitProvider(p provider.OAuth2Provider, c provider.Oauth2Option) (provider.Interface, error) { 14 | pi, ok := allProviders.Load(p) 15 | if !ok { 16 | return nil, FormatNotImplementedError(p) 17 | } 18 | pi.Init(c) 19 | return pi, nil 20 | } 21 | 22 | func RegisterProvider(ps ...provider.Interface) { 23 | for _, p := range ps { 24 | allProviders.Store(p.Provider(), p) 25 | } 26 | } 27 | 28 | func GetProvider(p provider.OAuth2Provider) (provider.Interface, error) { 29 | _, ok := enabledProviders.Load(p) 30 | if !ok { 31 | return nil, FormatNotImplementedError(p) 32 | } 33 | pi, ok := allProviders.Load(p) 34 | if !ok { 35 | return nil, FormatNotImplementedError(p) 36 | } 37 | return pi, nil 38 | } 39 | 40 | func AllProvider() map[provider.OAuth2Provider]provider.Interface { 41 | m := make(map[provider.OAuth2Provider]provider.Interface) 42 | allProviders.Range(func(key string, value provider.Interface) bool { 43 | m[key] = value 44 | return true 45 | }) 46 | return m 47 | } 48 | 49 | func EnabledProvider() *rwmap.RWMap[provider.OAuth2Provider, struct{}] { 50 | return &enabledProviders 51 | } 52 | 53 | func EnableProvider(p provider.OAuth2Provider) error { 54 | _, ok := allProviders.Load(p) 55 | if !ok { 56 | return FormatNotImplementedError(p) 57 | } 58 | enabledProviders.Store(p, struct{}{}) 59 | return nil 60 | } 61 | 62 | func DisableProvider(p provider.OAuth2Provider) error { 63 | _, ok := allProviders.Load(p) 64 | if !ok { 65 | return FormatNotImplementedError(p) 66 | } 67 | enabledProviders.Delete(p) 68 | return nil 69 | } 70 | 71 | type FormatNotImplementedError string 72 | 73 | func (f FormatNotImplementedError) Error() string { 74 | return string(f) + " is not implemented" 75 | } 76 | -------------------------------------------------------------------------------- /internal/provider/providers/xiaomi.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | json "github.com/json-iterator/go" 9 | "github.com/synctv-org/synctv/internal/provider" 10 | "golang.org/x/oauth2" 11 | ) 12 | 13 | type XiaomiProvider struct { 14 | config oauth2.Config 15 | } 16 | 17 | func newXiaomiProvider() provider.Interface { 18 | return &XiaomiProvider{ 19 | config: oauth2.Config{ 20 | Scopes: []string{"profile"}, 21 | Endpoint: oauth2.Endpoint{ 22 | AuthURL: "https://account.xiaomi.com/oauth2/authorize", 23 | TokenURL: "https://account.xiaomi.com/oauth2/token", 24 | }, 25 | }, 26 | } 27 | } 28 | 29 | func (p *XiaomiProvider) Init(c provider.Oauth2Option) { 30 | p.config.ClientID = c.ClientID 31 | p.config.ClientSecret = c.ClientSecret 32 | p.config.RedirectURL = c.RedirectURL 33 | } 34 | 35 | func (p *XiaomiProvider) Provider() provider.OAuth2Provider { 36 | return "xiaomi" 37 | } 38 | 39 | func (p *XiaomiProvider) NewAuthURL(_ context.Context, state string) (string, error) { 40 | return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline), nil 41 | } 42 | 43 | func (p *XiaomiProvider) GetToken(ctx context.Context, code string) (*oauth2.Token, error) { 44 | return p.config.Exchange(ctx, code) 45 | } 46 | 47 | func (p *XiaomiProvider) RefreshToken(ctx context.Context, tk string) (*oauth2.Token, error) { 48 | return p.config.TokenSource(ctx, &oauth2.Token{RefreshToken: tk}).Token() 49 | } 50 | 51 | func (p *XiaomiProvider) GetUserInfo(ctx context.Context, code string) (*provider.UserInfo, error) { 52 | tk, err := p.config.Exchange(ctx, code) 53 | if err != nil { 54 | return nil, err 55 | } 56 | client := p.config.Client(ctx, tk) 57 | req, err := http.NewRequestWithContext( 58 | ctx, 59 | http.MethodGet, 60 | fmt.Sprintf( 61 | "https://open.account.xiaomi.com/user/profile?clientId=%s&token=%s", 62 | p.config.ClientID, 63 | tk.AccessToken, 64 | ), 65 | nil, 66 | ) 67 | if err != nil { 68 | return nil, err 69 | } 70 | resp, err := client.Do(req) 71 | if err != nil { 72 | return nil, err 73 | } 74 | defer resp.Body.Close() 75 | ui := xiaomiUserInfo{} 76 | err = json.NewDecoder(resp.Body).Decode(&ui) 77 | if err != nil { 78 | return nil, err 79 | } 80 | return &provider.UserInfo{ 81 | Username: ui.Data.Name, 82 | ProviderUserID: ui.Data.UnionID, 83 | }, nil 84 | } 85 | 86 | type xiaomiUserInfo struct { 87 | Data struct { 88 | UnionID string `json:"unionId"` 89 | Name string `json:"miliaoNick"` 90 | } `json:"data"` 91 | } 92 | 93 | func init() { 94 | RegisterProvider(newXiaomiProvider()) 95 | } 96 | -------------------------------------------------------------------------------- /internal/rtmp/rtmp.go: -------------------------------------------------------------------------------- 1 | package rtmp 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "time" 7 | 8 | "github.com/golang-jwt/jwt/v5" 9 | "github.com/synctv-org/synctv/internal/conf" 10 | rtmps "github.com/zijiren233/livelib/server" 11 | "github.com/zijiren233/stream" 12 | ) 13 | 14 | var s *rtmps.Server 15 | 16 | type Claims struct { 17 | MovieID string `json:"m"` 18 | jwt.RegisteredClaims 19 | } 20 | 21 | func AuthRtmpPublish(authorization string) (movieID string, err error) { 22 | t, err := jwt.ParseWithClaims( 23 | strings.TrimPrefix(authorization, `Bearer `), 24 | &Claims{}, 25 | func(_ *jwt.Token) (any, error) { 26 | return stream.StringToBytes(conf.Conf.Jwt.Secret), nil 27 | }, 28 | ) 29 | if err != nil { 30 | return "", errors.New("auth failed") 31 | } 32 | claims, ok := t.Claims.(*Claims) 33 | if !ok { 34 | return "", errors.New("auth failed") 35 | } 36 | return claims.MovieID, nil 37 | } 38 | 39 | func NewRtmpAuthorization(movieID string) (string, error) { 40 | claims := &Claims{ 41 | MovieID: movieID, 42 | RegisteredClaims: jwt.RegisteredClaims{ 43 | NotBefore: jwt.NewNumericDate(time.Now()), 44 | }, 45 | } 46 | return jwt.NewWithClaims(jwt.SigningMethodHS256, claims). 47 | SignedString(stream.StringToBytes(conf.Conf.Jwt.Secret)) 48 | } 49 | 50 | func Init(rs *rtmps.Server) { 51 | s = rs 52 | } 53 | 54 | func Server() *rtmps.Server { 55 | return s 56 | } 57 | -------------------------------------------------------------------------------- /internal/settings/setting.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | json "github.com/json-iterator/go" 8 | "github.com/synctv-org/synctv/internal/model" 9 | "github.com/zijiren233/gencontainer/heap" 10 | ) 11 | 12 | var ErrSettingAlreadyInited = errors.New("setting already inited") 13 | 14 | var _ heap.Interface[maxHeapItem] = (*maxHeap)(nil) 15 | 16 | type maxHeapItem struct { 17 | Setting 18 | priority int 19 | } 20 | 21 | type maxHeap struct { 22 | items []maxHeapItem 23 | } 24 | 25 | func (h *maxHeap) Len() int { 26 | return len(h.items) 27 | } 28 | 29 | func (h *maxHeap) Less(i, j int) bool { 30 | return h.items[i].priority > h.items[j].priority 31 | } 32 | 33 | func (h *maxHeap) Swap(i, j int) { 34 | h.items[i], h.items[j] = h.items[j], h.items[i] 35 | } 36 | 37 | func (h *maxHeap) Push(x maxHeapItem) { 38 | h.items = append(h.items, x) 39 | } 40 | 41 | func (h *maxHeap) Pop() maxHeapItem { 42 | n := len(h.items) 43 | x := h.items[n-1] 44 | h.items = h.items[:n-1] 45 | return x 46 | } 47 | 48 | var ( 49 | Settings = make(map[string]Setting) 50 | GroupSettings = make(map[model.SettingGroup]map[string]Setting) 51 | needInit = new(maxHeap) 52 | ) 53 | 54 | func pushNeedInit(s Setting) { 55 | if s == nil { 56 | panic("push need init failed, setting is nil") 57 | } 58 | for i, item := range needInit.items { 59 | if item.Name() == s.Name() { 60 | heap.Remove(needInit, i) 61 | break 62 | } 63 | } 64 | heap.Push(needInit, maxHeapItem{ 65 | priority: s.InitPriority(), 66 | Setting: s, 67 | }) 68 | } 69 | 70 | func hasNeedInit() bool { 71 | return needInit.Len() > 0 72 | } 73 | 74 | func PopNeedInit() (Setting, bool) { 75 | for hasNeedInit() { 76 | item := heap.Pop(needInit) 77 | s := item.Setting 78 | if s.Inited() { 79 | continue 80 | } 81 | return s, true 82 | } 83 | return nil, false 84 | } 85 | 86 | type Setting interface { 87 | Name() string 88 | Type() model.SettingType 89 | Group() model.SettingGroup 90 | Init(value string) error 91 | Inited() bool 92 | SetInitPriority(priority int) 93 | InitPriority() int 94 | String() string 95 | SetString(value string) error 96 | DefaultString() string 97 | DefaultInterface() any 98 | Interface() any 99 | } 100 | 101 | //nolint:errcheck 102 | func SetValue(name string, value any) error { 103 | s, ok := Settings[name] 104 | if !ok { 105 | return fmt.Errorf("setting %s not found", name) 106 | } 107 | switch s.Type() { 108 | case model.SettingTypeBool: 109 | return s.(BoolSetting).Set(json.Wrap(value).ToBool()) 110 | case model.SettingTypeInt64: 111 | return s.(Int64Setting).Set(json.Wrap(value).ToInt64()) 112 | case model.SettingTypeFloat64: 113 | return s.(Float64Setting).Set(json.Wrap(value).ToFloat64()) 114 | case model.SettingTypeString: 115 | return s.(StringSetting).Set(json.Wrap(value).ToString()) 116 | } 117 | return s.SetString(json.Wrap(value).ToString()) 118 | } 119 | 120 | type setting struct { 121 | name string 122 | settingType model.SettingType 123 | group model.SettingGroup 124 | initPriority int 125 | inited bool 126 | } 127 | 128 | func (d *setting) Name() string { 129 | return d.name 130 | } 131 | 132 | func (d *setting) Type() model.SettingType { 133 | return d.settingType 134 | } 135 | 136 | func (d *setting) Group() model.SettingGroup { 137 | return d.group 138 | } 139 | 140 | func (d *setting) InitPriority() int { 141 | return d.initPriority 142 | } 143 | 144 | func (d *setting) Inited() bool { 145 | return d.inited 146 | } 147 | 148 | func (d *setting) SetInitPriority(priority int) { 149 | d.initPriority = priority 150 | } 151 | -------------------------------------------------------------------------------- /internal/sysNotify/signal.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package sysnotify 5 | 6 | import ( 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | ) 11 | 12 | func (sn *SysNotify) Init() { 13 | sn.c = make(chan os.Signal, 1) 14 | signal.Notify( 15 | sn.c, 16 | syscall.SIGHUP, /*1*/ 17 | syscall.SIGINT, /*2*/ 18 | syscall.SIGQUIT, /*3*/ 19 | syscall.SIGTERM, /*15*/ 20 | syscall.SIGUSR1, /*10*/ 21 | syscall.SIGUSR2, /*12*/ 22 | ) 23 | } 24 | 25 | func parseSysNotifyType(s os.Signal) NotifyType { 26 | switch s { 27 | case syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM: 28 | return NotifyTypeEXIT 29 | case syscall.SIGUSR1, syscall.SIGUSR2: 30 | return NotifyTypeRELOAD 31 | default: 32 | return 0 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/sysNotify/signal_windows.go: -------------------------------------------------------------------------------- 1 | package sysnotify 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | ) 8 | 9 | func (sn *SysNotify) Init() { 10 | sn.c = make(chan os.Signal, 1) 11 | signal.Notify(sn.c, syscall.SIGHUP /*1*/, syscall.SIGINT /*2*/, syscall.SIGQUIT /*3*/, syscall.SIGTERM /*15*/) 12 | } 13 | 14 | func parseSysNotifyType(s os.Signal) NotifyType { 15 | switch s { 16 | case syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM: 17 | return NotifyTypeEXIT 18 | default: 19 | return 0 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /internal/sysNotify/sysNotify.go: -------------------------------------------------------------------------------- 1 | package sysnotify 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "sync" 7 | 8 | log "github.com/sirupsen/logrus" 9 | "github.com/zijiren233/gencontainer/pqueue" 10 | "github.com/zijiren233/gencontainer/rwmap" 11 | ) 12 | 13 | var sysNotify SysNotify 14 | 15 | func Init() { 16 | sysNotify.Init() 17 | } 18 | 19 | func RegisterSysNotifyTask(priority int, task *Task) error { 20 | return sysNotify.RegisterSysNotifyTask(priority, task) 21 | } 22 | 23 | func WaitCbk() { 24 | sysNotify.WaitCbk() 25 | } 26 | 27 | type SysNotify struct { 28 | c chan os.Signal 29 | taskGroup rwmap.RWMap[NotifyType, *taskQueue] 30 | once sync.Once 31 | } 32 | 33 | type NotifyType int 34 | 35 | const ( 36 | NotifyTypeEXIT NotifyType = iota + 1 37 | NotifyTypeRELOAD 38 | ) 39 | 40 | type taskQueue struct { 41 | notifyTaskQueue *pqueue.PQueue[*Task] 42 | notifyTaskLock sync.Mutex 43 | } 44 | 45 | type Task struct { 46 | Task func() error 47 | Name string 48 | NotifyType NotifyType 49 | } 50 | 51 | func NewSysNotifyTask(name string, notifyType NotifyType, task func() error) *Task { 52 | return &Task{ 53 | Name: name, 54 | NotifyType: notifyType, 55 | Task: task, 56 | } 57 | } 58 | 59 | func runTask(tq *taskQueue) { 60 | tq.notifyTaskLock.Lock() 61 | defer tq.notifyTaskLock.Unlock() 62 | for tq.notifyTaskQueue.Len() > 0 { 63 | _, task := tq.notifyTaskQueue.Pop() 64 | func() { 65 | defer func() { 66 | if err := recover(); err != nil { 67 | log.Errorf("task: %s panic has returned: %v", task.Name, err) 68 | } 69 | }() 70 | log.Infof("task: %s running", task.Name) 71 | if err := task.Task(); err != nil { 72 | log.Errorf("task: %s an error occurred: %v", task.Name, err) 73 | } 74 | log.Infof("task: %s done", task.Name) 75 | }() 76 | } 77 | } 78 | 79 | func (sn *SysNotify) RegisterSysNotifyTask(priority int, task *Task) error { 80 | if task == nil || task.Task == nil { 81 | return errors.New("task is nil") 82 | } 83 | if task.NotifyType == 0 { 84 | panic("task notify type is 0") 85 | } 86 | tasks, _ := sn.taskGroup.LoadOrStore(task.NotifyType, &taskQueue{ 87 | notifyTaskQueue: pqueue.NewMinPriorityQueue[*Task](), 88 | }) 89 | tasks.notifyTaskLock.Lock() 90 | defer tasks.notifyTaskLock.Unlock() 91 | tasks.notifyTaskQueue.Push(priority, task) 92 | return nil 93 | } 94 | 95 | func (sn *SysNotify) waitCbk() { 96 | log.Info("wait sys notify") 97 | for s := range sn.c { 98 | log.Infof("receive sys notify: %v", s) 99 | switch parseSysNotifyType(s) { 100 | case NotifyTypeEXIT: 101 | tq, ok := sn.taskGroup.Load(NotifyTypeEXIT) 102 | if ok { 103 | log.Info("task: NotifyTypeEXIT running...") 104 | runTask(tq) 105 | } 106 | return 107 | case NotifyTypeRELOAD: 108 | tq, ok := sn.taskGroup.Load(NotifyTypeRELOAD) 109 | if ok { 110 | log.Info("task: NotifyTypeRELOAD running...") 111 | runTask(tq) 112 | } 113 | } 114 | } 115 | log.Info("task: all done") 116 | } 117 | 118 | func (sn *SysNotify) WaitCbk() { 119 | sn.once.Do(sn.waitCbk) 120 | } 121 | -------------------------------------------------------------------------------- /internal/sysnotify/signal.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package sysnotify 5 | 6 | import ( 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | ) 11 | 12 | func (sn *SysNotify) Init() { 13 | sn.c = make(chan os.Signal, 1) 14 | signal.Notify( 15 | sn.c, 16 | syscall.SIGHUP, /*1*/ 17 | syscall.SIGINT, /*2*/ 18 | syscall.SIGQUIT, /*3*/ 19 | syscall.SIGTERM, /*15*/ 20 | syscall.SIGUSR1, /*10*/ 21 | syscall.SIGUSR2, /*12*/ 22 | ) 23 | } 24 | 25 | func parseSysNotifyType(s os.Signal) NotifyType { 26 | switch s { 27 | case syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM: 28 | return NotifyTypeEXIT 29 | case syscall.SIGUSR1, syscall.SIGUSR2: 30 | return NotifyTypeRELOAD 31 | default: 32 | return 0 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/sysnotify/signal_windows.go: -------------------------------------------------------------------------------- 1 | package sysnotify 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | ) 8 | 9 | func (sn *SysNotify) Init() { 10 | sn.c = make(chan os.Signal, 1) 11 | signal.Notify(sn.c, syscall.SIGHUP /*1*/, syscall.SIGINT /*2*/, syscall.SIGQUIT /*3*/, syscall.SIGTERM /*15*/) 12 | } 13 | 14 | func parseSysNotifyType(s os.Signal) NotifyType { 15 | switch s { 16 | case syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM: 17 | return NotifyTypeEXIT 18 | default: 19 | return 0 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /internal/sysnotify/sysnotify.go: -------------------------------------------------------------------------------- 1 | package sysnotify 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "sync" 7 | 8 | log "github.com/sirupsen/logrus" 9 | "github.com/zijiren233/gencontainer/pqueue" 10 | "github.com/zijiren233/gencontainer/rwmap" 11 | ) 12 | 13 | var sysNotify SysNotify 14 | 15 | func Init() { 16 | sysNotify.Init() 17 | } 18 | 19 | func RegisterSysNotifyTask(priority int, task *Task) error { 20 | return sysNotify.RegisterSysNotifyTask(priority, task) 21 | } 22 | 23 | func WaitCbk() { 24 | sysNotify.WaitCbk() 25 | } 26 | 27 | type SysNotify struct { 28 | c chan os.Signal 29 | taskGroup rwmap.RWMap[NotifyType, *taskQueue] 30 | once sync.Once 31 | } 32 | 33 | type NotifyType int 34 | 35 | const ( 36 | NotifyTypeEXIT NotifyType = iota + 1 37 | NotifyTypeRELOAD 38 | ) 39 | 40 | type taskQueue struct { 41 | notifyTaskQueue *pqueue.PQueue[*Task] 42 | notifyTaskLock sync.Mutex 43 | } 44 | 45 | type Task struct { 46 | Task func() error 47 | Name string 48 | NotifyType NotifyType 49 | } 50 | 51 | func NewSysNotifyTask(name string, notifyType NotifyType, task func() error) *Task { 52 | return &Task{ 53 | Name: name, 54 | NotifyType: notifyType, 55 | Task: task, 56 | } 57 | } 58 | 59 | func runTask(tq *taskQueue) { 60 | tq.notifyTaskLock.Lock() 61 | defer tq.notifyTaskLock.Unlock() 62 | for tq.notifyTaskQueue.Len() > 0 { 63 | _, task := tq.notifyTaskQueue.Pop() 64 | func() { 65 | defer func() { 66 | if err := recover(); err != nil { 67 | log.Errorf("task: %s panic has returned: %v", task.Name, err) 68 | } 69 | }() 70 | log.Infof("task: %s running", task.Name) 71 | if err := task.Task(); err != nil { 72 | log.Errorf("task: %s an error occurred: %v", task.Name, err) 73 | } 74 | log.Infof("task: %s done", task.Name) 75 | }() 76 | } 77 | } 78 | 79 | func (sn *SysNotify) RegisterSysNotifyTask(priority int, task *Task) error { 80 | if task == nil || task.Task == nil { 81 | return errors.New("task is nil") 82 | } 83 | if task.NotifyType == 0 { 84 | panic("task notify type is 0") 85 | } 86 | tasks, _ := sn.taskGroup.LoadOrStore(task.NotifyType, &taskQueue{ 87 | notifyTaskQueue: pqueue.NewMinPriorityQueue[*Task](), 88 | }) 89 | tasks.notifyTaskLock.Lock() 90 | defer tasks.notifyTaskLock.Unlock() 91 | tasks.notifyTaskQueue.Push(priority, task) 92 | return nil 93 | } 94 | 95 | func (sn *SysNotify) waitCbk() { 96 | log.Info("wait sys notify") 97 | for s := range sn.c { 98 | log.Infof("receive sys notify: %v", s) 99 | switch parseSysNotifyType(s) { 100 | case NotifyTypeEXIT: 101 | tq, ok := sn.taskGroup.Load(NotifyTypeEXIT) 102 | if ok { 103 | log.Info("task: NotifyTypeEXIT running...") 104 | runTask(tq) 105 | } 106 | return 107 | case NotifyTypeRELOAD: 108 | tq, ok := sn.taskGroup.Load(NotifyTypeRELOAD) 109 | if ok { 110 | log.Info("task: NotifyTypeRELOAD running...") 111 | runTask(tq) 112 | } 113 | } 114 | } 115 | log.Info("task: all done") 116 | } 117 | 118 | func (sn *SysNotify) WaitCbk() { 119 | sn.once.Do(sn.waitCbk) 120 | } 121 | -------------------------------------------------------------------------------- /internal/vendor/alist.go: -------------------------------------------------------------------------------- 1 | package vendor 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/synctv-org/vendors/api/alist" 8 | alistService "github.com/synctv-org/vendors/service/alist" 9 | "google.golang.org/grpc" 10 | ) 11 | 12 | type AlistInterface = alist.AlistHTTPServer 13 | 14 | func LoadAlistClient(name string) AlistInterface { 15 | if cli, ok := LoadClients().alist[name]; ok { 16 | return cli 17 | } 18 | return alistLocalClient 19 | } 20 | 21 | var alistLocalClient AlistInterface 22 | 23 | func init() { 24 | alistLocalClient = alistService.NewAlistService(nil) 25 | } 26 | 27 | func AlistLocalClient() AlistInterface { 28 | return alistLocalClient 29 | } 30 | 31 | func NewAlistGrpcClient(conn *grpc.ClientConn) (AlistInterface, error) { 32 | if conn == nil { 33 | return nil, errors.New("grpc client conn is nil") 34 | } 35 | conn.GetState() 36 | return newGrpcAlist(alist.NewAlistClient(conn)), nil 37 | } 38 | 39 | var _ AlistInterface = (*grpcAlist)(nil) 40 | 41 | type grpcAlist struct { 42 | client alist.AlistClient 43 | } 44 | 45 | func newGrpcAlist(client alist.AlistClient) AlistInterface { 46 | return &grpcAlist{ 47 | client: client, 48 | } 49 | } 50 | 51 | func (a *grpcAlist) FsGet(ctx context.Context, req *alist.FsGetReq) (*alist.FsGetResp, error) { 52 | return a.client.FsGet(ctx, req) 53 | } 54 | 55 | func (a *grpcAlist) FsList(ctx context.Context, req *alist.FsListReq) (*alist.FsListResp, error) { 56 | return a.client.FsList(ctx, req) 57 | } 58 | 59 | func (a *grpcAlist) FsOther( 60 | ctx context.Context, 61 | req *alist.FsOtherReq, 62 | ) (*alist.FsOtherResp, error) { 63 | return a.client.FsOther(ctx, req) 64 | } 65 | 66 | func (a *grpcAlist) Login(ctx context.Context, req *alist.LoginReq) (*alist.LoginResp, error) { 67 | return a.client.Login(ctx, req) 68 | } 69 | 70 | func (a *grpcAlist) Me(ctx context.Context, req *alist.MeReq) (*alist.MeResp, error) { 71 | return a.client.Me(ctx, req) 72 | } 73 | 74 | func (a *grpcAlist) FsSearch( 75 | ctx context.Context, 76 | req *alist.FsSearchReq, 77 | ) (*alist.FsSearchResp, error) { 78 | return a.client.FsSearch(ctx, req) 79 | } 80 | -------------------------------------------------------------------------------- /internal/vendor/emby.go: -------------------------------------------------------------------------------- 1 | package vendor 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/synctv-org/vendors/api/emby" 8 | embyService "github.com/synctv-org/vendors/service/emby" 9 | "google.golang.org/grpc" 10 | ) 11 | 12 | type EmbyInterface = emby.EmbyHTTPServer 13 | 14 | func LoadEmbyClient(name string) EmbyInterface { 15 | if cli, ok := LoadClients().emby[name]; ok && cli != nil { 16 | return cli 17 | } 18 | return embyLocalClient 19 | } 20 | 21 | var embyLocalClient EmbyInterface 22 | 23 | func init() { 24 | embyLocalClient = embyService.NewEmbyService(nil) 25 | } 26 | 27 | func EmbyLocalClient() EmbyInterface { 28 | return embyLocalClient 29 | } 30 | 31 | func NewEmbyGrpcClient(conn *grpc.ClientConn) (EmbyInterface, error) { 32 | if conn == nil { 33 | return nil, errors.New("grpc client conn is nil") 34 | } 35 | conn.GetState() 36 | return newGrpcEmby(emby.NewEmbyClient(conn)), nil 37 | } 38 | 39 | var _ EmbyInterface = (*grpcEmby)(nil) 40 | 41 | type grpcEmby struct { 42 | client emby.EmbyClient 43 | } 44 | 45 | func newGrpcEmby(client emby.EmbyClient) EmbyInterface { 46 | return &grpcEmby{ 47 | client: client, 48 | } 49 | } 50 | 51 | func (e *grpcEmby) FsList(ctx context.Context, req *emby.FsListReq) (*emby.FsListResp, error) { 52 | return e.client.FsList(ctx, req) 53 | } 54 | 55 | func (e *grpcEmby) GetItem(ctx context.Context, req *emby.GetItemReq) (*emby.Item, error) { 56 | return e.client.GetItem(ctx, req) 57 | } 58 | 59 | func (e *grpcEmby) GetItems( 60 | ctx context.Context, 61 | req *emby.GetItemsReq, 62 | ) (*emby.GetItemsResp, error) { 63 | return e.client.GetItems(ctx, req) 64 | } 65 | 66 | func (e *grpcEmby) GetSystemInfo( 67 | ctx context.Context, 68 | req *emby.SystemInfoReq, 69 | ) (*emby.SystemInfoResp, error) { 70 | return e.client.GetSystemInfo(ctx, req) 71 | } 72 | 73 | func (e *grpcEmby) Login(ctx context.Context, req *emby.LoginReq) (*emby.LoginResp, error) { 74 | return e.client.Login(ctx, req) 75 | } 76 | 77 | func (e *grpcEmby) Logout(ctx context.Context, req *emby.LogoutReq) (*emby.Empty, error) { 78 | return e.client.Logout(ctx, req) 79 | } 80 | 81 | func (e *grpcEmby) Me(ctx context.Context, req *emby.MeReq) (*emby.MeResp, error) { 82 | return e.client.Me(ctx, req) 83 | } 84 | 85 | func (e *grpcEmby) PlaybackInfo( 86 | ctx context.Context, 87 | req *emby.PlaybackInfoReq, 88 | ) (*emby.PlaybackInfoResp, error) { 89 | return e.client.PlaybackInfo(ctx, req) 90 | } 91 | 92 | func (e *grpcEmby) DeleteActiveEncodeings( 93 | ctx context.Context, 94 | req *emby.DeleteActiveEncodeingsReq, 95 | ) (*emby.Empty, error) { 96 | return e.client.DeleteActiveEncodeings(ctx, req) 97 | } 98 | -------------------------------------------------------------------------------- /internal/version/update.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/cavaliergopher/grab/v3" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | func SelfUpdate(ctx context.Context, url string) error { 15 | now := time.Now().UnixNano() 16 | currentExecFile, err := ExecutableFile() 17 | if err != nil { 18 | log.Errorf("self update: get current executable file error: %v", err) 19 | return err 20 | } 21 | log.Debugf("self update: current executable file: %s", currentExecFile) 22 | 23 | tmp := filepath.Join(os.TempDir(), "synctv-server", fmt.Sprintf("self-update-%d", now)) 24 | if err := os.MkdirAll(tmp, 0o755); err != nil { 25 | log.Errorf("self update: mkdir %s error: %v", tmp, err) 26 | return err 27 | } 28 | log.Infof("self update: temp path: %s", tmp) 29 | defer func() { 30 | log.Infof("self update: remove temp path: %s", tmp) 31 | if err := os.RemoveAll(tmp); err != nil { 32 | log.Warnf("self update: remove temp path error: %v", err) 33 | } 34 | }() 35 | file, err := DownloadWithProgress(ctx, url, tmp) 36 | if err != nil { 37 | log.Errorf("self update: download %s error: %v", url, err) 38 | return err 39 | } 40 | log.Infof("self update: download success: %s", file) 41 | 42 | if err := os.Chmod(file, 0o755); err != nil { 43 | log.Errorf("self update: chmod %s error: %v", file, err) 44 | return err 45 | } 46 | log.Debugf("self update: chmod success: %s", file) 47 | 48 | oldName := fmt.Sprintf("%s-%d.old", currentExecFile, now) 49 | if err := os.Rename(currentExecFile, oldName); err != nil { 50 | log.Errorf("self update: rename %s -> %s error: %v", currentExecFile, oldName, err) 51 | return err 52 | } 53 | log.Debugf("self update: rename success: %s -> %s", currentExecFile, oldName) 54 | 55 | defer func() { 56 | if err != nil { 57 | log.Warnf("self update: rollback: %s -> %s", oldName, currentExecFile) 58 | if err := os.Rename(oldName, currentExecFile); err != nil { 59 | log.Errorf( 60 | "self update: rollback: rename %s -> %s error: %v", 61 | oldName, 62 | currentExecFile, 63 | err, 64 | ) 65 | } 66 | } else { 67 | log.Debugf("self update: remove old executable file: %s", oldName) 68 | if err := os.Remove(oldName); err != nil { 69 | log.Warnf("self update: remove old executable file error: %v", err) 70 | } 71 | } 72 | }() 73 | 74 | err = os.Rename(file, currentExecFile) 75 | if err != nil { 76 | log.Errorf("self update: rename %s -> %s error: %v", file, currentExecFile, err) 77 | return err 78 | } 79 | 80 | log.Infof("self update: update success: %s", currentExecFile) 81 | 82 | return nil 83 | } 84 | 85 | func DownloadWithProgress(ctx context.Context, url, path string) (string, error) { 86 | req, err := grab.NewRequest(path, url) 87 | if err != nil { 88 | return "", err 89 | } 90 | req = req.WithContext(ctx) 91 | resp := grab.NewClient().Do(req) 92 | t := time.NewTicker(250 * time.Millisecond) 93 | defer t.Stop() 94 | 95 | for { 96 | select { 97 | case <-t.C: 98 | log.Infof("self update: transferred %d / %d bytes (%.2f%%)", 99 | resp.BytesComplete(), 100 | resp.Size(), 101 | 100*resp.Progress()) 102 | 103 | case <-resp.Done: 104 | return resp.Filename, resp.Err() 105 | } 106 | } 107 | } 108 | 109 | // get current executable file 110 | func ExecutableFile() (string, error) { 111 | p, err := os.Executable() 112 | if err != nil { 113 | return "", err 114 | } 115 | return filepath.EvalSymlinks(p) 116 | } 117 | -------------------------------------------------------------------------------- /internal/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/synctv-org/synctv/internal/version" 7 | ) 8 | 9 | func TestCheckLatest(t *testing.T) { 10 | v, err := version.NewVersionInfo() 11 | if err != nil { 12 | t.Fatal(err) 13 | } 14 | s, err := v.CheckLatest(t.Context()) 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | t.Log(s) 19 | } 20 | 21 | func TestLatestBinaryURL(t *testing.T) { 22 | v, err := version.NewVersionInfo() 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | s, err := v.LatestBinaryURL(t.Context()) 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | t.Log(s) 31 | } 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/synctv-org/synctv/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /proto/message/message.go: -------------------------------------------------------------------------------- 1 | package pb 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/gorilla/websocket" 7 | "google.golang.org/protobuf/proto" 8 | ) 9 | 10 | func (em *Message) MessageType() int { 11 | return websocket.BinaryMessage 12 | } 13 | 14 | func (em *Message) Encode(w io.Writer) error { 15 | b, err := proto.Marshal(em) 16 | if err != nil { 17 | return err 18 | } 19 | _, err = w.Write(b) 20 | return err 21 | } 22 | -------------------------------------------------------------------------------- /proto/message/message.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | option go_package = ".;pb"; 3 | 4 | package proto; 5 | 6 | enum MessageType { 7 | UNKNOWN = 0; 8 | ERROR = 1; 9 | CHAT = 2; 10 | STATUS = 3; 11 | CHECK_STATUS = 4; 12 | EXPIRED = 5; 13 | CURRENT = 6; 14 | MOVIES = 7; 15 | VIEWER_COUNT = 8; 16 | SYNC = 9; 17 | MY_STATUS = 10; 18 | WEBRTC_OFFER = 11; 19 | WEBRTC_ANSWER = 12; 20 | WEBRTC_ICE_CANDIDATE = 13; 21 | WEBRTC_JOIN = 14; 22 | WEBRTC_LEAVE = 15; 23 | } 24 | 25 | message Sender { 26 | string user_id = 1; 27 | string username = 2; 28 | } 29 | 30 | message Status { 31 | bool is_playing = 1; 32 | double current_time = 2; 33 | double playback_rate = 3; 34 | } 35 | 36 | message WebRTCData { 37 | string data = 1; 38 | string to = 2; 39 | string from = 3; 40 | } 41 | 42 | message Message { 43 | MessageType type = 1; 44 | sfixed64 timestamp = 2; 45 | optional Sender sender = 3; 46 | 47 | oneof payload { 48 | string error_message = 4; 49 | string chat_content = 5; 50 | Status playback_status = 6; 51 | fixed64 expiration_id = 7; 52 | int64 viewer_count = 8; 53 | WebRTCData webrtc_data = 9; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /proto/provider/plugin.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | option go_package = ".;providerpb"; 3 | 4 | package proto; 5 | 6 | message InitReq { 7 | string client_id = 1; 8 | string client_secret = 2; 9 | string redirect_url = 3; 10 | } 11 | 12 | message GetTokenReq { string code = 1; } 13 | 14 | message RefreshTokenReq { string refresh_token = 1; } 15 | 16 | message ProviderResp { string name = 1; } 17 | 18 | message NewAuthURLReq { string state = 1; } 19 | 20 | message NewAuthURLResp { string url = 1; } 21 | 22 | message GetUserInfoReq { string code = 1; } 23 | 24 | message GetUserInfoResp { 25 | string username = 1; 26 | string provider_user_id = 2; 27 | } 28 | 29 | message Enpty {} 30 | 31 | service Oauth2Plugin { 32 | rpc Init(InitReq) returns (Enpty) {} 33 | rpc Provider(Enpty) returns (ProviderResp) {} 34 | rpc NewAuthURL(NewAuthURLReq) returns (NewAuthURLResp) {} 35 | rpc GetUserInfo(GetUserInfoReq) returns (GetUserInfoResp) {} 36 | } -------------------------------------------------------------------------------- /public/dist/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synctv-org/synctv/39c28e645884f9a6724b5ac995e7308524a2481a/public/dist/.gitkeep -------------------------------------------------------------------------------- /public/public.go: -------------------------------------------------------------------------------- 1 | package public 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | ) 7 | 8 | //go:embed all:dist 9 | var dist embed.FS 10 | 11 | var Public, _ = fs.Sub(dist, "dist") 12 | -------------------------------------------------------------------------------- /script/build.config.sh: -------------------------------------------------------------------------------- 1 | function parseDepArgs() { 2 | while [[ $# -gt 0 ]]; do 3 | case "${1}" in 4 | --version=*) 5 | VERSION="${1#*=}" 6 | shift 7 | ;; 8 | *) 9 | return 1 10 | ;; 11 | esac 12 | done 13 | } 14 | 15 | function printDepHelp() { 16 | echo -e " ${COLOR_LIGHT_YELLOW}--version=${COLOR_RESET} - Set the build version (default: 'dev')" 17 | } 18 | 19 | function printDepEnvHelp() { 20 | echo -e " ${COLOR_LIGHT_GREEN}VERSION${COLOR_RESET} - Set the build version (default: 'dev')" 21 | } 22 | 23 | function initDep() { 24 | local git_commit 25 | git_commit="$(git rev-parse --short HEAD)" || git_commit="dev" 26 | setDefault "VERSION" "${git_commit}" 27 | 28 | # replace space, newline, and double quote 29 | VERSION="$(echo "$VERSION" | sed 's/ //g' | sed 's/"//g' | sed 's/\n//g')" 30 | echo -e "${COLOR_LIGHT_BLUE}Version:${COLOR_RESET} ${COLOR_LIGHT_CYAN}${VERSION}${COLOR_RESET}" 31 | if [[ "${VERSION}" != "dev" ]] && [[ "${VERSION}" != "${git_commit}" ]] && [[ ! "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-beta.*|-rc.*|-alpha.*)?$ ]]; then 32 | echo -e "${COLOR_LIGHT_RED}Version format error: ${VERSION}${COLOR_RESET}" 33 | return 1 34 | fi 35 | 36 | addLDFLAGS "-X 'github.com/synctv-org/synctv/internal/version.Version=${VERSION}'" 37 | addLDFLAGS "-X 'github.com/synctv-org/synctv/internal/version.GitCommit=${git_commit}'" 38 | addTags "jsoniter" 39 | } 40 | -------------------------------------------------------------------------------- /script/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | synctv: 4 | image: 'synctvorg/synctv:latest' 5 | container_name: synctv 6 | restart: unless-stopped 7 | ports: 8 | - '8080:8080/tcp' 9 | - '8080:8080/udp' 10 | volumes: 11 | - /opt/synctv:/root/.synctv 12 | environment: 13 | - PUID=0 14 | - PGID=0 15 | - UMASK=022 16 | - TZ=Asia/Shanghai 17 | -------------------------------------------------------------------------------- /script/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | chown -R ${PUID}:${PGID} /root/.synctv 4 | 5 | umask ${UMASK} 6 | 7 | export ENV_NO_PREFIX=true 8 | 9 | export DATA_DIR=/root/.synctv 10 | 11 | exec su-exec ${PUID}:${PGID} synctv $@ --skip-env-flag=false 12 | -------------------------------------------------------------------------------- /script/proto.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | protoc --go_out=./proto/message ./proto/message/*.proto 3 | protoc --go_out=./proto/provider --go-grpc_out=./proto/provider ./proto/provider/*.proto 4 | -------------------------------------------------------------------------------- /server/handlers/danmu.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/synctv-org/synctv/server/handlers/vendors" 9 | "github.com/synctv-org/synctv/server/middlewares" 10 | "github.com/synctv-org/synctv/server/model" 11 | ) 12 | 13 | func StreamDanmu(ctx *gin.Context) { 14 | log := middlewares.GetLogger(ctx) 15 | 16 | room := middlewares.GetRoomEntry(ctx).Value() 17 | // user := middlewares.GetUserEntry(ctx).Value() 18 | 19 | m, err := room.GetMovieByID(ctx.Param("movieId")) 20 | if err != nil { 21 | log.Errorf("get movie by id error: %v", err) 22 | ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) 23 | return 24 | } 25 | 26 | v, err := vendors.NewVendorService(room, m) 27 | if err != nil { 28 | log.Errorf("new vendor service error: %v", err) 29 | ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) 30 | return 31 | } 32 | 33 | danmu, ok := v.(vendors.VendorDanmuService) 34 | if !ok { 35 | log.Errorf("vendor %s not support danmu", m.VendorInfo.Vendor) 36 | ctx.AbortWithStatusJSON( 37 | http.StatusBadRequest, 38 | model.NewAPIErrorStringResp("vendor not support danmu"), 39 | ) 40 | return 41 | } 42 | 43 | c, cancel := context.WithCancel(ctx.Request.Context()) 44 | defer cancel() 45 | 46 | err = danmu.StreamDanmu(c, func(danmu string) error { 47 | ctx.SSEvent("danmu", danmu) 48 | if err := ctx.Err(); err != nil { 49 | return err 50 | } 51 | ctx.Writer.Flush() 52 | return nil 53 | }) 54 | if err != nil { 55 | log.Errorf("stream danmu error: %v", err) 56 | ctx.SSEvent("error", err.Error()) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /server/handlers/proxy/buffer.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "sync" 7 | ) 8 | 9 | const ( 10 | DefaultBufferSize = 16 * 1024 11 | ) 12 | 13 | var sharedBufferPool = sync.Pool{ 14 | New: func() any { 15 | buffer := make([]byte, DefaultBufferSize) 16 | return &buffer 17 | }, 18 | } 19 | 20 | func getBuffer() *[]byte { 21 | buf, ok := sharedBufferPool.Get().(*[]byte) 22 | if !ok { 23 | panic("sharedBufferPool.Get() returned a non-[]byte value") 24 | } 25 | return buf 26 | } 27 | 28 | func putBuffer(buffer *[]byte) { 29 | sharedBufferPool.Put(buffer) 30 | } 31 | 32 | func copyBuffer(dst io.Writer, src io.Reader) (written int64, err error) { 33 | buf := getBuffer() 34 | defer putBuffer(buf) 35 | for { 36 | nr, er := src.Read(*buf) 37 | if nr > 0 { 38 | nw, ew := dst.Write((*buf)[0:nr]) 39 | if nw < 0 || nr < nw { 40 | nw = 0 41 | if ew == nil { 42 | ew = errors.New("invalid write result") 43 | } 44 | } 45 | written += int64(nw) 46 | if ew != nil { 47 | err = ew 48 | break 49 | } 50 | if nr != nw { 51 | err = io.ErrShortWrite 52 | break 53 | } 54 | } 55 | if er != nil { 56 | if er != io.EOF { 57 | err = er 58 | } 59 | break 60 | } 61 | } 62 | return written, err 63 | } 64 | -------------------------------------------------------------------------------- /server/handlers/public.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/synctv-org/synctv/internal/bootstrap" 9 | "github.com/synctv-org/synctv/internal/email" 10 | "github.com/synctv-org/synctv/internal/settings" 11 | "github.com/synctv-org/synctv/server/middlewares" 12 | "github.com/synctv-org/synctv/server/model" 13 | ) 14 | 15 | type publicSettings struct { 16 | EmailWhitelist []string `json:"emailWhitelist,omitempty"` 17 | PasswordDisableSignup bool `json:"passwordDisableSignup"` 18 | EmailEnable bool `json:"emailEnable"` 19 | EmailDisableSignup bool `json:"emailDisableSignup"` 20 | EmailWhitelistEnabled bool `json:"emailWhitelistEnabled"` 21 | Oauth2DisableSignup bool `json:"oauth2DisableSignup"` 22 | GuestEnable bool `json:"guestEnable"` 23 | P2PZone string `json:"p2pZone"` 24 | } 25 | 26 | func Settings(ctx *gin.Context) { 27 | log := middlewares.GetLogger(ctx) 28 | 29 | oauth2SignupEnabled, err := bootstrap.Oauth2SignupEnabledCache.Get(ctx) 30 | if err != nil { 31 | log.Errorf("failed to get oauth2 signup enabled: %v", err) 32 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 33 | return 34 | } 35 | ctx.JSON(200, model.NewAPIDataResp( 36 | &publicSettings{ 37 | PasswordDisableSignup: settings.DisableUserSignup.Get() || 38 | !settings.EnablePasswordSignup.Get(), 39 | 40 | EmailEnable: email.EnableEmail.Get(), 41 | EmailDisableSignup: settings.DisableUserSignup.Get() || 42 | email.DisableUserSignup.Get(), 43 | EmailWhitelistEnabled: email.EmailSignupWhiteListEnable.Get(), 44 | EmailWhitelist: strings.Split(email.EmailSignupWhiteList.Get(), ","), 45 | 46 | Oauth2DisableSignup: settings.DisableUserSignup.Get() || len(oauth2SignupEnabled) == 0, 47 | 48 | GuestEnable: settings.EnableGuest.Get(), 49 | P2PZone: settings.P2PZone.Get(), 50 | }, 51 | )) 52 | } 53 | -------------------------------------------------------------------------------- /server/handlers/root.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/synctv-org/synctv/internal/op" 8 | "github.com/synctv-org/synctv/server/middlewares" 9 | "github.com/synctv-org/synctv/server/model" 10 | ) 11 | 12 | func RootAddAdmin(ctx *gin.Context) { 13 | user := middlewares.GetUserEntry(ctx).Value() 14 | log := middlewares.GetLogger(ctx) 15 | 16 | req := model.IDReq{} 17 | if err := model.Decode(ctx, &req); err != nil { 18 | log.Errorf("failed to decode request: %v", err) 19 | ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) 20 | return 21 | } 22 | 23 | if req.ID == user.ID { 24 | log.Errorf("cannot add yourself") 25 | ctx.AbortWithStatusJSON( 26 | http.StatusBadRequest, 27 | model.NewAPIErrorStringResp("cannot add yourself"), 28 | ) 29 | return 30 | } 31 | u, err := op.LoadOrInitUserByID(req.ID) 32 | if err != nil { 33 | log.Errorf("failed to load user: %v", err) 34 | ctx.AbortWithStatusJSON( 35 | http.StatusInternalServerError, 36 | model.NewAPIErrorStringResp("user not found"), 37 | ) 38 | return 39 | } 40 | if u.Value().IsAdmin() { 41 | log.Errorf("user is already admin") 42 | ctx.AbortWithStatusJSON( 43 | http.StatusBadRequest, 44 | model.NewAPIErrorStringResp("user is already admin"), 45 | ) 46 | return 47 | } 48 | 49 | if err := u.Value().SetAdminRole(); err != nil { 50 | log.Errorf("failed to set role: %v", err) 51 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 52 | return 53 | } 54 | 55 | ctx.Status(http.StatusNoContent) 56 | } 57 | 58 | func RootDeleteAdmin(ctx *gin.Context) { 59 | user := middlewares.GetUserEntry(ctx) 60 | log := middlewares.GetLogger(ctx) 61 | 62 | req := model.IDReq{} 63 | if err := model.Decode(ctx, &req); err != nil { 64 | log.Errorf("failed to decode request: %v", err) 65 | ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) 66 | return 67 | } 68 | 69 | if req.ID == user.Value().ID { 70 | log.Errorf("cannot remove yourself") 71 | ctx.AbortWithStatusJSON( 72 | http.StatusBadRequest, 73 | model.NewAPIErrorStringResp("cannot remove yourself"), 74 | ) 75 | return 76 | } 77 | u, err := op.LoadOrInitUserByID(req.ID) 78 | if err != nil { 79 | log.Errorf("failed to load user: %v", err) 80 | ctx.AbortWithStatusJSON( 81 | http.StatusInternalServerError, 82 | model.NewAPIErrorStringResp("user not found"), 83 | ) 84 | return 85 | } 86 | if u.Value().IsRoot() { 87 | log.Errorf("cannot remove root") 88 | ctx.AbortWithStatusJSON( 89 | http.StatusBadRequest, 90 | model.NewAPIErrorStringResp("cannot remove root"), 91 | ) 92 | return 93 | } 94 | 95 | if err := u.Value().SetUserRole(); err != nil { 96 | log.Errorf("failed to set role: %v", err) 97 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 98 | return 99 | } 100 | 101 | ctx.Status(http.StatusNoContent) 102 | } 103 | -------------------------------------------------------------------------------- /server/handlers/vendors/vendorAlist/me.go: -------------------------------------------------------------------------------- 1 | package vendoralist 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/synctv-org/synctv/internal/db" 9 | "github.com/synctv-org/synctv/internal/vendor" 10 | "github.com/synctv-org/synctv/server/middlewares" 11 | "github.com/synctv-org/synctv/server/model" 12 | "github.com/synctv-org/vendors/api/alist" 13 | ) 14 | 15 | type AlistMeResp = model.VendorMeResp[*alist.MeResp] 16 | 17 | func Me(ctx *gin.Context) { 18 | user := middlewares.GetUserEntry(ctx).Value() 19 | 20 | serverID := ctx.Query("serverID") 21 | if serverID == "" { 22 | ctx.AbortWithStatusJSON( 23 | http.StatusBadRequest, 24 | model.NewAPIErrorResp(errors.New("serverID is required")), 25 | ) 26 | return 27 | } 28 | 29 | aucd, err := user.AlistCache().LoadOrStore(ctx, serverID) 30 | if err != nil { 31 | if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { 32 | ctx.JSON(http.StatusBadRequest, model.NewAPIErrorStringResp("alist server not found")) 33 | return 34 | } 35 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 36 | return 37 | } 38 | 39 | resp, err := vendor.LoadAlistClient(aucd.Backend).Me(ctx, &alist.MeReq{ 40 | Host: aucd.Host, 41 | Token: aucd.Token, 42 | }) 43 | if err != nil { 44 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 45 | return 46 | } 47 | 48 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&AlistMeResp{ 49 | IsLogin: true, 50 | Info: resp, 51 | })) 52 | } 53 | 54 | type AlistBindsResp []*struct { 55 | ServerID string `json:"serverId"` 56 | Host string `json:"host"` 57 | } 58 | 59 | func Binds(ctx *gin.Context) { 60 | user := middlewares.GetUserEntry(ctx).Value() 61 | 62 | ev, err := db.GetAlistVendors(user.ID) 63 | if err != nil { 64 | if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { 65 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&AlistMeResp{ 66 | IsLogin: false, 67 | })) 68 | return 69 | } 70 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 71 | return 72 | } 73 | 74 | resp := make(AlistBindsResp, len(ev)) 75 | for i, v := range ev { 76 | resp[i] = &struct { 77 | ServerID string `json:"serverId"` 78 | Host string `json:"host"` 79 | }{ 80 | ServerID: v.ServerID, 81 | Host: v.Host, 82 | } 83 | } 84 | 85 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) 86 | } 87 | -------------------------------------------------------------------------------- /server/handlers/vendors/vendorBilibili/me.go: -------------------------------------------------------------------------------- 1 | package vendorbilibili 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/synctv-org/synctv/internal/db" 9 | "github.com/synctv-org/synctv/internal/vendor" 10 | "github.com/synctv-org/synctv/server/middlewares" 11 | "github.com/synctv-org/synctv/server/model" 12 | "github.com/synctv-org/synctv/utils" 13 | "github.com/synctv-org/vendors/api/bilibili" 14 | ) 15 | 16 | type BilibiliMeResp = model.VendorMeResp[*bilibili.UserInfoResp] 17 | 18 | func Me(ctx *gin.Context) { 19 | user := middlewares.GetUserEntry(ctx).Value() 20 | 21 | bucd, err := user.BilibiliCache().Get(ctx) 22 | if err != nil { 23 | if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { 24 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&BilibiliMeResp{ 25 | IsLogin: false, 26 | })) 27 | return 28 | } 29 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 30 | return 31 | } 32 | if len(bucd.Cookies) == 0 { 33 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&BilibiliMeResp{ 34 | IsLogin: false, 35 | })) 36 | return 37 | } 38 | resp, err := vendor.LoadBilibiliClient(bucd.Backend).UserInfo(ctx, &bilibili.UserInfoReq{ 39 | Cookies: utils.HTTPCookieToMap(bucd.Cookies), 40 | }) 41 | if err != nil { 42 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 43 | return 44 | } 45 | 46 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&BilibiliMeResp{ 47 | IsLogin: resp.GetIsLogin(), 48 | Info: resp, 49 | })) 50 | } 51 | -------------------------------------------------------------------------------- /server/handlers/vendors/vendorEmby/me.go: -------------------------------------------------------------------------------- 1 | package vendoremby 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/synctv-org/synctv/internal/db" 9 | "github.com/synctv-org/synctv/internal/vendor" 10 | "github.com/synctv-org/synctv/server/middlewares" 11 | "github.com/synctv-org/synctv/server/model" 12 | "github.com/synctv-org/vendors/api/emby" 13 | ) 14 | 15 | type EmbyMeResp = model.VendorMeResp[*emby.SystemInfoResp] 16 | 17 | func Me(ctx *gin.Context) { 18 | user := middlewares.GetUserEntry(ctx).Value() 19 | 20 | serverID := ctx.Query("serverID") 21 | if serverID == "" { 22 | ctx.AbortWithStatusJSON( 23 | http.StatusBadRequest, 24 | model.NewAPIErrorResp(errors.New("serverID is required")), 25 | ) 26 | return 27 | } 28 | 29 | eucd, err := user.EmbyCache().LoadOrStore(ctx, serverID) 30 | if err != nil { 31 | if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { 32 | ctx.JSON(http.StatusBadRequest, model.NewAPIErrorStringResp("emby server not found")) 33 | return 34 | } 35 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 36 | return 37 | } 38 | 39 | data, err := vendor.LoadEmbyClient(eucd.Backend).GetSystemInfo(ctx, &emby.SystemInfoReq{ 40 | Host: eucd.Host, 41 | Token: eucd.APIKey, 42 | }) 43 | if err != nil { 44 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 45 | return 46 | } 47 | 48 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&EmbyMeResp{ 49 | IsLogin: true, 50 | Info: data, 51 | })) 52 | } 53 | 54 | type EmbyBindsResp []*struct { 55 | ServerID string `json:"serverId"` 56 | Host string `json:"host"` 57 | } 58 | 59 | func Binds(ctx *gin.Context) { 60 | user := middlewares.GetUserEntry(ctx).Value() 61 | 62 | ev, err := db.GetEmbyVendors(user.ID) 63 | if err != nil { 64 | if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { 65 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&EmbyMeResp{ 66 | IsLogin: false, 67 | })) 68 | return 69 | } 70 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 71 | return 72 | } 73 | 74 | resp := make(EmbyBindsResp, len(ev)) 75 | for i, v := range ev { 76 | resp[i] = &struct { 77 | ServerID string `json:"serverId"` 78 | Host string `json:"host"` 79 | }{ 80 | ServerID: v.ServerID, 81 | Host: v.Host, 82 | } 83 | } 84 | 85 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) 86 | } 87 | -------------------------------------------------------------------------------- /server/handlers/vendors/vendoralist/me.go: -------------------------------------------------------------------------------- 1 | package vendoralist 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/synctv-org/synctv/internal/db" 9 | "github.com/synctv-org/synctv/internal/vendor" 10 | "github.com/synctv-org/synctv/server/middlewares" 11 | "github.com/synctv-org/synctv/server/model" 12 | "github.com/synctv-org/vendors/api/alist" 13 | ) 14 | 15 | type AlistMeResp = model.VendorMeResp[*alist.MeResp] 16 | 17 | func Me(ctx *gin.Context) { 18 | user := middlewares.GetUserEntry(ctx).Value() 19 | 20 | serverID := ctx.Query("serverID") 21 | if serverID == "" { 22 | ctx.AbortWithStatusJSON( 23 | http.StatusBadRequest, 24 | model.NewAPIErrorResp(errors.New("serverID is required")), 25 | ) 26 | return 27 | } 28 | 29 | aucd, err := user.AlistCache().LoadOrStore(ctx, serverID) 30 | if err != nil { 31 | if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { 32 | ctx.JSON(http.StatusBadRequest, model.NewAPIErrorStringResp("alist server not found")) 33 | return 34 | } 35 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 36 | return 37 | } 38 | 39 | resp, err := vendor.LoadAlistClient(aucd.Backend).Me(ctx, &alist.MeReq{ 40 | Host: aucd.Host, 41 | Token: aucd.Token, 42 | }) 43 | if err != nil { 44 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 45 | return 46 | } 47 | 48 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&AlistMeResp{ 49 | IsLogin: true, 50 | Info: resp, 51 | })) 52 | } 53 | 54 | type AlistBindsResp []*struct { 55 | ServerID string `json:"serverId"` 56 | Host string `json:"host"` 57 | } 58 | 59 | func Binds(ctx *gin.Context) { 60 | user := middlewares.GetUserEntry(ctx).Value() 61 | 62 | ev, err := db.GetAlistVendors(user.ID) 63 | if err != nil { 64 | if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { 65 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&AlistMeResp{ 66 | IsLogin: false, 67 | })) 68 | return 69 | } 70 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 71 | return 72 | } 73 | 74 | resp := make(AlistBindsResp, len(ev)) 75 | for i, v := range ev { 76 | resp[i] = &struct { 77 | ServerID string `json:"serverId"` 78 | Host string `json:"host"` 79 | }{ 80 | ServerID: v.ServerID, 81 | Host: v.Host, 82 | } 83 | } 84 | 85 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) 86 | } 87 | -------------------------------------------------------------------------------- /server/handlers/vendors/vendorbilibili/me.go: -------------------------------------------------------------------------------- 1 | package vendorbilibili 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/synctv-org/synctv/internal/db" 9 | "github.com/synctv-org/synctv/internal/vendor" 10 | "github.com/synctv-org/synctv/server/middlewares" 11 | "github.com/synctv-org/synctv/server/model" 12 | "github.com/synctv-org/synctv/utils" 13 | "github.com/synctv-org/vendors/api/bilibili" 14 | ) 15 | 16 | type BilibiliMeResp = model.VendorMeResp[*bilibili.UserInfoResp] 17 | 18 | func Me(ctx *gin.Context) { 19 | user := middlewares.GetUserEntry(ctx).Value() 20 | 21 | bucd, err := user.BilibiliCache().Get(ctx) 22 | if err != nil { 23 | if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { 24 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&BilibiliMeResp{ 25 | IsLogin: false, 26 | })) 27 | return 28 | } 29 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 30 | return 31 | } 32 | if len(bucd.Cookies) == 0 { 33 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&BilibiliMeResp{ 34 | IsLogin: false, 35 | })) 36 | return 37 | } 38 | resp, err := vendor.LoadBilibiliClient(bucd.Backend).UserInfo(ctx, &bilibili.UserInfoReq{ 39 | Cookies: utils.HTTPCookieToMap(bucd.Cookies), 40 | }) 41 | if err != nil { 42 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 43 | return 44 | } 45 | 46 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&BilibiliMeResp{ 47 | IsLogin: resp.GetIsLogin(), 48 | Info: resp, 49 | })) 50 | } 51 | -------------------------------------------------------------------------------- /server/handlers/vendors/vendoremby/me.go: -------------------------------------------------------------------------------- 1 | package vendoremby 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/synctv-org/synctv/internal/db" 9 | "github.com/synctv-org/synctv/internal/vendor" 10 | "github.com/synctv-org/synctv/server/middlewares" 11 | "github.com/synctv-org/synctv/server/model" 12 | "github.com/synctv-org/vendors/api/emby" 13 | ) 14 | 15 | type EmbyMeResp = model.VendorMeResp[*emby.SystemInfoResp] 16 | 17 | func Me(ctx *gin.Context) { 18 | user := middlewares.GetUserEntry(ctx).Value() 19 | 20 | serverID := ctx.Query("serverID") 21 | if serverID == "" { 22 | ctx.AbortWithStatusJSON( 23 | http.StatusBadRequest, 24 | model.NewAPIErrorResp(errors.New("serverID is required")), 25 | ) 26 | return 27 | } 28 | 29 | eucd, err := user.EmbyCache().LoadOrStore(ctx, serverID) 30 | if err != nil { 31 | if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { 32 | ctx.JSON(http.StatusBadRequest, model.NewAPIErrorStringResp("emby server not found")) 33 | return 34 | } 35 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 36 | return 37 | } 38 | 39 | data, err := vendor.LoadEmbyClient(eucd.Backend).GetSystemInfo(ctx, &emby.SystemInfoReq{ 40 | Host: eucd.Host, 41 | Token: eucd.APIKey, 42 | }) 43 | if err != nil { 44 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 45 | return 46 | } 47 | 48 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&EmbyMeResp{ 49 | IsLogin: true, 50 | Info: data, 51 | })) 52 | } 53 | 54 | type EmbyBindsResp []*struct { 55 | ServerID string `json:"serverId"` 56 | Host string `json:"host"` 57 | } 58 | 59 | func Binds(ctx *gin.Context) { 60 | user := middlewares.GetUserEntry(ctx).Value() 61 | 62 | ev, err := db.GetEmbyVendors(user.ID) 63 | if err != nil { 64 | if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { 65 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(&EmbyMeResp{ 66 | IsLogin: false, 67 | })) 68 | return 69 | } 70 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 71 | return 72 | } 73 | 74 | resp := make(EmbyBindsResp, len(ev)) 75 | for i, v := range ev { 76 | resp[i] = &struct { 77 | ServerID string `json:"serverId"` 78 | Host string `json:"host"` 79 | }{ 80 | ServerID: v.ServerID, 81 | Host: v.Host, 82 | } 83 | } 84 | 85 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) 86 | } 87 | -------------------------------------------------------------------------------- /server/handlers/vendors/vendors.go: -------------------------------------------------------------------------------- 1 | package vendors 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "maps" 7 | "net/http" 8 | "slices" 9 | 10 | "github.com/gin-gonic/gin" 11 | dbModel "github.com/synctv-org/synctv/internal/model" 12 | "github.com/synctv-org/synctv/internal/op" 13 | "github.com/synctv-org/synctv/internal/vendor" 14 | "github.com/synctv-org/synctv/server/handlers/vendors/vendoralist" 15 | "github.com/synctv-org/synctv/server/handlers/vendors/vendorbilibili" 16 | "github.com/synctv-org/synctv/server/handlers/vendors/vendoremby" 17 | "github.com/synctv-org/synctv/server/model" 18 | ) 19 | 20 | func Backends(ctx *gin.Context) { 21 | var backends []string 22 | switch ctx.Param("vendor") { 23 | case dbModel.VendorBilibili: 24 | backends = slices.Collect(maps.Keys(vendor.LoadClients().BilibiliClients())) 25 | case dbModel.VendorAlist: 26 | backends = slices.Collect(maps.Keys(vendor.LoadClients().AlistClients())) 27 | case dbModel.VendorEmby: 28 | backends = slices.Collect(maps.Keys(vendor.LoadClients().EmbyClients())) 29 | default: 30 | ctx.AbortWithStatusJSON( 31 | http.StatusBadRequest, 32 | model.NewAPIErrorStringResp("invalid vendor name"), 33 | ) 34 | return 35 | } 36 | ctx.JSON(http.StatusOK, model.NewAPIDataResp(backends)) 37 | } 38 | 39 | type VendorService interface { 40 | ListDynamicMovie( 41 | ctx context.Context, 42 | reqUser *op.User, 43 | subPath, keyword string, 44 | page, _max int, 45 | ) (*model.MovieList, error) 46 | ProxyMovie(ctx *gin.Context) 47 | GenMovieInfo( 48 | ctx context.Context, 49 | reqUser *op.User, 50 | userAgent, userToken string, 51 | ) (*dbModel.Movie, error) 52 | } 53 | 54 | type VendorDanmuService interface { 55 | StreamDanmu(ctx context.Context, handler func(danmu string) error) error 56 | } 57 | 58 | func NewVendorService(room *op.Room, movie *op.Movie) (VendorService, error) { 59 | switch movie.VendorInfo.Vendor { 60 | case dbModel.VendorBilibili: 61 | return vendorbilibili.NewBilibiliVendorService(room, movie) 62 | case dbModel.VendorAlist: 63 | return vendoralist.NewAlistVendorService(room, movie) 64 | case dbModel.VendorEmby: 65 | return vendoremby.NewEmbyVendorService(room, movie) 66 | default: 67 | return nil, fmt.Errorf("vendor %s not support", movie.VendorInfo.Vendor) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /server/middlewares/cors.go: -------------------------------------------------------------------------------- 1 | package middlewares 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.AllowHeaders = []string{"*"} 12 | config.AllowMethods = []string{"*"} 13 | return cors.New(config) 14 | } 15 | -------------------------------------------------------------------------------- /server/middlewares/init.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gin-gonic/gin" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/synctv-org/synctv/internal/conf" 9 | limiter "github.com/ulule/limiter/v3" 10 | ) 11 | 12 | func Init(e *gin.Engine) { 13 | w := log.StandardLogger().Writer() 14 | e. 15 | Use(NewLog(log.StandardLogger())). 16 | Use(gin.RecoveryWithWriter(w)). 17 | Use(NewCors()) 18 | if conf.Conf.RateLimit.Enable { 19 | d, err := time.ParseDuration(conf.Conf.RateLimit.Period) 20 | if err != nil { 21 | log.Fatal(err) 22 | } 23 | options := []limiter.Option{ 24 | limiter.WithTrustForwardHeader(conf.Conf.RateLimit.TrustForwardHeader), 25 | } 26 | if conf.Conf.RateLimit.TrustedClientIPHeader != "" { 27 | options = append( 28 | options, 29 | limiter.WithClientIPHeader(conf.Conf.RateLimit.TrustedClientIPHeader), 30 | ) 31 | } 32 | e.Use(NewLimiter(d, conf.Conf.RateLimit.Limit, options...)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /server/middlewares/log.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sync" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/pkg/errors" 11 | "github.com/sirupsen/logrus" 12 | "github.com/synctv-org/synctv/server/model" 13 | ) 14 | 15 | var fieldsPool = sync.Pool{ 16 | New: func() any { 17 | return make(logrus.Fields, 6) 18 | }, 19 | } 20 | 21 | func NewLog(l *logrus.Logger) gin.HandlerFunc { 22 | return func(c *gin.Context) { 23 | fields, ok := fieldsPool.Get().(logrus.Fields) 24 | if !ok { 25 | c.JSON( 26 | http.StatusInternalServerError, 27 | model.NewAPIErrorResp(errors.New("invalid fields type")), 28 | ) 29 | return 30 | } 31 | defer func() { 32 | clear(fields) 33 | fieldsPool.Put(fields) 34 | }() 35 | 36 | entry := &logrus.Entry{ 37 | Logger: l, 38 | Data: fields, 39 | } 40 | c.Set("log", entry) 41 | 42 | start := time.Now() 43 | path := c.Request.URL.Path 44 | raw := c.Request.URL.RawQuery 45 | 46 | c.Next() 47 | 48 | param := gin.LogFormatterParams{ 49 | Request: c.Request, 50 | Keys: c.Keys, 51 | } 52 | 53 | // Stop timer 54 | param.Latency = time.Since(start) 55 | 56 | param.ClientIP = c.ClientIP() 57 | param.Method = c.Request.Method 58 | param.StatusCode = c.Writer.Status() 59 | param.ErrorMessage = c.Errors.ByType(gin.ErrorTypePrivate).String() 60 | 61 | param.BodySize = c.Writer.Size() 62 | 63 | if raw != "" { 64 | path = path + "?" + raw 65 | } 66 | 67 | param.Path = path 68 | 69 | logColor(entry, param) 70 | } 71 | } 72 | 73 | func logColor(logger *logrus.Entry, p gin.LogFormatterParams) { 74 | str := formatter(p) 75 | code := p.StatusCode 76 | switch { 77 | case code >= http.StatusBadRequest && code < http.StatusInternalServerError: 78 | logger.Error(str) 79 | default: 80 | logger.Info(str) 81 | } 82 | } 83 | 84 | func formatter(param gin.LogFormatterParams) string { 85 | var statusColor, methodColor, resetColor string 86 | if param.IsOutputColor() { 87 | statusColor = param.StatusCodeColor() 88 | methodColor = param.MethodColor() 89 | resetColor = param.ResetColor() 90 | } 91 | 92 | if param.Latency > time.Minute { 93 | param.Latency = param.Latency.Truncate(time.Second) 94 | } 95 | return fmt.Sprintf("[GIN] |%s %3d %s| %13v | %15s |%s %-7s %s %#v\n%s", 96 | statusColor, param.StatusCode, resetColor, 97 | param.Latency, 98 | param.ClientIP, 99 | methodColor, param.Method, resetColor, 100 | param.Path, 101 | param.ErrorMessage, 102 | ) 103 | } 104 | 105 | func GetLogger(c *gin.Context) *logrus.Entry { 106 | if log, ok := c.Get("log"); ok { 107 | entry, ok := log.(*logrus.Entry) 108 | if !ok { 109 | panic("invalid log type") 110 | } 111 | return entry 112 | } 113 | fields, ok := fieldsPool.Get().(logrus.Fields) 114 | if !ok { 115 | panic("invalid fields type") 116 | } 117 | entry := &logrus.Entry{ 118 | Logger: logrus.StandardLogger(), 119 | Data: fields, 120 | } 121 | c.Set("log", entry) 122 | return entry 123 | } 124 | -------------------------------------------------------------------------------- /server/middlewares/rateLimit.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/synctv-org/synctv/server/model" 9 | limiter "github.com/ulule/limiter/v3" 10 | mgin "github.com/ulule/limiter/v3/drivers/middleware/gin" 11 | "github.com/ulule/limiter/v3/drivers/store/memory" 12 | ) 13 | 14 | func NewLimiter(period time.Duration, limit int64, options ...limiter.Option) gin.HandlerFunc { 15 | limiter := limiter.New(memory.NewStore(), limiter.Rate{ 16 | Period: period, 17 | Limit: limit, 18 | }, options...) 19 | return mgin.NewMiddleware(limiter, mgin.WithLimitReachedHandler(func(c *gin.Context) { 20 | c.JSON(http.StatusTooManyRequests, model.NewAPIErrorStringResp("too many requests")) 21 | })) 22 | } 23 | -------------------------------------------------------------------------------- /server/model/api.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "regexp" 5 | "time" 6 | ) 7 | 8 | var ( 9 | // alnumReg = regexp.MustCompile(`^[[:alnum:]]+$`) 10 | alnumPrintReg = regexp.MustCompile(`^[[:print:][:alnum:]]+$`) 11 | alnumPrintHanReg = regexp.MustCompile(`^[[:print:][:alnum:]\p{Han}]+$`) 12 | emailReg = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) 13 | ) 14 | 15 | type APIResp struct { 16 | Data any `json:"data,omitempty"` 17 | Error string `json:"error,omitempty"` 18 | Time int64 `json:"time"` 19 | } 20 | 21 | func (ar *APIResp) SetError(err error) { 22 | ar.Error = err.Error() 23 | } 24 | 25 | func (ar *APIResp) SetDate(data any) { 26 | ar.Data = data 27 | } 28 | 29 | func NewAPIErrorResp(err error) *APIResp { 30 | return &APIResp{ 31 | Time: time.Now().UnixMicro(), 32 | Error: err.Error(), 33 | } 34 | } 35 | 36 | func NewAPIErrorStringResp(err string) *APIResp { 37 | return &APIResp{ 38 | Time: time.Now().UnixMicro(), 39 | Error: err, 40 | } 41 | } 42 | 43 | func NewAPIDataResp(data any) *APIResp { 44 | return &APIResp{ 45 | Time: time.Now().UnixMicro(), 46 | Data: data, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /server/model/auth.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/gin-gonic/gin" 7 | json "github.com/json-iterator/go" 8 | ) 9 | 10 | type OAuth2CallbackReq struct { 11 | Code string `json:"code"` 12 | State string `json:"state"` 13 | } 14 | 15 | var ( 16 | ErrInvalidOAuth2Code = errors.New("invalid oauth2 code") 17 | ErrInvalidOAuth2State = errors.New("invalid oauth2 state") 18 | ) 19 | 20 | func (o *OAuth2CallbackReq) Validate() error { 21 | if o.Code == "" { 22 | return ErrInvalidOAuth2Code 23 | } 24 | if o.State == "" { 25 | return ErrInvalidOAuth2State 26 | } 27 | return nil 28 | } 29 | 30 | func (o *OAuth2CallbackReq) Decode(ctx *gin.Context) error { 31 | return json.NewDecoder(ctx.Request.Body).Decode(o) 32 | } 33 | 34 | type OAuth2Req struct { 35 | Redirect string `json:"redirect"` 36 | } 37 | 38 | func (o *OAuth2Req) Validate() error { 39 | return nil 40 | } 41 | 42 | func (o *OAuth2Req) Decode(ctx *gin.Context) error { 43 | return json.NewDecoder(ctx.Request.Body).Decode(o) 44 | } 45 | -------------------------------------------------------------------------------- /server/model/decode.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | type Decoder interface { 6 | Decode(ctx *gin.Context) error 7 | Validate() error 8 | } 9 | 10 | func Decode(ctx *gin.Context, decoder Decoder) error { 11 | if err := decoder.Decode(ctx); err != nil { 12 | return err 13 | } 14 | if err := decoder.Validate(); err != nil { 15 | return err 16 | } 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /server/model/member.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | json "github.com/json-iterator/go" 6 | dbModel "github.com/synctv-org/synctv/internal/model" 7 | ) 8 | 9 | type RoomMembersResp struct { 10 | UserID string `json:"userId"` 11 | Username string `json:"username"` 12 | RoomID string `json:"roomId"` 13 | JoinAt int64 `json:"joinAt"` 14 | OnlineCount int `json:"onlineCount"` 15 | Permissions dbModel.RoomMemberPermission `json:"permissions"` 16 | AdminPermissions dbModel.RoomAdminPermission `json:"adminPermissions"` 17 | Role dbModel.RoomMemberRole `json:"role"` 18 | Status dbModel.RoomMemberStatus `json:"status"` 19 | } 20 | 21 | type ( 22 | RoomApproveMemberReq = UserIDReq 23 | RoomBanMemberReq = UserIDReq 24 | RoomUnbanMemberReq = UserIDReq 25 | ) 26 | 27 | type RoomSetMemberPermissionsReq struct { 28 | UserIDReq 29 | Permissions dbModel.RoomMemberPermission `json:"permissions"` 30 | } 31 | 32 | func (r *RoomSetMemberPermissionsReq) Decode(ctx *gin.Context) error { 33 | return json.NewDecoder(ctx.Request.Body).Decode(r) 34 | } 35 | 36 | type RoomMeResp struct { 37 | UserID string `json:"userId"` 38 | RoomID string `json:"roomId"` 39 | JoinAt int64 `json:"joinAt"` 40 | Role dbModel.RoomMemberRole `json:"role"` 41 | Status dbModel.RoomMemberStatus `json:"status"` 42 | Permissions dbModel.RoomMemberPermission `json:"permissions"` 43 | AdminPermissions dbModel.RoomAdminPermission `json:"adminPermissions"` 44 | } 45 | 46 | type RoomSetAdminReq struct { 47 | UserIDReq 48 | AdminPermissions dbModel.RoomAdminPermission `json:"adminPermissions"` 49 | } 50 | 51 | func (r *RoomSetAdminReq) Decode(ctx *gin.Context) error { 52 | return json.NewDecoder(ctx.Request.Body).Decode(r) 53 | } 54 | 55 | type RoomSetMemberReq struct { 56 | UserIDReq 57 | Permissions dbModel.RoomMemberPermission `json:"permissions"` 58 | } 59 | 60 | func (r *RoomSetMemberReq) Decode(ctx *gin.Context) error { 61 | return json.NewDecoder(ctx.Request.Body).Decode(r) 62 | } 63 | 64 | type RoomSetAdminPermissionsReq struct { 65 | UserIDReq 66 | AdminPermissions dbModel.RoomAdminPermission `json:"adminPermissions"` 67 | } 68 | 69 | func (r *RoomSetAdminPermissionsReq) Decode(ctx *gin.Context) error { 70 | return json.NewDecoder(ctx.Request.Body).Decode(r) 71 | } 72 | -------------------------------------------------------------------------------- /server/model/vendor.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | json "github.com/json-iterator/go" 10 | ) 11 | 12 | type VendorMeResp[T any] struct { 13 | Info T `json:"info,omitempty"` 14 | IsLogin bool `json:"isLogin"` 15 | } 16 | 17 | type VendorFSListResp[T any] struct { 18 | Paths []*Path `json:"paths"` 19 | Items []T `json:"items"` 20 | Total uint64 `json:"total"` 21 | } 22 | 23 | func GenDefaultPaths(path string, skipEmpty bool, paths ...*Path) []*Path { 24 | path = strings.TrimRight(path, "/") 25 | for _, v := range strings.Split(path, `/`) { 26 | if v == "" && skipEmpty { 27 | continue 28 | } 29 | if l := len(paths); l != 0 { 30 | paths = append(paths, &Path{ 31 | Name: v, 32 | Path: fmt.Sprintf("%s/%s", strings.TrimRight(paths[l-1].Path, "/"), v), 33 | }) 34 | } else { 35 | paths = append(paths, &Path{ 36 | Name: v, 37 | Path: v, 38 | }) 39 | } 40 | } 41 | return paths 42 | } 43 | 44 | type Path struct { 45 | Name string `json:"name"` 46 | Path string `json:"path"` 47 | } 48 | 49 | type Item struct { 50 | Name string `json:"name"` 51 | Path string `json:"path"` 52 | IsDir bool `json:"isDir"` 53 | } 54 | 55 | type ServerIDReq struct { 56 | ServerID string `json:"serverId"` 57 | } 58 | 59 | func (r *ServerIDReq) Validate() error { 60 | if r.ServerID == "" { 61 | return errors.New("serverId is required") 62 | } 63 | return nil 64 | } 65 | 66 | func (r *ServerIDReq) Decode(ctx *gin.Context) error { 67 | return json.NewDecoder(ctx.Request.Body).Decode(r) 68 | } 69 | -------------------------------------------------------------------------------- /server/oauth2/init.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/synctv-org/synctv/server/middlewares" 6 | ) 7 | 8 | func Init(e *gin.Engine) { 9 | { 10 | oauth2 := e.Group("/oauth2") 11 | needAuthOauth2 := oauth2.Group("") 12 | needAuthOauth2.Use(middlewares.AuthUserMiddleware) 13 | 14 | oauth2.GET("/enabled", OAuth2EnabledAPI) 15 | 16 | oauth2.GET("/enabled/signup", OAuth2SignupEnabledAPI) 17 | 18 | oauth2.GET("/login/:type", OAuth2) 19 | 20 | oauth2.POST("/login/:type", OAuth2Api) 21 | 22 | oauth2.GET("/callback/:type", OAuth2Callback) 23 | 24 | oauth2.POST("/callback/:type", OAuth2CallbackAPI) 25 | 26 | needAuthOauth2.POST("/bind/:type", BindAPI) 27 | 28 | needAuthOauth2.POST("/unbind/:type", UnBindAPI) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/oauth2/model.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | type CallbackType = string 4 | 5 | const ( 6 | CallbackTypeAuth CallbackType = "auth" 7 | CallbackTypeBind CallbackType = "bind" 8 | ) 9 | -------------------------------------------------------------------------------- /server/oauth2/oauth2.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/synctv-org/synctv/internal/bootstrap" 8 | "github.com/synctv-org/synctv/server/middlewares" 9 | "github.com/synctv-org/synctv/server/model" 10 | ) 11 | 12 | func OAuth2EnabledAPI(ctx *gin.Context) { 13 | log := middlewares.GetLogger(ctx) 14 | 15 | data, err := bootstrap.Oauth2EnabledCache.Get(ctx) 16 | if err != nil { 17 | log.Errorf("failed to get oauth2 enabled: %v", err) 18 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 19 | return 20 | } 21 | 22 | ctx.JSON(200, gin.H{ 23 | "enabled": data, 24 | }) 25 | } 26 | 27 | func OAuth2SignupEnabledAPI(ctx *gin.Context) { 28 | log := middlewares.GetLogger(ctx) 29 | 30 | oauth2SignupEnabled, err := bootstrap.Oauth2SignupEnabledCache.Get(ctx) 31 | if err != nil { 32 | log.Errorf("failed to get oauth2 signup enabled: %v", err) 33 | ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) 34 | return 35 | } 36 | 37 | ctx.JSON(200, gin.H{ 38 | "signupEnabled": oauth2SignupEnabled, 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /server/oauth2/render.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "embed" 5 | "html/template" 6 | "time" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/synctv-org/synctv/internal/provider" 10 | "github.com/zijiren233/gencontainer/synccache" 11 | ) 12 | 13 | //go:embed templates/*.html 14 | var temp embed.FS 15 | 16 | var ( 17 | redirectTemplate *template.Template 18 | tokenTemplate *template.Template 19 | states *synccache.SyncCache[string, stateHandler] 20 | ) 21 | 22 | type stateHandler func(ctx *gin.Context, pi provider.Interface, code string) 23 | 24 | func RenderRedirect(ctx *gin.Context, url string) error { 25 | ctx.Header("Content-Type", "text/html; charset=utf-8") 26 | return redirectTemplate.Execute(ctx.Writer, url) 27 | } 28 | 29 | func RenderToken(ctx *gin.Context, url, token string) error { 30 | ctx.Header("Content-Type", "text/html; charset=utf-8") 31 | return tokenTemplate.Execute(ctx.Writer, map[string]string{"Url": url, "Token": token}) 32 | } 33 | 34 | func init() { 35 | redirectTemplate = template.Must(template.ParseFS(temp, "templates/redirect.html")) 36 | tokenTemplate = template.Must(template.ParseFS(temp, "templates/token.html")) 37 | states = synccache.NewSyncCache[string, stateHandler](time.Minute * 10) 38 | } 39 | -------------------------------------------------------------------------------- /server/oauth2/templates/redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Redirecting.. 9 | 10 | 11 | 12 |

If you are not redirected, please click here.

13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /server/oauth2/templates/token.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Redirecting.. 9 | 10 | 11 | 12 |

If you are not redirected, please click here.

13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /server/router.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/synctv-org/synctv/cmd/flags" 6 | "github.com/synctv-org/synctv/server/handlers" 7 | "github.com/synctv-org/synctv/server/middlewares" 8 | auth "github.com/synctv-org/synctv/server/oauth2" 9 | "github.com/synctv-org/synctv/server/static" 10 | ) 11 | 12 | func Init(e *gin.Engine) { 13 | middlewares.Init(e) 14 | auth.Init(e) 15 | handlers.Init(e) 16 | if !flags.Server.DisableWeb { 17 | static.Init(e) 18 | } 19 | } 20 | 21 | func NewAndInit() (e *gin.Engine) { 22 | e = gin.New() 23 | Init(e) 24 | return 25 | } 26 | -------------------------------------------------------------------------------- /server/static/static.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | import ( 4 | "io/fs" 5 | "net/http" 6 | "os" 7 | "strings" 8 | 9 | "github.com/gin-gonic/gin" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/synctv-org/synctv/cmd/flags" 12 | "github.com/synctv-org/synctv/public" 13 | ) 14 | 15 | func Init(e *gin.Engine) { 16 | e.GET("/", func(ctx *gin.Context) { 17 | ctx.Redirect(http.StatusMovedPermanently, "/web/") 18 | }) 19 | 20 | web := e.Group("/web") 21 | 22 | if flags.Server.WebPath == "" { 23 | err := SiglePageAppFS(web, public.Public, true) 24 | if err != nil { 25 | log.Fatalf("failed to init fs router: %v", err) 26 | } 27 | 28 | // err := initFSRouter(web, public.Public.(fs.ReadDirFS), ".") 29 | // if err != nil { 30 | // panic(err) 31 | // } 32 | 33 | // e.NoRoute(func(ctx *gin.Context) { 34 | // if strings.HasPrefix(ctx.Request.URL.Path, "/web/") { 35 | // ctx.FileFromFS("", http.FS(public.Public)) 36 | // return 37 | // } 38 | // }) 39 | } else { 40 | err := SiglePageAppFS(web, os.DirFS(flags.Server.WebPath), false) 41 | if err != nil { 42 | log.Fatalf("failed to init fs router: %v", err) 43 | } 44 | 45 | // web.Static("/", flags.WebPath) 46 | 47 | // e.NoRoute(func(ctx *gin.Context) { 48 | // if strings.HasPrefix(ctx.Request.URL.Path, "/web/") { 49 | // ctx.FileFromFS("", http.Dir(flags.WebPath)) 50 | // return 51 | // } 52 | // }) 53 | } 54 | } 55 | 56 | func newFSHandler(fileSys fs.FS) func(ctx *gin.Context) { 57 | return func(ctx *gin.Context) { 58 | fp := strings.Trim(ctx.Param("filepath"), "/") 59 | f, err := fileSys.Open(fp) 60 | if err != nil { 61 | fp = "" 62 | } else { 63 | f.Close() 64 | } 65 | ctx.FileFromFS(fp, http.FS(fileSys)) 66 | } 67 | } 68 | 69 | func newStatCachedFSHandler(fileSys fs.FS) (func(ctx *gin.Context), error) { 70 | cache := make(map[string]struct{}) 71 | err := fs.WalkDir(fileSys, ".", func(path string, _ fs.DirEntry, _ error) error { 72 | cache[`/`+path] = struct{}{} 73 | return nil 74 | }) 75 | if err != nil { 76 | return nil, err 77 | } 78 | return func(ctx *gin.Context) { 79 | fp := ctx.Param("filepath") 80 | if _, ok := cache[fp]; !ok { 81 | fp = "" 82 | } 83 | ctx.FileFromFS(fp, http.FS(fileSys)) 84 | }, nil 85 | } 86 | 87 | func SiglePageAppFS(r *gin.RouterGroup, fileSys fs.FS, cacheStat bool) error { 88 | var h func(ctx *gin.Context) 89 | if cacheStat { 90 | var err error 91 | h, err = newStatCachedFSHandler(fileSys) 92 | if err != nil { 93 | return err 94 | } 95 | } else { 96 | h = newFSHandler(fileSys) 97 | } 98 | r.GET("/*filepath", h) 99 | r.HEAD("/*filepath", h) 100 | return nil 101 | } 102 | 103 | // func initFSRouter(e *gin.RouterGroup, f fs.ReadDirFS, path string) error { 104 | // dirs, err := f.ReadDir(path) 105 | // if err != nil { 106 | // return err 107 | // } 108 | // for _, dir := range dirs { 109 | // u, err := url.JoinPath(path, dir.Name()) 110 | // if err != nil { 111 | // return err 112 | // } 113 | // if dir.IsDir() { 114 | // err = initFSRouter(e, f, u) 115 | // if err != nil { 116 | // return err 117 | // } 118 | // } else { 119 | // e.StaticFileFS(u, u, http.FS(f)) 120 | // } 121 | // } 122 | // return nil 123 | // } 124 | -------------------------------------------------------------------------------- /utils/crypto.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "errors" 9 | "io" 10 | ) 11 | 12 | func Crypto(v, key []byte) ([]byte, error) { 13 | block, err := aes.NewCipher(key) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | // Use GCM as an AEAD mode instead of CFB 19 | aead, err := cipher.NewGCM(block) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | // Create a nonce for this encryption 25 | nonce := make([]byte, aead.NonceSize()) 26 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 27 | return nil, err 28 | } 29 | 30 | // Encrypt and authenticate the plaintext 31 | ciphertext := aead.Seal(nonce, nonce, v, nil) 32 | return ciphertext, nil 33 | } 34 | 35 | func Decrypto(v, key []byte) ([]byte, error) { 36 | block, err := aes.NewCipher(key) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | // Use GCM as an AEAD mode instead of CFB 42 | aead, err := cipher.NewGCM(block) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | // Check if the ciphertext is at least as long as the nonce 48 | nonceSize := aead.NonceSize() 49 | if len(v) < nonceSize { 50 | return nil, errors.New("ciphertext too short") 51 | } 52 | 53 | // Extract the nonce from the ciphertext 54 | nonce, ciphertext := v[:nonceSize], v[nonceSize:] 55 | 56 | // Decrypt and verify the ciphertext 57 | plaintext, err := aead.Open(nil, nonce, ciphertext, nil) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | return plaintext, nil 63 | } 64 | 65 | func CryptoToBase64(v, key []byte) (string, error) { 66 | ciphertext, err := Crypto(v, key) 67 | if err != nil { 68 | return "", err 69 | } 70 | return base64.StdEncoding.EncodeToString(ciphertext), nil 71 | } 72 | 73 | func DecryptoFromBase64(v string, key []byte) ([]byte, error) { 74 | ciphertext, err := base64.StdEncoding.DecodeString(v) 75 | if err != nil { 76 | return nil, err 77 | } 78 | return Decrypto(ciphertext, key) 79 | } 80 | 81 | func GenCryptoKey(base string) []byte { 82 | key := make([]byte, 32) 83 | for i := range len(base) { 84 | key[i%32] ^= base[i] 85 | } 86 | return key 87 | } 88 | 89 | func GenCryptoKeyWithBytes(base []byte) []byte { 90 | key := make([]byte, 32) 91 | for i := range base { 92 | key[i%32] ^= base[i] 93 | } 94 | return key 95 | } 96 | -------------------------------------------------------------------------------- /utils/crypto_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/synctv-org/synctv/utils" 7 | ) 8 | 9 | func TestCrypto(t *testing.T) { 10 | m := []byte("hello world") 11 | key := []byte(utils.RandString(32)) 12 | m, err := utils.Crypto(m, key) 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | t.Log(string(m)) 17 | m, err = utils.Decrypto(m, key) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | t.Log(string(m)) 22 | } 23 | -------------------------------------------------------------------------------- /utils/fastJSONSerializer/fastJSONSerializer.go: -------------------------------------------------------------------------------- 1 | package fastjsonserializer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | 8 | jsoniter "github.com/json-iterator/go" 9 | "github.com/zijiren233/stream" 10 | "gorm.io/gorm/schema" 11 | ) 12 | 13 | var json = jsoniter.ConfigCompatibleWithStandardLibrary 14 | 15 | type JSONSerializer struct{} 16 | 17 | func (*JSONSerializer) Scan( 18 | ctx context.Context, 19 | field *schema.Field, 20 | dst reflect.Value, 21 | dbValue any, 22 | ) (err error) { 23 | fieldValue := reflect.New(field.FieldType) 24 | 25 | if dbValue != nil { 26 | var bytes []byte 27 | switch v := dbValue.(type) { 28 | case []byte: 29 | bytes = v 30 | case string: 31 | bytes = stream.StringToBytes(v) 32 | default: 33 | return fmt.Errorf("failed to unmarshal JSONB value: %#v", dbValue) 34 | } 35 | 36 | if len(bytes) == 0 { 37 | field.ReflectValueOf(ctx, dst).Set(reflect.Zero(field.FieldType)) 38 | return nil 39 | } 40 | 41 | err = json.Unmarshal(bytes, fieldValue.Interface()) 42 | } 43 | 44 | field.ReflectValueOf(ctx, dst).Set(fieldValue.Elem()) 45 | return 46 | } 47 | 48 | func (*JSONSerializer) Value( 49 | _ context.Context, 50 | _ *schema.Field, 51 | _ reflect.Value, 52 | fieldValue any, 53 | ) (any, error) { 54 | return json.Marshal(fieldValue) 55 | } 56 | 57 | func init() { 58 | schema.RegisterSerializer("fastjson", new(JSONSerializer)) 59 | } 60 | -------------------------------------------------------------------------------- /utils/m3u8/m3u8.go: -------------------------------------------------------------------------------- 1 | package m3u8 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | func GetM3u8AllSegments(m3u8Str, baseURL string) ([]string, error) { 11 | var segments []string 12 | err := RangeM3u8SegmentsWithBaseURL(m3u8Str, baseURL, func(segmentUrl string) (bool, error) { 13 | segments = append(segments, segmentUrl) 14 | return true, nil 15 | }) 16 | if err != nil { 17 | return nil, err 18 | } 19 | return segments, nil 20 | } 21 | 22 | func RangeM3u8Segments(m3u8Str string, callback func(segmentUrl string) (bool, error)) error { 23 | scanner := bufio.NewScanner(strings.NewReader(m3u8Str)) 24 | for scanner.Scan() { 25 | line := strings.TrimSpace(scanner.Text()) 26 | if line != "" && !strings.HasPrefix(line, "#") { 27 | if ok, err := callback(line); err != nil { 28 | return err 29 | } else if !ok { 30 | break 31 | } 32 | } 33 | } 34 | if err := scanner.Err(); err != nil { 35 | return fmt.Errorf("scan m3u8 error: %w", err) 36 | } 37 | return nil 38 | } 39 | 40 | func RangeM3u8SegmentsWithBaseURL( 41 | m3u8Str, baseURL string, 42 | callback func(segmentURL string) (bool, error), 43 | ) error { 44 | baseURLParsed, err := url.Parse(baseURL) 45 | if err != nil { 46 | return fmt.Errorf("parse base url error: %w", err) 47 | } 48 | return RangeM3u8Segments(m3u8Str, func(segmentURL string) (bool, error) { 49 | if !strings.HasPrefix(segmentURL, "http://") && !strings.HasPrefix(segmentURL, "https://") { 50 | segmentURLParsed, err := url.Parse(segmentURL) 51 | if err != nil { 52 | return false, fmt.Errorf("parse segment url error: %w", err) 53 | } 54 | segmentURL = baseURLParsed.ResolveReference(segmentURLParsed).String() 55 | } 56 | return callback(segmentURL) 57 | }) 58 | } 59 | 60 | func ReplaceM3u8Segments( 61 | m3u8Str string, 62 | callback func(segmentURL string) (string, error), 63 | ) (string, error) { 64 | var result strings.Builder 65 | scanner := bufio.NewScanner(strings.NewReader(m3u8Str)) 66 | for scanner.Scan() { 67 | line := strings.TrimSpace(scanner.Text()) 68 | if line != "" && !strings.HasPrefix(line, "#") { 69 | newSegment, err := callback(line) 70 | if err != nil { 71 | return "", fmt.Errorf("callback error: %w", err) 72 | } 73 | result.WriteString(newSegment) 74 | } else { 75 | result.WriteString(line) 76 | } 77 | result.WriteString("\n") 78 | } 79 | if err := scanner.Err(); err != nil { 80 | return "", fmt.Errorf("scan m3u8 error: %w", err) 81 | } 82 | return result.String(), nil 83 | } 84 | 85 | func ReplaceM3u8SegmentsWithBaseURL( 86 | m3u8Str, baseURL string, 87 | callback func(segmentURL string) (string, error), 88 | ) (string, error) { 89 | baseURLParsed, err := url.Parse(baseURL) 90 | if err != nil { 91 | return "", fmt.Errorf("parse base url error: %w", err) 92 | } 93 | return ReplaceM3u8Segments(m3u8Str, func(segmentURL string) (string, error) { 94 | if !strings.HasPrefix(segmentURL, "http://") && !strings.HasPrefix(segmentURL, "https://") { 95 | segmentURLParsed, err := url.Parse(segmentURL) 96 | if err != nil { 97 | return "", fmt.Errorf("parse segment url error: %w", err) 98 | } 99 | segmentURL = baseURLParsed.ResolveReference(segmentURLParsed).String() 100 | } 101 | return callback(segmentURL) 102 | }) 103 | } 104 | -------------------------------------------------------------------------------- /utils/smtp/format.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "fmt" 7 | "mime" 8 | "strings" 9 | "time" 10 | 11 | smtp "github.com/emersion/go-smtp" 12 | "github.com/zijiren233/stream" 13 | ) 14 | 15 | type FormatMailConfig struct { 16 | date string 17 | mimeVersion string 18 | contentType string 19 | contentTransferEncoding string 20 | } 21 | 22 | type FormatMailOption func(c *FormatMailConfig) 23 | 24 | func WithDate(date time.Time) FormatMailOption { 25 | return func(c *FormatMailConfig) { 26 | c.date = date.Format(time.RFC1123Z) 27 | } 28 | } 29 | 30 | func WithMimeVersion(mimeVersion string) FormatMailOption { 31 | return func(c *FormatMailConfig) { 32 | c.mimeVersion = mimeVersion 33 | } 34 | } 35 | 36 | func WithContentType(contentType string) FormatMailOption { 37 | return func(c *FormatMailConfig) { 38 | c.contentType = contentType 39 | } 40 | } 41 | 42 | func WithContentTransferEncoding(contentTransferEncoding string) FormatMailOption { 43 | return func(c *FormatMailConfig) { 44 | c.contentTransferEncoding = contentTransferEncoding 45 | } 46 | } 47 | 48 | func FormatMail(from string, to []string, subject, body string, opts ...FormatMailOption) string { 49 | c := &FormatMailConfig{ 50 | date: time.Now().Format(time.RFC1123Z), 51 | mimeVersion: "1.0", 52 | contentType: "text/html; charset=UTF-8", 53 | contentTransferEncoding: "base64", 54 | } 55 | for _, opt := range opts { 56 | opt(c) 57 | } 58 | buf := bytes.NewBuffer(nil) 59 | 60 | fmt.Fprintf(buf, "From: %s\r\n", from) 61 | fmt.Fprintf(buf, "To: %s\r\n", strings.Join(to, ", ")) 62 | fmt.Fprintf(buf, "Subject: %s\r\n", mime.QEncoding.Encode("UTF-8", subject)) 63 | fmt.Fprintf(buf, "Date: %s\r\n", c.date) 64 | fmt.Fprintf(buf, "MIME-Version: %s\r\n", c.mimeVersion) 65 | fmt.Fprintf(buf, "Content-Type: %s\r\n", c.contentType) 66 | if c.contentTransferEncoding != "" { 67 | fmt.Fprintf(buf, "Content-Transfer-Encoding: %s\r\n", c.contentTransferEncoding) 68 | } 69 | 70 | buf.WriteString("\r\n") 71 | 72 | switch c.contentTransferEncoding { 73 | case "base64": 74 | encodedBody := base64.StdEncoding.EncodeToString(stream.StringToBytes(body)) 75 | for i := 0; i < len(encodedBody); i += 76 { 76 | end := i + 76 77 | if end > len(encodedBody) { 78 | end = len(encodedBody) 79 | } 80 | buf.WriteString(encodedBody[i:end] + "\r\n") 81 | } 82 | case "": 83 | buf.WriteString(body) 84 | } 85 | 86 | return buf.String() 87 | } 88 | 89 | func SendEmail( 90 | cli *smtp.Client, 91 | from string, 92 | to []string, 93 | subject, body string, 94 | opts ...FormatMailOption, 95 | ) error { 96 | return cli.SendMail( 97 | from, 98 | to, 99 | strings.NewReader( 100 | FormatMail( 101 | from, 102 | to, 103 | subject, 104 | body, 105 | opts..., 106 | ), 107 | ), 108 | ) 109 | } 110 | -------------------------------------------------------------------------------- /utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/synctv-org/synctv/utils" 9 | ) 10 | 11 | func TestGetPageItems(t *testing.T) { 12 | type args struct { 13 | items []int 14 | page int 15 | pageSize int 16 | } 17 | tests := []struct { 18 | name string 19 | args args 20 | want []int 21 | }{ 22 | { 23 | name: "Test Case 1", 24 | args: args{ 25 | items: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 26 | pageSize: 5, 27 | page: 1, 28 | }, 29 | want: []int{1, 2, 3, 4, 5}, 30 | }, 31 | { 32 | name: "Test Case 2", 33 | args: args{ 34 | items: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 35 | pageSize: 5, 36 | page: 2, 37 | }, 38 | want: []int{6, 7, 8, 9, 10}, 39 | }, 40 | { 41 | name: "Test Case 3", 42 | args: args{ 43 | items: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 44 | pageSize: 5, 45 | page: 3, 46 | }, 47 | want: []int{}, 48 | }, 49 | } 50 | for _, tt := range tests { 51 | t.Run(tt.name, func(t *testing.T) { 52 | if got := utils.GetPageItems(tt.args.items, tt.args.page, tt.args.pageSize); !reflect.DeepEqual( 53 | got, 54 | tt.want, 55 | ) { 56 | t.Errorf("GetPageItems() = %v, want %v", got, tt.want) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | func FuzzCompVersion(f *testing.F) { 63 | f.Add("v1.0.0", "v1.0.1") 64 | f.Add("v0.2.9", "v1.5.2") 65 | f.Add("v0.3.0-beta-1", "v0.3.0-alpha-2") 66 | f.Add("v0.3.1-beta.1", "v0.3.1-alpha.2") 67 | f.Add("v0.2.9", "v0.3.1-alpha.2") 68 | f.Add("v0.2.9", "v0.3.1-alpha-2") 69 | f.Add("v0.3.1", "v0.3.1-alpha.2") 70 | f.Fuzz(func(t *testing.T, a, b string) { 71 | t.Logf("a: %s, b: %s", a, b) 72 | _, err := utils.CompVersion(a, b) 73 | if err != nil { 74 | t.Errorf("CompVersion error = %v", err) 75 | } 76 | }) 77 | } 78 | 79 | func TestIsLocalIP(t *testing.T) { 80 | tests := []struct { 81 | name string 82 | host string 83 | want bool 84 | }{ 85 | { 86 | name: "Test Case 1", 87 | host: "www.baidu.com", 88 | want: false, 89 | }, 90 | { 91 | name: "Test Case 2", 92 | host: "127.0.0.1", 93 | want: true, 94 | }, 95 | { 96 | name: "Test Case 2", 97 | host: "127.0.0.1:9012", 98 | want: true, 99 | }, 100 | { 101 | name: "Test Case 3", 102 | host: "localhost:9012", 103 | want: true, 104 | }, 105 | } 106 | for _, tt := range tests { 107 | t.Run(tt.name, func(t *testing.T) { 108 | if got := utils.IsLocalIP(tt.host); got != tt.want { 109 | t.Errorf("IsLocalIP() = %v, want %v", got, tt.want) 110 | } 111 | }) 112 | } 113 | } 114 | 115 | func TestTruncateByRune(t *testing.T) { 116 | // len("测") = 3 117 | name := "abcd测试" 118 | if !strings.EqualFold(utils.TruncateByRune(name, 6), "abcd") { 119 | t.Errorf("TruncateByRune() = %v, want %v", utils.TruncateByRune(name, 6), "abcd") 120 | } 121 | if !strings.EqualFold(utils.TruncateByRune(name, 7), "abcd测") { 122 | t.Errorf("TruncateByRune() = %v, want %v", utils.TruncateByRune(name, 7), "abcd测") 123 | } 124 | if !strings.EqualFold(utils.TruncateByRune(name, 8), "abcd测") { 125 | t.Errorf("TruncateByRune() = %v, want %v", utils.TruncateByRune(name, 8), "abcd测") 126 | } 127 | if !strings.EqualFold(utils.TruncateByRune(name, 9), "abcd测") { 128 | t.Errorf("TruncateByRune() = %v, want %v", utils.TruncateByRune(name, 9), "abcd测") 129 | } 130 | if !strings.EqualFold(utils.TruncateByRune(name, 10), "abcd测试") { 131 | t.Errorf("TruncateByRune() = %v, want %v", utils.TruncateByRune(name, 10), "abcd测试") 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /utils/websocket.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/gorilla/websocket" 8 | ) 9 | 10 | type WebSocket struct { 11 | Heartbeat time.Duration 12 | } 13 | 14 | func DefaultWebSocket() *WebSocket { 15 | return &WebSocket{Heartbeat: time.Second * 5} 16 | } 17 | 18 | type WebSocketConfig func(*WebSocket) 19 | 20 | func WithHeartbeatInterval(d time.Duration) WebSocketConfig { 21 | return func(ws *WebSocket) { 22 | ws.Heartbeat = d 23 | } 24 | } 25 | 26 | func NewWebSocketServer(conf ...WebSocketConfig) *WebSocket { 27 | ws := DefaultWebSocket() 28 | for _, wsc := range conf { 29 | wsc(ws) 30 | } 31 | return ws 32 | } 33 | 34 | func (ws *WebSocket) Server( 35 | w http.ResponseWriter, 36 | r *http.Request, 37 | subprotocols []string, 38 | handler func(c *websocket.Conn) error, 39 | ) error { 40 | conf := []UpgraderConf{} 41 | if len(subprotocols) > 0 { 42 | conf = append(conf, WithSubprotocols(subprotocols)) 43 | } 44 | wsc, err := ws.NewWebSocketClient(w, r, nil, conf...) 45 | if err != nil { 46 | return err 47 | } 48 | defer wsc.Close() 49 | return handler(wsc) 50 | } 51 | 52 | type UpgraderConf func(*websocket.Upgrader) 53 | 54 | func WithSubprotocols(subprotocols []string) UpgraderConf { 55 | return func(ug *websocket.Upgrader) { 56 | ug.Subprotocols = subprotocols 57 | } 58 | } 59 | 60 | func (ws *WebSocket) newUpgrader(conf ...UpgraderConf) *websocket.Upgrader { 61 | ug := &websocket.Upgrader{ 62 | HandshakeTimeout: time.Second * 30, 63 | ReadBufferSize: 1024, 64 | WriteBufferSize: 1024, 65 | CheckOrigin: func(_ *http.Request) bool { 66 | return true 67 | }, 68 | } 69 | for _, uc := range conf { 70 | uc(ug) 71 | } 72 | return ug 73 | } 74 | 75 | func (ws *WebSocket) NewWebSocketClient( 76 | w http.ResponseWriter, 77 | r *http.Request, 78 | responseHeader http.Header, 79 | conf ...UpgraderConf, 80 | ) (*websocket.Conn, error) { 81 | conn, err := ws.newUpgrader(conf...).Upgrade(w, r, responseHeader) 82 | if err != nil { 83 | return nil, err 84 | } 85 | return conn, nil 86 | } 87 | --------------------------------------------------------------------------------