├── .dockerignore
├── .gitattributes
├── .github
└── workflows
│ ├── images.yaml
│ ├── jekyll-gh-pages.yml
│ └── release.yml
├── .gitignore
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── bun.lockb
├── cmd
└── misstodon
│ ├── commands
│ ├── banner.txt
│ └── start.go
│ ├── logger
│ └── logger.go
│ └── main.go
├── config_example.toml
├── docker-compose.yml
├── go.mod
├── go.sum
├── internal
├── api
│ ├── httperror
│ │ └── error.go
│ ├── middleware
│ │ ├── context.go
│ │ ├── cors.go
│ │ ├── logger.go
│ │ └── recover.go
│ ├── nodeinfo
│ │ └── nodeinfo.go
│ ├── oauth
│ │ └── oauth.go
│ ├── router.go
│ ├── v1
│ │ ├── accounts.go
│ │ ├── application.go
│ │ ├── instance.go
│ │ ├── media.go
│ │ ├── notifications.go
│ │ ├── staticfile.go
│ │ ├── statuses.go
│ │ ├── streaming.go
│ │ ├── timelines.go
│ │ └── trends.go
│ ├── v2
│ │ ├── .gitkeep
│ │ └── media.go
│ └── wellknown
│ │ └── wellknown.go
├── database
│ ├── buntdb
│ │ └── db.go
│ └── database.go
├── global
│ ├── buildinfo.go
│ ├── config.go
│ └── database.go
├── misstodon
│ └── context.go
└── utils
│ ├── http.go
│ ├── map.go
│ ├── utils.go
│ └── utils_test.go
├── package.json
├── pkg
├── httpclient
│ ├── httpclient.go
│ └── resty.go
├── mfm
│ ├── .gitignore
│ ├── mastodon.go
│ ├── mfm.go
│ ├── mfm_test.go
│ ├── models.go
│ └── parse.ts
└── misstodon
│ ├── models
│ ├── Account.go
│ ├── Application.go
│ ├── Context.go
│ ├── CustomEmoji.go
│ ├── Instance.go
│ ├── MediaAttachment.go
│ ├── MkApplication.go
│ ├── MkEmoji.go
│ ├── MkFile.go
│ ├── MkMeta.go
│ ├── MkNote.go
│ ├── MkNotification.go
│ ├── MkRelation.go
│ ├── MkStats.go
│ ├── MkStreamMessage.go
│ ├── MkUser.go
│ ├── NodeInfo.go
│ ├── Notification.go
│ ├── Relationship.go
│ ├── Status.go
│ ├── StreamEvent.go
│ ├── Tag.go
│ └── Timelines.go
│ └── provider
│ └── misskey
│ ├── .env.example
│ ├── .gitignore
│ ├── accounts.go
│ ├── accounts_test.go
│ ├── application.go
│ ├── drive.go
│ ├── errors.go
│ ├── httpclient.go
│ ├── instance.go
│ ├── instance_test.go
│ ├── media.go
│ ├── misskey_test.go
│ ├── notifications.go
│ ├── oauth.go
│ ├── statuses.go
│ ├── streaming
│ └── streaming.go
│ ├── timelines.go
│ ├── timelines_test.go
│ ├── trends.go
│ ├── utils.go
│ └── wellknown.go
└── pnpm-lock.yaml
/.dockerignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .github
3 | .vscode
4 | cert/
5 | logs/
6 | build/
7 | node_modules/
8 |
9 | Dockerfile
10 | docker-compose.yml
11 | /pkg/mfm/out.js
12 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 | *.png binary
3 | pkg/mfm/*.js -diff
--------------------------------------------------------------------------------
/.github/workflows/images.yaml:
--------------------------------------------------------------------------------
1 | name: Build images
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | tags: [ v* ]
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - name: Build image
14 | run: docker build --build-arg version=$(git describe --tags --always) -t ghcr.io/gizmo-ds/misstodon:latest -f Dockerfile .
15 | - name: Push latest
16 | env:
17 | CR_PAT: ${{ secrets.CR_PAT }}
18 | run: |
19 | echo $CR_PAT | docker login ghcr.io -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
20 | docker push ghcr.io/gizmo-ds/misstodon:latest
21 | - name: Push tag
22 | if: startsWith(github.ref, 'refs/tags/v')
23 | run: |
24 | docker tag ghcr.io/gizmo-ds/misstodon:latest ghcr.io/gizmo-ds/misstodon:${{ github.ref }}
25 | docker push ghcr.io/gizmo-ds/misstodon:${{ github.ref }}
26 |
--------------------------------------------------------------------------------
/.github/workflows/jekyll-gh-pages.yml:
--------------------------------------------------------------------------------
1 | # Sample workflow for building and deploying a Jekyll site to GitHub Pages
2 | name: Deploy Jekyll with GitHub Pages dependencies preinstalled
3 |
4 | on:
5 | # Runs on pushes targeting the default branch
6 | push:
7 | branches: ["main"]
8 |
9 | # Allows you to run this workflow manually from the Actions tab
10 | workflow_dispatch:
11 |
12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
13 | permissions:
14 | contents: read
15 | pages: write
16 | id-token: write
17 |
18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
20 | concurrency:
21 | group: "pages"
22 | cancel-in-progress: false
23 |
24 | jobs:
25 | # Build job
26 | build:
27 | runs-on: ubuntu-latest
28 | steps:
29 | - name: Checkout
30 | uses: actions/checkout@v3
31 | - name: Setup Pages
32 | uses: actions/configure-pages@v3
33 | - name: Build with Jekyll
34 | uses: actions/jekyll-build-pages@v1
35 | with:
36 | source: ./
37 | destination: ./_site
38 | - name: Upload artifact
39 | uses: actions/upload-pages-artifact@v1
40 |
41 | # Deployment job
42 | deploy:
43 | environment:
44 | name: github-pages
45 | url: ${{ steps.deployment.outputs.page_url }}
46 | runs-on: ubuntu-latest
47 | needs: build
48 | steps:
49 | - name: Deploy to GitHub Pages
50 | id: deployment
51 | uses: actions/deploy-pages@v1
52 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - v*
7 |
8 | jobs:
9 | release:
10 | name: Release new version
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v2
14 |
15 | - uses: actions/setup-go@v3
16 | with:
17 | go-version: "stable"
18 | cache: true
19 |
20 | - name: Test
21 | run: go test ./...
22 |
23 | - name: Build
24 | if: startsWith(github.ref, 'refs/tags/')
25 | run: make && make zip && make sha256sum
26 |
27 | - name: Release
28 | uses: softprops/action-gh-release@v1
29 | if: startsWith(github.ref, 'refs/tags/')
30 | with:
31 | files: |
32 | build/*.zip
33 | build/*.sha265
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .vscode
3 |
4 | cert/
5 | logs/
6 | build/
7 | /data/
8 | node_modules/
9 |
10 | config.toml
11 | .env
12 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | [](https://afdian.com/a/gizmo)
2 | 
3 | [](https://github.com/gizmo-ds/misstodon/actions/workflows/images.yaml)
4 | [](./LICENSE)
5 |
6 | # Contribute to misstodon
7 |
8 | ## Prepare your environment
9 |
10 | Prerequisites:
11 |
12 | - [Go 1.20+](https://go.dev/doc/install)
13 | - [git](https://git-scm.com/)
14 | - [Bun](https://bun.sh/docs/installation) or [Deno](https://deno.land/manual/getting_started/installation) or [Node.js](https://nodejs.org/)
15 |
16 | ## Fork and clone misstodon
17 |
18 | First you need to fork this project on GitHub.
19 |
20 | Clone your fork:
21 |
22 | ```shell
23 | git clone git@github.com:/misstodon.git
24 | ```
25 |
26 | ## Prerequisite before build
27 |
28 | ### mfm.js
29 |
30 | Misskey uses a non-standard Markdown implementation, which they call MFM (Misskey Flavored Markdown).
31 |
32 | > Misskey has an [mfm.rs](https://github.com/misskey-dev/mfm.rs). If it's completed, I will attempt to compile it to WebAssembly and replace the current implementation.
33 |
34 | If you are using [Bun](https://bun.sh/docs/installation):
35 |
36 | ```shell
37 | bun install
38 | bun run build
39 | ```
40 |
41 | If you are using [Deno](https://deno.land/manual/getting_started/installation):
42 |
43 | ```shell
44 | deno task build
45 | ```
46 |
47 | If you are using [Node.js](https://nodejs.org/):
48 |
49 | ```shell
50 | corepack prepare pnpm@latest --activate
51 | pnpm install
52 | pnpm run build
53 | ```
54 |
55 | ## Test your change
56 |
57 | Currently, misstodon lacks proper unit tests. You can create test cases in the `pkg/misstodon/provider/misskey` directory.
58 |
59 | ```shell
60 | go test github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey -v -run TestTimelinePublic
61 | ```
62 |
63 | Another simple approach is to run the misstodon server and use tools like [Insomnia](https://insomnia.rest/) to test the API.
64 |
65 | Start the misstodon server:
66 |
67 | ```shell
68 | cp config_example.toml config.toml
69 | go run cmd/misstodon/main.go start --fallbackServer=misskey.io
70 | ```
71 |
72 | Request the API:
73 |
74 | ```shell
75 | curl --request GET --url "http://localhost:3000/nodeinfo/2.0" | jq .
76 | ```
77 |
78 | ## Create a commit
79 |
80 | Commit messages should be well formatted, and to make that "standardized", we are using Conventional Commits.
81 |
82 | You can follow the documentation on [their website](https://www.conventionalcommits.org).
83 |
84 | ## Submit a pull request
85 |
86 | Push your branch to your `misstodon` fork and open a pull request against the main branch.
87 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM docker.io/oven/bun:latest AS mfm-builder
2 | WORKDIR /app
3 | COPY pkg/mfm /app/pkg/mfm
4 | COPY ./package.json /app/package.json
5 | COPY ./bun.lockb /app/bun.lockb
6 | RUN bun install && bun run build
7 |
8 | FROM docker.io/library/golang:1.20-alpine AS builder
9 | WORKDIR /app
10 | COPY . /app
11 | ENV CGO_ENABLED=0
12 | ARG version=development
13 | COPY --from=mfm-builder /app/pkg/mfm/out.js /app/pkg/mfm/out.js
14 | RUN go mod download
15 | RUN go build -trimpath -tags timetzdata \
16 | -ldflags "-s -w -X github.com/gizmo-ds/misstodon/internal/global.AppVersion=$version" \
17 | -o misstodon \
18 | ./cmd/misstodon
19 |
20 | FROM gcr.io/distroless/static-debian11:latest
21 | WORKDIR /app
22 | COPY --from=builder /app/misstodon /app/misstodon
23 | COPY --from=builder /app/config_example.toml /app/config.toml
24 | ENTRYPOINT ["/app/misstodon", "start"]
25 | EXPOSE 3000
26 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | NAME=misstodon
2 | OUTDIR=build
3 | PKGNAME=github.com/gizmo-ds/misstodon
4 | MAIN=cmd/misstodon/main.go
5 | VERSION=$(shell git describe --tags --always)
6 | FLAGS+=-trimpath
7 | FLAGS+=-tags timetzdata
8 | FLAGS+=-ldflags "-s -w -X $(PKGNAME)/internal/global.AppVersion=$(VERSION)"
9 | export CGO_ENABLED=0
10 |
11 | PLATFORMS := linux windows darwin
12 |
13 | all: build-all
14 |
15 | generate:
16 | go generate ./...
17 |
18 | build-all: $(PLATFORMS)
19 |
20 | $(PLATFORMS): generate
21 | GOOS=$@ GOARCH=amd64 go build $(FLAGS) -o $(OUTDIR)/$(NAME)-$@-amd64$(if $(filter windows,$@),.exe) $(MAIN)
22 |
23 | sha256sum:
24 | cd $(OUTDIR); for file in *; do sha256sum $$file > $$file.sha256; done
25 |
26 | zip:
27 | cp config_example.toml $(OUTDIR)/config.toml
28 | for platform in $(PLATFORMS); do \
29 | zip -jq9 $(OUTDIR)/$(NAME)-$$platform-amd64.zip $(OUTDIR)/$(NAME)-$$platform-amd64* $(OUTDIR)/config.toml README.md LICENSE; \
30 | done
31 |
32 | clean:
33 | rm -rf $(OUTDIR)/*
34 |
35 | build-image: generate
36 | docker build --no-cache --build-arg version=$(shell git describe --tags --always) -t ghcr.io/gizmo-ds/misstodon:latest -f Dockerfile .
37 |
38 | build-develop-image: generate
39 | docker build --no-cache --build-arg version=$(shell git describe --tags --always) -t ghcr.io/gizmo-ds/misstodon:develop -f Dockerfile .
40 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # misstodon
2 |
3 | [](https://afdian.com/a/gizmo)
4 | 
5 | [](https://github.com/gizmo-ds/misstodon/actions/workflows/images.yaml)
6 | [](./LICENSE)
7 |
8 | Misskey Mastodon-compatible APIs, Getting my [Misskey](https://github.com/misskey-dev/misskey/tree/13.2.0) instance to work in [Elk](https://github.com/elk-zone/elk)
9 |
10 | > [!IMPORTANT]
11 | > Thank you for your interest and support for this project, which aims to make the Misskey API compatible with Mastodon clients. However, I have recently lost interest in Misskey and have decided to no longer actively maintain this project. In a twist of fate, my misskey.moe account has also been deleted recently—talk about a sign!
12 | >
13 | > If the community is interested in continuing this project, feel free to fork it and take it to new heights!
14 | >
15 | > Please be aware that due to the lack of maintenance, there may be unresolved issues or compatibility problems in the project.
16 |
17 | ## Demo
18 |
19 | Elk: [https://elk.zone/misstodon.aika.dev/public](https://elk.zone/misstodon.aika.dev/public)
20 | Elk: [https://elk.zone/mt_misskey_moe.aika.dev/public](https://elk.zone/mt_misskey_moe.aika.dev/public)
21 | Phanpy: [https://phanpy.social/#/mt_misskey_io.aika.dev/p](https://phanpy.social/#/mt_misskey_io.aika.dev/p)
22 |
23 | ## How to Use
24 |
25 | > [!WARNING]
26 | > `aika.dev` is a demonstration site and may not guarantee high availability. We recommend [self-hosting](#running-your-own-instance) for greater control.
27 |
28 | ### Domain Name Prefixing Scheme (Recommended)
29 |
30 | The simplest usage method is to specify the instance address using a domain name prefix.
31 |
32 | 1. Replace underscores ("\_") in the domain name with double underscores ("\_\_").
33 | 2. Replace dots (".") in the domain name with underscores ("\_").
34 | 3. Prepend "mt\_" to the modified string.
35 | 4. Append ".aika.dev" to the modified string.
36 |
37 | When processing `misskey.io` according to the described steps, it will be transformed into the result: `mt_misskey_io.aika.dev`.
38 |
39 | ```bash
40 | curl --request GET --url 'https://mt_misskey_io.aika.dev/nodeinfo/2.0' | jq .
41 | ```
42 |
43 | ### Self-Hosting with Default Instance Configuration
44 |
45 | Edit the 'config.toml' file, and within the "[proxy]" section, modify the "fallback_server" field. For example:
46 |
47 | ```toml
48 | [proxy]
49 | fallback_server = "misskey.io"
50 | ```
51 |
52 | If you are [deploying using Docker Compose](#running-your-own-instance), you can specify the default instance by modifying the 'docker-compose.yml' file. Look for the 'MISSTODON_FALLBACK_SERVER' field within the Docker Compose configuration and set it to the desired default instance.
53 |
54 | ### Instance Specification via Query Parameter
55 |
56 | ```bash
57 | curl --request GET --url 'https://misstodon.aika.dev/nodeinfo/2.0?server=misskey.io' | jq .
58 | ```
59 |
60 | ### Instance Specification via Header
61 |
62 | ```bash
63 | curl --request GET --url https://misstodon.aika.dev/nodeinfo/2.0 --header 'x-proxy-server: misskey.io' | jq .
64 | ```
65 |
66 | ## Running your own instance
67 |
68 | The simplest way is to use Docker Compose. Download the [docker-compose.yml](https://github.com/gizmo-ds/misstodon/raw/main/docker-compose.yml) file to your local machine. Customize it to your needs, particularly by changing the "MISSTODON_FALLBACK_SERVER" in the "environment" to your preferred Misskey instance domain. Afterward, run the following command:
69 |
70 | ```bash
71 | docker-compose up -d
72 | ```
73 |
74 | > [!IMPORTANT]
75 | > For security and privacy, we strongly discourage using HTTP directly. Instead, consider configuring a TLS certificate or utilizing Misstodon's AutoTLS feature for enhanced security.
76 |
77 | ## Roadmap
78 |
79 |
80 |
81 | - [x] .well-known
82 | - [x] `GET` /.well-known/host-meta
83 | - [x] `GET` /.well-known/webfinger
84 | - [x] `GET` /.well-known/nodeinfo
85 | - [x] `GET` /.well-known/change-password
86 | - [x] Nodeinfo
87 | - [x] `GET` /nodeinfo/2.0
88 | - [ ] Auth
89 | - [x] `GET` /oauth/authorize
90 | - [x] `POST` /oauth/token
91 | - [x] `POST` /api/v1/apps
92 | - [ ] `GET` /api/v1/apps/verify_credentials
93 | - [x] Instance
94 | - [x] `GET` /api/v1/instance
95 | - [x] `GET` /api/v1/custom_emojis
96 | - [ ] Accounts
97 | - [x] `GET` /api/v1/accounts/lookup
98 | - [x] `GET` /api/v1/accounts/:user_id
99 | - [x] `GET` /api/v1/accounts/verify_credentials
100 | - [ ] `PATCH` /api/v1/accounts/update_credentials
101 | - [x] `GET` /api/v1/accounts/relationships
102 | - [ ] `GET` /api/v1/accounts/:user_id/statuses
103 | - [x] `GET` /api/v1/accounts/:user_id/following
104 | - [x] `GET` /api/v1/accounts/:user_id/followers
105 | - [x] `POST` /api/v1/accounts/:user_id/follow
106 | - [x] `POST` /api/v1/accounts/:user_id/unfollow
107 | - [x] `GET` /api/v1/follow_requests
108 | - [x] `POST` /api/v1/accounts/:user_id/mute
109 | - [x] `POST` /api/v1/accounts/:user_id/unmute
110 | - [x] `GET` /api/v1/bookmarks
111 | - [x] `GET` /api/v1/favourites
112 | - [ ] `GET` /api/v1/preferences
113 | - [ ] Statuses
114 | - [x] `POST` /api/v1/statuses
115 | - [x] `GET` /api/v1/statuses/:status_id
116 | - [x] `DELETE` /api/v1/statuses/:status_id
117 | - [x] `GET` /api/v1/statuses/:status_id/context
118 | - [x] `POST` /api/v1/statuses/:status_id/reblog
119 | - [x] `POST` /api/v1/statuses/:status_id/favourite
120 | - [x] `POST` /api/v1/statuses/:status_id/unfavourite
121 | - [x] `POST` /api/v1/statuses/:status_id/bookmark
122 | - [x] `POST` /api/v1/statuses/:status_id/unbookmark
123 | - [ ] `GET` /api/v1/statuses/:status_id/favourited_by
124 | - [ ] `GET` /api/v1/statuses/:status_id/reblogged_by
125 | - [x] Timelines
126 | - [x] `GET` /api/v1/timelines/home
127 | - [x] `GET` /api/v1/timelines/public
128 | - [x] `GET` /api/v1/timelines/tag/:hashtag
129 | - [ ] `WS` /api/v1/streaming
130 | - [ ] Notifications
131 | - [x] `GET` /api/v1/notifications
132 | - [ ] `POST` /api/v1/push/subscription
133 | - [ ] `GET` /api/v1/push/subscription
134 | - [ ] `PUT` /api/v1/push/subscription
135 | - [ ] `DELETE` /api/v1/push/subscription
136 | - [ ] Search
137 | - [ ] `GET` /api/v2/search
138 | - [ ] Conversations
139 | - [ ] `GET` /api/v1/conversations
140 | - [ ] `DELETE` /api/v1/conversations/:id
141 | - [ ] `POST` /api/v1/conversations/:id/read
142 | - [x] Trends
143 | - [x] `GET` /api/v1/trends/statuses
144 | - [x] `GET` /api/v1/trends/tags
145 | - [x] Media
146 | - [x] `POST` /api/v1/media
147 | - [x] `POST` /api/v2/media
148 |
149 |
150 |
151 | ## Information for Developers
152 |
153 | [Contributing](./CONTRIBUTING.md) Information about contributing to this project.
154 |
155 | ## Sponsors
156 |
157 | [](https://afdian.com/a/gizmo)
158 |
159 | ## Contributors
160 |
161 | 
162 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gizmo-ds/misstodon/21bf379c5f8547486494792885c3350c51dc78ec/bun.lockb
--------------------------------------------------------------------------------
/cmd/misstodon/commands/banner.txt:
--------------------------------------------------------------------------------
1 | __ ____ __ __
2 | / |/ (_)_________/ /_____ ____/ /___ ____
3 | / /|_/ / / ___/ ___/ __/ __ \/ __ / __ \/ __ \
4 | / / / / (__ |__ ) /_/ /_/ / /_/ / /_/ / / / /
5 | /_/ /_/_/____/____/\__/\____/\__,_/\____/_/ /_/
--------------------------------------------------------------------------------
/cmd/misstodon/commands/start.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | _ "embed"
5 | "fmt"
6 | "path/filepath"
7 |
8 | "github.com/gizmo-ds/misstodon/internal/api"
9 | "github.com/gizmo-ds/misstodon/internal/database"
10 | "github.com/gizmo-ds/misstodon/internal/global"
11 | "github.com/gizmo-ds/misstodon/internal/utils"
12 | "github.com/labstack/echo/v4"
13 | "github.com/labstack/echo/v4/middleware"
14 | "github.com/rs/zerolog/log"
15 | "github.com/urfave/cli/v2"
16 | "golang.org/x/crypto/acme/autocert"
17 | )
18 |
19 | //go:embed banner.txt
20 | var banner string
21 |
22 | var Start = &cli.Command{
23 | Name: "start",
24 | Usage: "Start the server",
25 | Before: func(c *cli.Context) error {
26 | appVersion := global.AppVersion
27 | if !c.Bool("no-color") {
28 | appVersion = "\033[1;31;40m" + appVersion + "\033[0m"
29 | }
30 | fmt.Printf("\n%s %s\n\n", banner, appVersion)
31 | return nil
32 | },
33 | Flags: []cli.Flag{
34 | &cli.StringFlag{
35 | Name: "bind",
36 | Aliases: []string{"b"},
37 | Usage: "bind address",
38 | },
39 | &cli.StringFlag{
40 | Name: "fallback-server",
41 | Usage: "if proxy-server is not found in the request, the fallback server address will be used, " +
42 | `e.g. "misskey.io"`,
43 | },
44 | },
45 | Action: func(c *cli.Context) error {
46 | conf := global.Config
47 | global.DB = database.NewDatabase(
48 | conf.Database.Type,
49 | conf.Database.Address)
50 | defer global.DB.Close()
51 | if c.IsSet("fallbackServer") {
52 | conf.Proxy.FallbackServer = c.String("fallbackServer")
53 | }
54 | bindAddress, _ := utils.StrEvaluation(c.String("bind"), conf.Server.BindAddress)
55 |
56 | e := echo.New()
57 | e.HidePort, e.HideBanner = true, true
58 |
59 | api.Router(e)
60 |
61 | logStart := log.Info().Str("address", bindAddress)
62 | switch {
63 | case conf.Server.AutoTLS && conf.Server.Domain != "":
64 | e.Pre(middleware.HTTPSNonWWWRedirect())
65 | cacheDir, _ := filepath.Abs("./cert/.cache")
66 | e.AutoTLSManager.Cache = autocert.DirCache(cacheDir)
67 | e.AutoTLSManager.HostPolicy = autocert.HostWhitelist(conf.Server.Domain)
68 | logStart.Msg("Starting server with AutoTLS")
69 | return e.StartAutoTLS(bindAddress)
70 | case conf.Server.TlsCertFile != "" && conf.Server.TlsKeyFile != "":
71 | logStart.Msg("Starting server with TLS")
72 | return e.StartTLS(bindAddress, conf.Server.TlsCertFile, conf.Server.TlsKeyFile)
73 | default:
74 | logStart.Msg("Starting server")
75 | return e.Start(bindAddress)
76 | }
77 | },
78 | }
79 |
--------------------------------------------------------------------------------
/cmd/misstodon/logger/logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "io"
5 | "os"
6 | "path/filepath"
7 |
8 | "github.com/gizmo-ds/misstodon/internal/global"
9 | "github.com/rs/zerolog"
10 | "github.com/rs/zerolog/log"
11 | "gopkg.in/natefinch/lumberjack.v2"
12 | )
13 |
14 | func Init(noColor bool) {
15 | _ = os.MkdirAll(filepath.Dir(global.Config.Logger.Filename), 0750)
16 | zerolog.SetGlobalLevel(zerolog.Level(global.Config.Logger.Level))
17 | writers := []io.Writer{&lumberjack.Logger{
18 | Filename: global.Config.Logger.Filename,
19 | MaxAge: global.Config.Logger.MaxAge,
20 | MaxBackups: global.Config.Logger.MaxBackups,
21 | }}
22 | if global.Config.Logger.ConsoleWriter {
23 | writers = append(writers, zerolog.ConsoleWriter{
24 | Out: os.Stderr,
25 | NoColor: noColor,
26 | TimeFormat: "2006-01-02 15:04:05",
27 | FieldsExclude: []string{"stack"},
28 | })
29 | }
30 | log.Logger = zerolog.New(zerolog.MultiLevelWriter(writers...)).
31 | With().Timestamp().Stack().Logger()
32 | }
33 |
--------------------------------------------------------------------------------
/cmd/misstodon/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | _ "embed"
5 | "os"
6 |
7 | "github.com/gizmo-ds/misstodon/cmd/misstodon/commands"
8 | "github.com/gizmo-ds/misstodon/cmd/misstodon/logger"
9 | "github.com/gizmo-ds/misstodon/internal/global"
10 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
11 | "github.com/pkg/errors"
12 | "github.com/rs/zerolog"
13 | "github.com/rs/zerolog/log"
14 | "github.com/rs/zerolog/pkgerrors"
15 | "github.com/urfave/cli/v2"
16 | )
17 |
18 | func main() {
19 | zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
20 | err := (&cli.App{
21 | Name: "misstodon",
22 | Usage: "misskey api proxy",
23 | Version: global.AppVersion,
24 | EnableBashCompletion: true,
25 | Suggest: true,
26 | Flags: []cli.Flag{
27 | &cli.BoolFlag{
28 | Name: "no-color",
29 | Usage: "Disable color output",
30 | },
31 | &cli.StringFlag{
32 | Name: "config",
33 | Aliases: []string{"c"},
34 | Usage: "config file",
35 | Value: "config.toml",
36 | },
37 | },
38 | Before: func(c *cli.Context) error {
39 | if err := global.LoadConfig(c.String("config")); err != nil {
40 | log.Fatal().Stack().Err(errors.WithStack(err)).Msg("Failed to load config")
41 | }
42 | logger.Init(c.Bool("no-color"))
43 | misskey.SetHeader("User-Agent", "misstodon/"+global.AppVersion)
44 | return nil
45 | },
46 | Commands: []*cli.Command{
47 | commands.Start,
48 | },
49 | }).Run(os.Args)
50 | if err != nil {
51 | log.Fatal().Err(errors.WithStack(err)).Msg("Failed to start")
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/config_example.toml:
--------------------------------------------------------------------------------
1 | [proxy]
2 | #fallback_server = "example.com"
3 |
4 | [server]
5 | bind_address = "[::]:3000"
6 | # auto_tls = true
7 | # domain = "example.com" # required if auto_tls is true
8 | # tls_cert_file = "cert/fullchain.pem"
9 | # tls_key_file = "cert/privkey.pem"
10 |
11 | [logger]
12 | level = 0
13 | console_writer = true
14 | request_logger = true
15 | filename = "logs/misstodon.jsonl"
16 | max_age = 7
17 | max_backups = 10
18 |
19 | [database]
20 | type = "buntdb"
21 | address = "data/data.db"
22 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | misstodon:
5 | image: ghcr.io/gizmo-ds/misstodon:latest
6 | container_name: misstodon
7 | restart: always
8 | environment:
9 | MISSTODON_FALLBACK_SERVER: misskey.moe
10 | # MISSTODON_SERVER_AUTO_TLS: true
11 | # MISSTODON_SERVER_DOMAIN: example.com # required if MISSTODON_SERVER_AUTO_TLS is true
12 | # MISSTODON_SERVER_TLS_CERT_FILE: /app/cert/fullchain.pem
13 | # MISSTODON_SERVER_TLS_KEY_FILE: /app/cert/privkey.pem
14 | ports:
15 | - "3000:3000"
16 | volumes:
17 | - ./logs:/app/logs
18 | - ./data:/app/data
19 | - ./cert:/app/cert
20 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/gizmo-ds/misstodon
2 |
3 | go 1.20
4 | toolchain go1.24.1
5 |
6 | require (
7 | github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
8 | github.com/duke-git/lancet/v2 v2.2.8
9 | github.com/go-resty/resty/v2 v2.11.0
10 | github.com/gorilla/websocket v1.5.1
11 | github.com/jinzhu/configor v1.2.2
12 | github.com/joho/godotenv v1.5.1
13 | github.com/labstack/echo/v4 v4.11.4
14 | github.com/pkg/errors v0.9.1
15 | github.com/rs/xid v1.5.0
16 | github.com/rs/zerolog v1.31.0
17 | github.com/stretchr/testify v1.8.4
18 | github.com/tidwall/buntdb v1.3.0
19 | github.com/urfave/cli/v2 v2.27.0
20 | golang.org/x/crypto v0.35.0
21 | golang.org/x/net v0.36.0
22 | gopkg.in/natefinch/lumberjack.v2 v2.2.1
23 | )
24 |
25 | require (
26 | github.com/BurntSushi/toml v1.3.2 // indirect
27 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
28 | github.com/davecgh/go-spew v1.1.1 // indirect
29 | github.com/dlclark/regexp2 v1.7.0 // indirect
30 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
31 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
32 | github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
33 | github.com/labstack/gommon v0.4.2 // indirect
34 | github.com/mattn/go-colorable v0.1.13 // indirect
35 | github.com/mattn/go-isatty v0.0.20 // indirect
36 | github.com/pmezard/go-difflib v1.0.0 // indirect
37 | github.com/russross/blackfriday/v2 v2.1.0 // indirect
38 | github.com/tidwall/btree v1.4.2 // indirect
39 | github.com/tidwall/gjson v1.14.3 // indirect
40 | github.com/tidwall/grect v0.1.4 // indirect
41 | github.com/tidwall/match v1.1.1 // indirect
42 | github.com/tidwall/pretty v1.2.0 // indirect
43 | github.com/tidwall/rtred v0.1.2 // indirect
44 | github.com/tidwall/tinyqueue v0.1.1 // indirect
45 | github.com/valyala/bytebufferpool v1.0.0 // indirect
46 | github.com/valyala/fasttemplate v1.2.2 // indirect
47 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
48 | golang.org/x/exp v0.0.0-20221208152030-732eee02a75a // indirect
49 | golang.org/x/sys v0.30.0 // indirect
50 | golang.org/x/text v0.22.0 // indirect
51 | golang.org/x/time v0.5.0 // indirect
52 | gopkg.in/yaml.v3 v3.0.1 // indirect
53 | )
54 |
55 | replace github.com/gorilla/websocket v1.5.1 => github.com/gizmo-ds/gorilla-websocket v0.0.0-20230212044710-0f26ab2a978a
56 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
2 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
3 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
4 | github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
5 | github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
6 | github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
7 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
8 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
9 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
10 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
13 | github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
14 | github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
15 | github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
16 | github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
17 | github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw=
18 | github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
19 | github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
20 | github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
21 | github.com/duke-git/lancet/v2 v2.2.8 h1:wlruXhliDe4zls1e2cYmz4qLc+WtcvrpcCnk1VJdEaA=
22 | github.com/duke-git/lancet/v2 v2.2.8/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
23 | github.com/gizmo-ds/gorilla-websocket v0.0.0-20230212044710-0f26ab2a978a h1:gGpVQzL8lkpLAbtJo1CbZS6z0b7PJryBCENCJFQc6Ms=
24 | github.com/gizmo-ds/gorilla-websocket v0.0.0-20230212044710-0f26ab2a978a/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
25 | github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8=
26 | github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
27 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
28 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
29 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
30 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
31 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
32 | github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
33 | github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
34 | github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
35 | github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA=
36 | github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8=
37 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
38 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
39 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
40 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
41 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
42 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
43 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
44 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
45 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
46 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
47 | github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
48 | github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
49 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
50 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
51 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
52 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
53 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
54 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
55 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
56 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
57 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
58 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
59 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
60 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
61 | github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
62 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
63 | github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
64 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
65 | github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
66 | github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
67 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
68 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
69 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
70 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
71 | github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI=
72 | github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8=
73 | github.com/tidwall/btree v1.4.2 h1:PpkaieETJMUxYNADsjgtNRcERX7mGc/GP2zp/r5FM3g=
74 | github.com/tidwall/btree v1.4.2/go.mod h1:LGm8L/DZjPLmeWGjv5kFrY8dL4uVhMmzmmLYmsObdKE=
75 | github.com/tidwall/buntdb v1.3.0 h1:gdhWO+/YwoB2qZMeAU9JcWWsHSYU3OvcieYgFRS0zwA=
76 | github.com/tidwall/buntdb v1.3.0/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU=
77 | github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
78 | github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
79 | github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
80 | github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg=
81 | github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q=
82 | github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8=
83 | github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8=
84 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
85 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
86 | github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
87 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
88 | github.com/tidwall/rtred v0.1.2 h1:exmoQtOLvDoO8ud++6LwVsAMTu0KPzLTUrMln8u1yu8=
89 | github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ=
90 | github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE=
91 | github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw=
92 | github.com/urfave/cli/v2 v2.27.0 h1:uNs1K8JwTFL84X68j5Fjny6hfANh9nTlJ6dRtZAFAHY=
93 | github.com/urfave/cli/v2 v2.27.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
94 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
95 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
96 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
97 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
98 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
99 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
100 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
101 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
102 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
103 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
104 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
105 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
106 | golang.org/x/exp v0.0.0-20221208152030-732eee02a75a h1:4iLhBPcpqFmylhnkbY3W0ONLUYYkDAW9xMFLfxgsvCw=
107 | golang.org/x/exp v0.0.0-20221208152030-732eee02a75a/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
108 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
109 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
110 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
111 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
112 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
113 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
114 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
115 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
116 | golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
117 | golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
118 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
119 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
120 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
121 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
122 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
123 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
124 | golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
125 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
126 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
127 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
128 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
129 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
130 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
131 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
132 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
133 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
134 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
135 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
136 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
137 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
138 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
139 | golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
140 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
141 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
142 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
143 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
144 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
145 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
146 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
147 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
148 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
149 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
150 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
151 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
152 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
153 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
154 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
155 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
156 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
157 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
158 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
159 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
160 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
161 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
162 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
163 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
164 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
165 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
166 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
167 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
168 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
169 |
--------------------------------------------------------------------------------
/internal/api/httperror/error.go:
--------------------------------------------------------------------------------
1 | package httperror
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "net/http"
7 |
8 | "github.com/labstack/echo/v4"
9 | "github.com/rs/xid"
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | type ServerError struct {
14 | TraceID string `json:"trace_id,omitempty"`
15 | Error string `json:"error"`
16 | }
17 |
18 | func ErrorHandler(err error, c echo.Context) {
19 | code := http.StatusInternalServerError
20 | if err == nil {
21 | return
22 | }
23 |
24 | info := ServerError{Error: err.Error()}
25 | var he *echo.HTTPError
26 | if errors.As(err, &he) {
27 | code = he.Code
28 | info.Error = fmt.Sprint(he.Message)
29 | }
30 |
31 | if code == http.StatusInternalServerError {
32 | id := xid.New().String()
33 | info = ServerError{
34 | TraceID: id,
35 | Error: "Internal Server Error",
36 | }
37 | log.Warn().Err(err).
38 | Str("user_agent", c.Request().UserAgent()).
39 | Str("trace_id", id).
40 | Int("code", code).
41 | Msg("Server Error")
42 | }
43 | _ = c.JSON(code, info)
44 | }
45 |
--------------------------------------------------------------------------------
/internal/api/middleware/context.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 |
7 | "github.com/gizmo-ds/misstodon/internal/api/httperror"
8 | "github.com/gizmo-ds/misstodon/internal/global"
9 | "github.com/gizmo-ds/misstodon/internal/utils"
10 | "github.com/labstack/echo/v4"
11 | )
12 |
13 | func SetContextData(next echo.HandlerFunc) echo.HandlerFunc {
14 | return func(c echo.Context) error {
15 | var hostProxyServer string
16 | host := c.Request().Host
17 | if strings.HasPrefix(host, "mt_") {
18 | tmp := strings.Split(host[3:], ".")[0]
19 | tmp = strings.ReplaceAll(tmp, "__", "+")
20 | arr := strings.Split(tmp, "_")
21 | if len(arr) > 1 {
22 | tmp = strings.Join(arr, ".")
23 | hostProxyServer = strings.ReplaceAll(tmp, "+", "_")
24 | }
25 | }
26 | proxyServer, ok := utils.StrEvaluation(
27 | hostProxyServer,
28 | c.Param("proxyServer"),
29 | c.QueryParam("server"),
30 | c.Request().Header.Get("x-proxy-server"),
31 | global.Config.Proxy.FallbackServer)
32 | if !ok {
33 | if !utils.Contains([]string{
34 | "/.well-known/nodeinfo",
35 | "/nodeinfo/2.0",
36 | }, c.Path()) {
37 | return c.JSON(http.StatusBadRequest, httperror.ServerError{
38 | Error: "no proxy server specified",
39 | })
40 | }
41 | }
42 | c.Response().Header().Set("User-Agent", "misstodon/"+global.AppVersion)
43 | c.Response().Header().Set("X-Proxy-Server", proxyServer)
44 | c.Set("proxy-server", proxyServer)
45 | return next(c)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/internal/api/middleware/cors.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/labstack/echo/v4"
5 | "github.com/labstack/echo/v4/middleware"
6 | )
7 |
8 | func CORS() echo.MiddlewareFunc {
9 | return middleware.CORS()
10 | }
11 |
--------------------------------------------------------------------------------
/internal/api/middleware/logger.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/labstack/echo/v4"
5 | "github.com/labstack/echo/v4/middleware"
6 | "github.com/rs/zerolog/log"
7 | )
8 |
9 | var Logger = middleware.RequestLoggerWithConfig(
10 | middleware.RequestLoggerConfig{
11 | LogMethod: true,
12 | LogURI: true,
13 | LogStatus: true,
14 | LogLatency: true,
15 | LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error {
16 | log.Info().
17 | Str("proxy-server", c.Get("proxy-server").(string)).
18 | Str("method", v.Method).
19 | Str("uri", v.URI).
20 | Int("status", v.Status).
21 | Str("latency", v.Latency.String()).
22 | Msg("Request")
23 | return nil
24 | },
25 | })
26 |
--------------------------------------------------------------------------------
/internal/api/middleware/recover.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/labstack/echo/v4"
7 | "github.com/labstack/echo/v4/middleware"
8 | )
9 |
10 | func Recover() echo.MiddlewareFunc {
11 | return middleware.RecoverWithConfig(middleware.RecoverConfig{
12 | Skipper: middleware.DefaultSkipper,
13 | StackSize: 4 << 10, // 4 KB
14 | DisableStackAll: false,
15 | DisablePrintStack: false,
16 | LogLevel: 0,
17 | LogErrorFunc: func(c echo.Context, err error, stack []byte) error {
18 | return fmt.Errorf("panic recover\n%s", string(stack))
19 | },
20 | DisableErrorHandler: false,
21 | })
22 | }
23 |
--------------------------------------------------------------------------------
/internal/api/nodeinfo/nodeinfo.go:
--------------------------------------------------------------------------------
1 | package nodeinfo
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gizmo-ds/misstodon/internal/api/middleware"
7 | "github.com/gizmo-ds/misstodon/internal/global"
8 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
9 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
10 | "github.com/labstack/echo/v4"
11 | )
12 |
13 | func Router(e *echo.Group) {
14 | group := e.Group("/nodeinfo", middleware.CORS())
15 | group.GET("/2.0", InfoHandler)
16 | }
17 |
18 | func InfoHandler(c echo.Context) error {
19 | server, _ := c.Get("proxy-server").(string)
20 | var err error
21 | info := models.NodeInfo{
22 | Version: "2.0",
23 | Software: models.NodeInfoSoftware{
24 | Name: "misstodon",
25 | Version: global.AppVersion,
26 | },
27 | Protocols: []string{"activitypub"},
28 | Services: models.NodeInfoServices{
29 | Inbound: []string{},
30 | Outbound: []string{},
31 | },
32 | Metadata: struct{}{},
33 | }
34 | if server != "" {
35 | info, err = misskey.NodeInfo(
36 | server,
37 | models.NodeInfo{
38 | Version: "2.0",
39 | Software: models.NodeInfoSoftware{
40 | Name: "misstodon",
41 | Version: global.AppVersion,
42 | },
43 | Protocols: []string{"activitypub"},
44 | Services: models.NodeInfoServices{
45 | Inbound: []string{},
46 | Outbound: []string{},
47 | },
48 | })
49 | if err != nil {
50 | return err
51 | }
52 | }
53 | return c.JSON(http.StatusOK, info)
54 | }
55 |
--------------------------------------------------------------------------------
/internal/api/oauth/oauth.go:
--------------------------------------------------------------------------------
1 | package oauth
2 |
3 | import (
4 | "net/http"
5 | "net/url"
6 | "strings"
7 | "time"
8 |
9 | "github.com/gizmo-ds/misstodon/internal/api/middleware"
10 | "github.com/gizmo-ds/misstodon/internal/global"
11 | "github.com/gizmo-ds/misstodon/internal/misstodon"
12 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
13 | "github.com/labstack/echo/v4"
14 | )
15 |
16 | func Router(e *echo.Group) {
17 | group := e.Group("/oauth", middleware.CORS())
18 | group.GET("/authorize", AuthorizeHandler)
19 | group.POST("/token", TokenHandler)
20 | // NOTE: This is not a standard endpoint
21 | group.GET("/redirect", RedirectHandler)
22 | }
23 |
24 | func RedirectHandler(c echo.Context) error {
25 | redirectUris := c.QueryParam("redirect_uris")
26 | server := c.QueryParam("server")
27 | token := c.QueryParam("token")
28 | if redirectUris == "" || server == "" {
29 | return c.String(http.StatusBadRequest, "redirect_uris and server are required")
30 | }
31 | if token == "" {
32 | if strings.Contains(redirectUris, "?token=") {
33 | i := strings.Index(redirectUris, "?token=")
34 | token = redirectUris[i+7:]
35 | redirectUris = redirectUris[:i]
36 | }
37 | if strings.Contains(server, "?token=") {
38 | i := strings.Index(server, "?token=")
39 | token = server[i+7:]
40 | server = server[:i]
41 | }
42 | }
43 | u, err := url.Parse(redirectUris)
44 | if err != nil {
45 | return err
46 | }
47 | query := u.Query()
48 | query.Add("code", token)
49 | u.RawQuery = query.Encode()
50 | return c.Redirect(http.StatusFound, u.String())
51 | }
52 |
53 | func TokenHandler(c echo.Context) error {
54 | var params struct {
55 | GrantType string `json:"grant_type" form:"grant_type"`
56 | ClientID string `json:"client_id" form:"client_id"`
57 | ClientSecret string `json:"client_secret" form:"client_secret"`
58 | RedirectURI string `json:"redirect_uri" form:"redirect_uri"`
59 | Code string `json:"code" form:"code"`
60 | Scope string `json:"scope" form:"scope"`
61 | }
62 | if err := c.Bind(¶ms); err != nil {
63 | return c.JSON(http.StatusBadRequest, echo.Map{
64 | "error": err.Error(),
65 | })
66 | }
67 | if params.GrantType == "" || params.ClientID == "" ||
68 | params.ClientSecret == "" || params.RedirectURI == "" {
69 | return c.JSON(http.StatusBadRequest, echo.Map{
70 | "error": "grant_type, client_id, client_secret and redirect_uri are required",
71 | })
72 | }
73 | ctx, err := misstodon.ContextWithEchoContext(c, false)
74 | if err != nil {
75 | return err
76 | }
77 | accessToken, userID, err := misskey.OAuthToken(ctx, params.Code, params.ClientSecret)
78 | if err != nil {
79 | return err
80 | }
81 | return c.JSON(http.StatusOK, echo.Map{
82 | "access_token": strings.Join([]string{userID, accessToken}, "."),
83 | "token_type": "Bearer",
84 | "scope": params.Scope,
85 | "created_at": time.Now().Unix(),
86 | })
87 | }
88 |
89 | func AuthorizeHandler(c echo.Context) error {
90 | var params struct {
91 | ClientID string `query:"client_id"`
92 | RedirectUri string `query:"redirect_uri"`
93 | ResponseType string `query:"response_type"`
94 | Scope string `query:"scope"`
95 | Lang string `query:"lang"`
96 | ForceLogin bool `query:"force_login"`
97 | }
98 | if err := c.Bind(¶ms); err != nil {
99 | return c.JSON(http.StatusBadRequest, echo.Map{
100 | "error": err.Error(),
101 | })
102 | }
103 | if params.ResponseType != "code" {
104 | return c.JSON(http.StatusBadRequest, echo.Map{
105 | "error": "response_type must be code",
106 | })
107 | }
108 | if params.ClientID == "" || params.RedirectUri == "" || params.ResponseType == "" {
109 | return c.JSON(http.StatusBadRequest, echo.Map{
110 | "error": "client_id, redirect_uri and response_type are required",
111 | })
112 | }
113 | ctx, err := misstodon.ContextWithEchoContext(c, false)
114 | if err != nil {
115 | return err
116 | }
117 | secret, ok := global.DB.Get(ctx.ProxyServer(), params.ClientID)
118 | if !ok {
119 | return c.JSON(http.StatusBadRequest, echo.Map{
120 | "error": "client_id is invalid",
121 | })
122 | }
123 | u, err := misskey.OAuthAuthorize(ctx, secret)
124 | if err != nil {
125 | return err
126 | }
127 | return c.Redirect(http.StatusFound, u)
128 | }
129 |
--------------------------------------------------------------------------------
/internal/api/router.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "github.com/gizmo-ds/misstodon/internal/api/httperror"
5 | "github.com/gizmo-ds/misstodon/internal/api/middleware"
6 | "github.com/gizmo-ds/misstodon/internal/api/nodeinfo"
7 | "github.com/gizmo-ds/misstodon/internal/api/oauth"
8 | v1 "github.com/gizmo-ds/misstodon/internal/api/v1"
9 | v2 "github.com/gizmo-ds/misstodon/internal/api/v2"
10 | "github.com/gizmo-ds/misstodon/internal/api/wellknown"
11 | "github.com/gizmo-ds/misstodon/internal/global"
12 | "github.com/labstack/echo/v4"
13 | )
14 |
15 | func Router(e *echo.Echo) {
16 | e.HTTPErrorHandler = httperror.ErrorHandler
17 | e.Use(
18 | middleware.SetContextData,
19 | middleware.Recover())
20 | if global.Config.Logger.RequestLogger {
21 | e.Use(middleware.Logger)
22 | }
23 | for _, group := range []*echo.Group{
24 | e.Group(""),
25 | e.Group("/:proxyServer"),
26 | } {
27 | wellknown.Router(group)
28 | nodeinfo.Router(group)
29 | oauth.Router(group)
30 | v1Api := group.Group("/api/v1", middleware.CORS())
31 | v2Api := group.Group("/api/v2", middleware.CORS())
32 | group.GET("/static/missing.png", v1.MissingImageHandler)
33 | v1.InstanceRouter(v1Api)
34 | v1.AccountsRouter(v1Api)
35 | v1.ApplicationRouter(v1Api)
36 | v1.StatusesRouter(v1Api)
37 | v1.StreamingRouter(v1Api)
38 | v1.TimelinesRouter(v1Api)
39 | v1.TrendsRouter(v1Api)
40 | v1.MediaRouter(v1Api)
41 | v1.NotificationsRouter(v1Api)
42 | v2.MediaRouter(v2Api)
43 |
44 | v1Api.GET("/bookmarks", v1.StatusBookmarks)
45 | v1Api.GET("/follow_requests", v1.AccountFollowRequests)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/internal/api/v1/accounts.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "fmt"
5 | "mime/multipart"
6 | "net/http"
7 |
8 | "github.com/gizmo-ds/misstodon/internal/api/httperror"
9 | "github.com/gizmo-ds/misstodon/internal/misstodon"
10 | "github.com/gizmo-ds/misstodon/internal/utils"
11 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
12 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
13 | "github.com/labstack/echo/v4"
14 | "github.com/pkg/errors"
15 | )
16 |
17 | func AccountsRouter(e *echo.Group) {
18 | group := e.Group("/accounts")
19 | e.GET("/favourites", AccountFavourites)
20 | group.GET("/verify_credentials", AccountsVerifyCredentialsHandler)
21 | group.PATCH("/update_credentials", AccountsUpdateCredentialsHandler)
22 | group.GET("/lookup", AccountsLookupHandler)
23 | group.GET("/:id", AccountsGetHandler)
24 | group.GET("/:id/statuses", AccountsStatusesHandler)
25 | group.GET("/:id/followers", AccountFollowers)
26 | group.GET("/:id/following", AccountFollowing)
27 | group.GET("/relationships", AccountRelationships)
28 | group.POST("/:id/follow", AccountFollow)
29 | group.POST("/:id/unfollow", AccountUnfollow)
30 | group.POST("/:id/mute", AccountMute)
31 | group.POST("/:id/unmute", AccountUnmute)
32 | }
33 |
34 | func AccountsVerifyCredentialsHandler(c echo.Context) error {
35 | ctx, err := misstodon.ContextWithEchoContext(c, true)
36 | if err != nil {
37 | return err
38 | }
39 | info, err := misskey.VerifyCredentials(ctx)
40 | if err != nil {
41 | return err
42 | }
43 | return c.JSON(http.StatusOK, info)
44 | }
45 |
46 | func AccountsLookupHandler(c echo.Context) error {
47 | acct := c.QueryParam("acct")
48 | if acct == "" {
49 | return c.JSON(http.StatusBadRequest, httperror.ServerError{
50 | Error: "acct is required",
51 | })
52 | }
53 | ctx, _ := misstodon.ContextWithEchoContext(c)
54 | info, err := misskey.AccountsLookup(ctx, acct)
55 | if err != nil {
56 | if errors.Is(err, misskey.ErrNotFound) {
57 | return c.JSON(http.StatusNotFound, httperror.ServerError{
58 | Error: "Record not found",
59 | })
60 | } else if errors.Is(err, misskey.ErrAcctIsInvalid) {
61 | return c.JSON(http.StatusBadRequest, httperror.ServerError{
62 | Error: err.Error(),
63 | })
64 | }
65 | return err
66 | }
67 | if info.Header == "" || info.HeaderStatic == "" {
68 | info.Header = fmt.Sprintf("%s://%s/static/missing.png", c.Scheme(), c.Request().Host)
69 | info.HeaderStatic = info.Header
70 | }
71 | return c.JSON(http.StatusOK, info)
72 | }
73 |
74 | func AccountsStatusesHandler(c echo.Context) error {
75 | uid := c.Param("id")
76 |
77 | ctx, _ := misstodon.ContextWithEchoContext(c)
78 |
79 | limit := 30
80 | pinnedOnly := false
81 | onlyMedia := false
82 | onlyPublic := false
83 | excludeReplies := false
84 | excludeReblogs := false
85 | maxID := ""
86 | minID := ""
87 | if err := echo.QueryParamsBinder(c).
88 | Int("limit", &limit).
89 | Bool("pinned_only", &pinnedOnly).
90 | Bool("only_media", &onlyMedia).
91 | Bool("only_public", &onlyPublic).
92 | Bool("exclude_replies", &excludeReplies).
93 | Bool("exclude_reblogs", &excludeReblogs).
94 | String("max_id", &maxID).
95 | String("min_id", &minID).
96 | BindError(); err != nil {
97 | var e *echo.BindingError
98 | errors.As(err, &e)
99 | return c.JSON(http.StatusBadRequest, echo.Map{
100 | "field": e.Field,
101 | "error": e.Message,
102 | })
103 | }
104 | statuses, err := misskey.AccountsStatuses(
105 | ctx, uid,
106 | limit,
107 | pinnedOnly, onlyMedia, onlyPublic, excludeReplies, excludeReblogs,
108 | maxID, minID)
109 | if err != nil {
110 | return err
111 | }
112 | return c.JSON(http.StatusOK, utils.SliceIfNull(statuses))
113 | }
114 |
115 | func AccountsUpdateCredentialsHandler(c echo.Context) error {
116 | form, err := parseAccountsUpdateCredentialsForm(c)
117 | if err != nil {
118 | return c.JSON(http.StatusBadRequest, httperror.ServerError{Error: err.Error()})
119 | }
120 |
121 | ctx, err := misstodon.ContextWithEchoContext(c, true)
122 | if err != nil {
123 | return err
124 | }
125 |
126 | account, err := misskey.UpdateCredentials(ctx,
127 | form.DisplayName, form.Note,
128 | form.Locked, form.Bot, form.Discoverable,
129 | form.SourcePrivacy, form.SourceSensitive, form.SourceLanguage,
130 | form.AccountFields,
131 | form.Avatar, form.Header)
132 | if err != nil {
133 | return err
134 | }
135 | return c.JSON(http.StatusOK, account)
136 | }
137 |
138 | type accountsUpdateCredentialsForm struct {
139 | DisplayName *string `form:"display_name"`
140 | Note *string `form:"note"`
141 | Locked *bool `form:"locked"`
142 | Bot *bool `form:"bot"`
143 | Discoverable *bool `form:"discoverable"`
144 | SourcePrivacy *string `form:"source[privacy]"`
145 | SourceSensitive *bool `form:"source[sensitive]"`
146 | SourceLanguage *string `form:"source[language]"`
147 | AccountFields []models.AccountField
148 | Avatar *multipart.FileHeader
149 | Header *multipart.FileHeader
150 | }
151 |
152 | func parseAccountsUpdateCredentialsForm(c echo.Context) (f accountsUpdateCredentialsForm, err error) {
153 | var form accountsUpdateCredentialsForm
154 | if err = c.Bind(&form); err != nil {
155 | return
156 | }
157 |
158 | var values = make(map[string][]string)
159 | for k, v := range c.QueryParams() {
160 | values[k] = v
161 | }
162 | if fp, err := c.FormParams(); err == nil {
163 | for k, v := range fp {
164 | values[k] = v
165 | }
166 | }
167 | if mf, err := c.MultipartForm(); err == nil {
168 | for k, v := range mf.Value {
169 | values[k] = v
170 | }
171 | }
172 | for _, field := range utils.GetFieldsAttributes(values) {
173 | form.AccountFields = append(form.AccountFields, models.AccountField(field))
174 | }
175 | if fh, err := c.FormFile("avatar"); err == nil {
176 | form.Avatar = fh
177 | }
178 | if fh, err := c.FormFile("header"); err == nil {
179 | form.Header = fh
180 | }
181 | return form, nil
182 | }
183 |
184 | func AccountFollowRequests(c echo.Context) error {
185 | ctx, err := misstodon.ContextWithEchoContext(c, true)
186 | if err != nil {
187 | return err
188 | }
189 | var query struct {
190 | Limit int `query:"limit"`
191 | MaxID string `query:"max_id"`
192 | SinceID string `query:"since_id"`
193 | }
194 | if err = c.Bind(&query); err != nil {
195 | return err
196 | }
197 | if query.Limit <= 0 {
198 | query.Limit = 40
199 | }
200 | accounts, err := misskey.AccountFollowRequests(ctx, query.Limit, query.SinceID, query.MaxID)
201 | if err != nil {
202 | return err
203 | }
204 | return c.JSON(http.StatusOK, utils.SliceIfNull(accounts))
205 | }
206 |
207 | func AccountFollowers(c echo.Context) error {
208 | ctx, _ := misstodon.ContextWithEchoContext(c)
209 | id := c.Param("id")
210 | var query struct {
211 | Limit int `query:"limit"`
212 | MaxID string `query:"max_id"`
213 | MinID string `query:"min_id"`
214 | SinceID string `query:"since_id"`
215 | }
216 | if err := c.Bind(&query); err != nil {
217 | return c.JSON(http.StatusBadRequest, httperror.ServerError{Error: err.Error()})
218 | }
219 | if query.Limit <= 0 {
220 | query.Limit = 40
221 | }
222 | if query.Limit > 80 {
223 | query.Limit = 80
224 | }
225 | accounts, err := misskey.AccountFollowers(ctx, id, query.Limit, query.SinceID, query.MinID, query.MaxID)
226 | if err != nil {
227 | return err
228 | }
229 | return c.JSON(http.StatusOK, utils.SliceIfNull(accounts))
230 | }
231 |
232 | func AccountFollowing(c echo.Context) error {
233 | ctx, _ := misstodon.ContextWithEchoContext(c)
234 |
235 | id := c.Param("id")
236 | var query struct {
237 | Limit int `query:"limit"`
238 | MaxID string `query:"max_id"`
239 | MinID string `query:"min_id"`
240 | SinceID string `query:"since_id"`
241 | }
242 | if err := c.Bind(&query); err != nil {
243 | return c.JSON(http.StatusBadRequest, httperror.ServerError{Error: err.Error()})
244 | }
245 | if query.Limit <= 0 {
246 | query.Limit = 40
247 | }
248 | if query.Limit > 80 {
249 | query.Limit = 80
250 | }
251 | accounts, err := misskey.AccountFollowing(ctx, id, query.Limit, query.SinceID, query.MinID, query.MaxID)
252 | if err != nil {
253 | return err
254 | }
255 | return c.JSON(http.StatusOK, utils.SliceIfNull(accounts))
256 | }
257 |
258 | func AccountRelationships(c echo.Context) error {
259 | ctx, err := misstodon.ContextWithEchoContext(c, true)
260 | if err != nil {
261 | return err
262 | }
263 | var ids []string
264 | for k, v := range c.QueryParams() {
265 | if k == "id[]" {
266 | ids = append(ids, v...)
267 | continue
268 | }
269 | }
270 | relationships, err := misskey.AccountRelationships(ctx, ids)
271 | if err != nil {
272 | return err
273 | }
274 | return c.JSON(http.StatusOK, relationships)
275 | }
276 |
277 | func AccountFollow(c echo.Context) error {
278 | ctx, err := misstodon.ContextWithEchoContext(c, true)
279 | if err != nil {
280 | return err
281 | }
282 | id := c.Param("id")
283 | if err = misskey.AccountFollow(ctx, id); err != nil {
284 | return err
285 | }
286 | relationships, err := misskey.AccountRelationships(ctx, []string{id})
287 | if err != nil {
288 | return err
289 | }
290 | return c.JSON(http.StatusOK, relationships[0])
291 | }
292 |
293 | func AccountUnfollow(c echo.Context) error {
294 | ctx, err := misstodon.ContextWithEchoContext(c, true)
295 | if err != nil {
296 | return err
297 | }
298 | id := c.Param("id")
299 | if err = misskey.AccountUnfollow(ctx, id); err != nil {
300 | return err
301 | }
302 | relationships, err := misskey.AccountRelationships(ctx, []string{id})
303 | if err != nil {
304 | return err
305 | }
306 | return c.JSON(http.StatusOK, relationships[0])
307 | }
308 |
309 | func AccountMute(c echo.Context) error {
310 | ctx, err := misstodon.ContextWithEchoContext(c, true)
311 | if err != nil {
312 | return err
313 | }
314 | var params struct {
315 | ID string `param:"id"`
316 | Duration int64 `json:"duration" form:"duration"`
317 | }
318 | if err := c.Bind(¶ms); err != nil {
319 | return err
320 | }
321 | if err = misskey.AccountMute(ctx, params.ID, params.Duration); err != nil {
322 | return err
323 | }
324 | relationships, err := misskey.AccountRelationships(ctx, []string{params.ID})
325 | if err != nil {
326 | return err
327 | }
328 | return c.JSON(http.StatusOK, relationships[0])
329 | }
330 |
331 | func AccountUnmute(c echo.Context) error {
332 | ctx, err := misstodon.ContextWithEchoContext(c, true)
333 | if err != nil {
334 | return err
335 | }
336 | id := c.Param("id")
337 | if err = misskey.AccountUnmute(ctx, id); err != nil {
338 | return err
339 | }
340 | relationships, err := misskey.AccountRelationships(ctx, []string{id})
341 | if err != nil {
342 | return err
343 | }
344 | return c.JSON(http.StatusOK, relationships[0])
345 | }
346 |
347 | func AccountsGetHandler(c echo.Context) error {
348 | ctx, _ := misstodon.ContextWithEchoContext(c)
349 | info, err := misskey.AccountGet(ctx, c.Param("id"))
350 | if err != nil {
351 | return err
352 | }
353 | if info.Header == "" || info.HeaderStatic == "" {
354 | info.Header = fmt.Sprintf("%s://%s/static/missing.png", c.Scheme(), c.Request().Host)
355 | info.HeaderStatic = info.Header
356 | }
357 | return c.JSON(http.StatusOK, info)
358 | }
359 |
360 | func AccountFavourites(c echo.Context) error {
361 | ctx, err := misstodon.ContextWithEchoContext(c, true)
362 | if err != nil {
363 | return err
364 | }
365 |
366 | var params struct {
367 | Limit int `query:"limit"`
368 | MaxID string `query:"max_id"`
369 | MinID string `query:"min_id"`
370 | SinceID string `query:"since_id"`
371 | }
372 | if err = c.Bind(¶ms); err != nil {
373 | return err
374 | }
375 | if params.Limit <= 0 {
376 | params.Limit = 20
377 | }
378 | list, err := misskey.AccountFavourites(ctx,
379 | params.Limit, params.SinceID, params.MinID, params.MaxID)
380 | if err != nil {
381 | return err
382 | }
383 | return c.JSON(http.StatusOK, list)
384 | }
385 |
--------------------------------------------------------------------------------
/internal/api/v1/application.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "net/http"
5 | "net/url"
6 | "strings"
7 |
8 | "github.com/gizmo-ds/misstodon/internal/global"
9 | "github.com/gizmo-ds/misstodon/internal/misstodon"
10 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
11 | "github.com/labstack/echo/v4"
12 | "github.com/pkg/errors"
13 | )
14 |
15 | func ApplicationRouter(e *echo.Group) {
16 | group := e.Group("/apps")
17 | group.POST("", ApplicationCreateHandler)
18 | }
19 |
20 | func ApplicationCreateHandler(c echo.Context) error {
21 | var params struct {
22 | ClientName string `json:"client_name" form:"client_name"`
23 | WebSite string `json:"website" form:"website"`
24 | RedirectUris string `json:"redirect_uris" form:"redirect_uris"`
25 | Scopes string `json:"scopes" form:"scopes"`
26 | }
27 | if err := c.Bind(¶ms); err != nil {
28 | return echo.NewHTTPError(http.StatusBadRequest, err)
29 | }
30 | if params.ClientName == "" || params.RedirectUris == "" {
31 | return echo.NewHTTPError(http.StatusBadRequest, "client_name and redirect_uris are required")
32 | }
33 |
34 | ctx, err := misstodon.ContextWithEchoContext(c, false)
35 | if err != nil {
36 | return err
37 | }
38 |
39 | u, err := url.Parse(strings.Join([]string{"https://", c.Request().Host, "/oauth/redirect"}, ""))
40 | if err != nil {
41 | return errors.WithStack(err)
42 | }
43 | query := u.Query()
44 | query.Add("server", ctx.ProxyServer())
45 | query.Add("redirect_uris", params.RedirectUris)
46 | u.RawQuery = query.Encode()
47 |
48 | app, err := misskey.ApplicationCreate(
49 | ctx,
50 | params.ClientName,
51 | u.String(),
52 | params.Scopes,
53 | params.WebSite)
54 | if err != nil {
55 | return err
56 | }
57 | err = global.DB.Set(ctx.ProxyServer(), app.ID, *app.ClientSecret, -1)
58 | if err != nil {
59 | return err
60 | }
61 | return c.JSON(http.StatusOK, app)
62 | }
63 |
--------------------------------------------------------------------------------
/internal/api/v1/instance.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gizmo-ds/misstodon/internal/global"
7 | "github.com/gizmo-ds/misstodon/internal/misstodon"
8 | "github.com/gizmo-ds/misstodon/internal/utils"
9 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
10 | "github.com/labstack/echo/v4"
11 | )
12 |
13 | func InstanceRouter(e *echo.Group) {
14 | group := e.Group("/instance")
15 | group.GET("", InstanceHandler)
16 | group.GET("/peers", InstancePeersHandler)
17 | e.GET("/custom_emojis", InstanceCustomEmojis)
18 | }
19 |
20 | func InstanceHandler(c echo.Context) error {
21 | ctx, err := misstodon.ContextWithEchoContext(c, false)
22 | if err != nil {
23 | return err
24 | }
25 |
26 | info, err := misskey.Instance(ctx, global.AppVersion)
27 | if err != nil {
28 | return err
29 | }
30 | return c.JSON(http.StatusOK, info)
31 | }
32 |
33 | func InstancePeersHandler(c echo.Context) error {
34 | ctx, err := misstodon.ContextWithEchoContext(c, false)
35 | if err != nil {
36 | return err
37 | }
38 |
39 | peers, err := misskey.InstancePeers(ctx)
40 | if err != nil {
41 | return err
42 | }
43 | return c.JSON(http.StatusOK, utils.SliceIfNull(peers))
44 | }
45 |
46 | func InstanceCustomEmojis(c echo.Context) error {
47 | ctx, err := misstodon.ContextWithEchoContext(c, false)
48 | if err != nil {
49 | return err
50 | }
51 |
52 | emojis, err := misskey.InstanceCustomEmojis(ctx)
53 | if err != nil {
54 | return err
55 | }
56 | return c.JSON(http.StatusOK, emojis)
57 | }
58 |
--------------------------------------------------------------------------------
/internal/api/v1/media.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gizmo-ds/misstodon/internal/misstodon"
7 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
8 | "github.com/labstack/echo/v4"
9 | )
10 |
11 | func MediaRouter(e *echo.Group) {
12 | group := e.Group("/media")
13 | group.POST("", MediaUploadHandler)
14 | }
15 |
16 | func MediaUploadHandler(c echo.Context) error {
17 | file, err := c.FormFile("file")
18 | if err != nil {
19 | return c.JSON(http.StatusBadRequest, echo.Map{"error": err.Error()})
20 | }
21 | description := c.FormValue("description")
22 |
23 | ctx, err := misstodon.ContextWithEchoContext(c, true)
24 | if err != nil {
25 | return err
26 | }
27 |
28 | ma, err := misskey.MediaUpload(ctx, file, description)
29 | if err != nil {
30 | return err
31 | }
32 | return c.JSON(http.StatusOK, ma)
33 | }
34 |
--------------------------------------------------------------------------------
/internal/api/v1/notifications.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/duke-git/lancet/v2/slice"
7 | "github.com/gizmo-ds/misstodon/internal/api/httperror"
8 | "github.com/gizmo-ds/misstodon/internal/misstodon"
9 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
10 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
11 | "github.com/labstack/echo/v4"
12 | )
13 |
14 | func NotificationsRouter(e *echo.Group) {
15 | group := e.Group("/notifications")
16 | group.GET("", NotificationsHandler)
17 | }
18 |
19 | func NotificationsHandler(c echo.Context) error {
20 | ctx, err := misstodon.ContextWithEchoContext(c, true)
21 | if err != nil {
22 | return err
23 | }
24 | _ = ctx
25 | var query struct {
26 | MaxId string `query:"max_id"`
27 | MinId string `query:"min_id"`
28 | SinceId string `query:"since_id"`
29 | Limit int `query:"limit"`
30 | }
31 | if err := c.Bind(&query); err != nil {
32 | return c.JSON(http.StatusBadRequest, httperror.ServerError{Error: err.Error()})
33 | }
34 |
35 | getTypes := func(name string) []models.NotificationType {
36 | types := slice.Map(c.QueryParams()[name], func(_ int, item string) models.NotificationType { return models.NotificationType(item) })
37 | types = slice.Filter(types, func(_ int, item models.NotificationType) bool {
38 | return item != "" && item.ToMkNotificationType() != models.MkNotificationTypeUnknown
39 | })
40 | return types
41 | }
42 |
43 | types := getTypes("types[]")
44 | excludeTypes := getTypes("exclude_types[]")
45 |
46 | result, err := misskey.NotificationsGet(ctx,
47 | query.Limit, query.SinceId, query.MinId, query.MaxId,
48 | types, excludeTypes, "")
49 | if err != nil {
50 | return err
51 | }
52 | return c.JSON(http.StatusOK, result)
53 | }
54 |
--------------------------------------------------------------------------------
/internal/api/v1/staticfile.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "bytes"
5 | "net/http"
6 | "time"
7 |
8 | "github.com/labstack/echo/v4"
9 | )
10 |
11 | var png1x1 = []byte{137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13,
12 | 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 4, 0, 0, 0, 181,
13 | 28, 12, 2, 0, 0, 0, 11, 73, 68, 65, 84, 120, 218, 99, 100,
14 | 96, 0, 0, 0, 6, 0, 2, 48, 129, 208, 47, 0, 0, 0, 0, 73, 69,
15 | 78, 68, 174, 66, 96, 130}
16 |
17 | func MissingImageHandler(c echo.Context) error {
18 | r := bytes.NewReader(png1x1)
19 | c.Response().Header().Set("Content-Type", "image/png")
20 | http.ServeContent(c.Response(), c.Request(), "missing.png", time.Unix(0, 0), r)
21 | return nil
22 | }
23 |
--------------------------------------------------------------------------------
/internal/api/v1/statuses.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/gizmo-ds/misstodon/internal/api/httperror"
8 | "github.com/gizmo-ds/misstodon/internal/misstodon"
9 | "github.com/gizmo-ds/misstodon/internal/utils"
10 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
11 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
12 | "github.com/labstack/echo/v4"
13 | "github.com/pkg/errors"
14 | )
15 |
16 | func StatusesRouter(e *echo.Group) {
17 | group := e.Group("/statuses")
18 | group.POST("", PostNewStatusHandler)
19 | group.GET("/:id", StatusHandler)
20 | group.DELETE("/:id", StatusDeleteHandler)
21 | group.GET("/:id/context", StatusContextHandler)
22 | group.POST("/:id/reblog", StatusReblogHandler)
23 | group.POST("/:id/bookmark", StatusBookmarkHandler)
24 | group.POST("/:id/unbookmark", StatusUnBookmarkHandler)
25 | group.POST("/:id/favourite", StatusFavouriteHandler)
26 | group.POST("/:id/unfavourite", StatusUnFavouriteHandler)
27 | }
28 |
29 | func StatusHandler(c echo.Context) error {
30 | id := c.Param("id")
31 | ctx, _ := misstodon.ContextWithEchoContext(c)
32 | info, err := misskey.StatusSingle(ctx, id)
33 | if err != nil {
34 | return err
35 | }
36 | return c.JSON(http.StatusOK, info)
37 | }
38 |
39 | func StatusContextHandler(c echo.Context) error {
40 | id := c.Param("id")
41 | ctx, err := misstodon.ContextWithEchoContext(c, false)
42 | if err != nil {
43 | return err
44 | }
45 | result, err := misskey.StatusContext(ctx, id)
46 | if err != nil {
47 | return err
48 | }
49 | return c.JSON(http.StatusOK, result)
50 | }
51 |
52 | func StatusFavouriteHandler(c echo.Context) error {
53 | id := c.Param("id")
54 | ctx, err := misstodon.ContextWithEchoContext(c, true)
55 | if err != nil {
56 | return err
57 | }
58 | status, err := misskey.StatusFavourite(ctx, id)
59 | if err != nil {
60 | if errors.Is(err, misskey.ErrUnauthorized) {
61 | return c.JSON(http.StatusUnauthorized, httperror.ServerError{Error: err.Error()})
62 | } else if errors.Is(err, misskey.ErrNotFound) {
63 | return c.JSON(http.StatusNotFound, httperror.ServerError{Error: err.Error()})
64 | } else {
65 | return err
66 | }
67 | }
68 | return c.JSON(http.StatusOK, status)
69 | }
70 |
71 | func StatusUnFavouriteHandler(c echo.Context) error {
72 | id := c.Param("id")
73 | ctx, err := misstodon.ContextWithEchoContext(c, true)
74 | if err != nil {
75 | return err
76 | }
77 | status, err := misskey.StatusUnFavourite(ctx, id)
78 | if err != nil {
79 | if errors.Is(err, misskey.ErrUnauthorized) {
80 | return c.JSON(http.StatusUnauthorized, httperror.ServerError{Error: err.Error()})
81 | } else if errors.Is(err, misskey.ErrNotFound) {
82 | return c.JSON(http.StatusNotFound, httperror.ServerError{Error: err.Error()})
83 | } else {
84 | return err
85 | }
86 | }
87 | return c.JSON(http.StatusOK, status)
88 | }
89 |
90 | func StatusBookmarkHandler(c echo.Context) error {
91 | id := c.Param("id")
92 | ctx, err := misstodon.ContextWithEchoContext(c, true)
93 | if err != nil {
94 | return err
95 | }
96 | status, err := misskey.StatusBookmark(ctx, id)
97 | if err != nil {
98 | if errors.Is(err, misskey.ErrUnauthorized) {
99 | return c.JSON(http.StatusUnauthorized, httperror.ServerError{Error: err.Error()})
100 | } else if errors.Is(err, misskey.ErrNotFound) {
101 | return c.JSON(http.StatusNotFound, httperror.ServerError{Error: err.Error()})
102 | } else {
103 | return err
104 | }
105 | }
106 | return c.JSON(http.StatusOK, status)
107 | }
108 |
109 | func StatusUnBookmarkHandler(c echo.Context) error {
110 | id := c.Param("id")
111 | ctx, err := misstodon.ContextWithEchoContext(c, true)
112 | if err != nil {
113 | return err
114 | }
115 | status, err := misskey.StatusUnBookmark(ctx, id)
116 | if err != nil {
117 | if errors.Is(err, misskey.ErrUnauthorized) {
118 | return c.JSON(http.StatusUnauthorized, httperror.ServerError{Error: err.Error()})
119 | } else if errors.Is(err, misskey.ErrNotFound) {
120 | return c.JSON(http.StatusNotFound, httperror.ServerError{Error: err.Error()})
121 | } else {
122 | return err
123 | }
124 | }
125 | return c.JSON(http.StatusOK, status)
126 | }
127 |
128 | func StatusBookmarks(c echo.Context) error {
129 | ctx, err := misstodon.ContextWithEchoContext(c, true)
130 | if err != nil {
131 | return err
132 | }
133 | var query struct {
134 | Limit int `query:"limit"`
135 | MaxID string `query:"max_id"`
136 | MinID string `query:"min_id"`
137 | SinceID string `query:"since_id"`
138 | }
139 | if err = c.Bind(&query); err != nil {
140 | return c.JSON(http.StatusBadRequest, httperror.ServerError{Error: err.Error()})
141 | }
142 | if query.Limit <= 0 {
143 | query.Limit = 20
144 | }
145 | status, err := misskey.StatusBookmarks(ctx, query.Limit, query.SinceID, query.MinID, query.MaxID)
146 | if err != nil {
147 | if errors.Is(err, misskey.ErrUnauthorized) {
148 | return c.JSON(http.StatusUnauthorized, httperror.ServerError{Error: err.Error()})
149 | } else {
150 | return err
151 | }
152 | }
153 | return c.JSON(http.StatusOK, utils.SliceIfNull(status))
154 | }
155 |
156 | type postNewStatusForm struct {
157 | Status *string `json:"status"`
158 | Poll any `json:"poll"` // FIXME: Poll 未实现
159 | MediaIDs []string `json:"media_ids"`
160 | InReplyToID string `json:"in_reply_to_id"`
161 | Sensitive bool `json:"sensitive"`
162 | SpoilerText string `json:"spoiler_text"`
163 | Visibility models.StatusVisibility `json:"visibility"`
164 | Language string `json:"language"`
165 | ScheduledAt time.Time `json:"scheduled_at"`
166 | }
167 |
168 | func PostNewStatusHandler(c echo.Context) error {
169 | ctx, err := misstodon.ContextWithEchoContext(c, true)
170 | if err != nil {
171 | return err
172 | }
173 |
174 | var form postNewStatusForm
175 | if err = c.Bind(&form); err != nil {
176 | return c.JSON(http.StatusBadRequest, httperror.ServerError{Error: err.Error()})
177 | }
178 | status, err := misskey.PostNewStatus(ctx,
179 | form.Status, form.Poll, form.MediaIDs, form.InReplyToID,
180 | form.Sensitive, form.SpoilerText,
181 | form.Visibility, form.Language,
182 | form.ScheduledAt)
183 | if err != nil {
184 | return err
185 | }
186 | return c.JSON(http.StatusOK, status)
187 | }
188 |
189 | func StatusReblogHandler(c echo.Context) error {
190 | id := c.Param("id")
191 | ctx, err := misstodon.ContextWithEchoContext(c, true)
192 | if err != nil {
193 | return err
194 | }
195 | status, err := misskey.StatusReblog(ctx, id, models.StatusVisibilityPublic)
196 | if err != nil {
197 | return err
198 | }
199 | return c.JSON(http.StatusOK, status)
200 | }
201 |
202 | func StatusDeleteHandler(c echo.Context) error {
203 | id := c.Param("id")
204 | ctx, err := misstodon.ContextWithEchoContext(c, true)
205 | if err != nil {
206 | return err
207 | }
208 | status, err := misskey.StatusDelete(ctx, id)
209 | if err != nil {
210 | return err
211 | }
212 | return c.JSON(http.StatusOK, status)
213 | }
214 |
--------------------------------------------------------------------------------
/internal/api/v1/streaming.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "context"
5 | "net/http"
6 |
7 | "github.com/gizmo-ds/misstodon/internal/misstodon"
8 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
9 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey/streaming"
10 | "github.com/gorilla/websocket"
11 | "github.com/labstack/echo/v4"
12 | "github.com/pkg/errors"
13 | "github.com/rs/zerolog/log"
14 | )
15 |
16 | var wsUpgrade = websocket.Upgrader{
17 | ReadBufferSize: 4096, // we don't expect reads
18 | WriteBufferSize: 4096,
19 | NegotiateSubprotocol: func(r *http.Request) (string, error) {
20 | return r.Header.Get("Sec-Websocket-Protocol"), nil
21 | },
22 | CheckOrigin: func(r *http.Request) bool { return true },
23 | }
24 |
25 | func StreamingRouter(e *echo.Group) {
26 | e.GET("/streaming", StreamingHandler)
27 | }
28 |
29 | func StreamingHandler(c echo.Context) error {
30 | var token string
31 | if token = c.QueryParam("access_token"); token == "" {
32 | if token = c.Request().Header.Get("Sec-Websocket-Protocol"); token == "" {
33 | return errors.New("no access token provided")
34 | }
35 | }
36 | mCtx, err := misstodon.ContextWithEchoContext(c, false)
37 | if err != nil {
38 | return err
39 | }
40 |
41 | conn, err := wsUpgrade.Upgrade(c.Response(), c.Request(), nil)
42 | if err != nil {
43 | return err
44 | }
45 | defer conn.Close()
46 |
47 | ctx, cancel := context.WithCancel(context.Background())
48 | defer cancel()
49 |
50 | ch := make(chan models.StreamEvent)
51 | defer close(ch)
52 | go func() {
53 | if err := streaming.Streaming(ctx, mCtx, token, ch); err != nil {
54 | log.Debug().Caller().Err(err).Msg("Streaming error")
55 | }
56 | _ = conn.Close()
57 | }()
58 |
59 | go func() {
60 | for {
61 | select {
62 | case <-ctx.Done():
63 | return
64 | case event := <-ch:
65 | log.Debug().Caller().Any("event", event).Msg("Streaming")
66 | }
67 | }
68 | }()
69 |
70 | for {
71 | select {
72 | case <-ctx.Done():
73 | return nil
74 | default:
75 | }
76 | _, _, err = conn.ReadMessage()
77 | if err != nil {
78 | if _, ok := err.(*websocket.CloseError); ok {
79 | cancel()
80 | return nil
81 | }
82 | return err
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/internal/api/v1/timelines.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/gizmo-ds/misstodon/internal/misstodon"
8 | "github.com/gizmo-ds/misstodon/internal/utils"
9 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
10 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
11 | "github.com/labstack/echo/v4"
12 | )
13 |
14 | func TimelinesRouter(e *echo.Group) {
15 | group := e.Group("/timelines")
16 | group.GET("/public", TimelinePublicHandler)
17 | group.GET("/home", TimelineHomeHandler)
18 | group.GET("/tag/:hashtag", TimelineHashtag)
19 | }
20 |
21 | func TimelinePublicHandler(c echo.Context) error {
22 | ctx, _ := misstodon.ContextWithEchoContext(c)
23 | limit := 20
24 | if v, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
25 | limit = v
26 | if limit > 40 {
27 | limit = 40
28 | }
29 | }
30 | timelineType := models.TimelinePublicTypeRemote
31 | if c.QueryParam("local") == "true" {
32 | timelineType = models.TimelinePublicTypeLocal
33 | }
34 | list, err := misskey.TimelinePublic(ctx,
35 | timelineType, c.QueryParam("only_media") == "true", limit,
36 | c.QueryParam("max_id"), c.QueryParam("min_id"))
37 | if err != nil {
38 | return err
39 | }
40 | return c.JSON(http.StatusOK, utils.SliceIfNull(list))
41 | }
42 |
43 | func TimelineHomeHandler(c echo.Context) error {
44 | ctx, err := misstodon.ContextWithEchoContext(c, true)
45 | if err != nil {
46 | return err
47 | }
48 | limit := 20
49 | if v, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
50 | limit = v
51 | if limit > 40 {
52 | limit = 40
53 | }
54 | }
55 | list, err := misskey.TimelineHome(ctx,
56 | limit, c.QueryParam("max_id"), c.QueryParam("min_id"))
57 | if err != nil {
58 | return err
59 | }
60 | return c.JSON(http.StatusOK, utils.SliceIfNull(list))
61 | }
62 |
63 | func TimelineHashtag(c echo.Context) error {
64 | ctx, _ := misstodon.ContextWithEchoContext(c)
65 |
66 | limit := 20
67 | if v, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
68 | limit = v
69 | if limit > 40 {
70 | limit = 40
71 | }
72 | }
73 |
74 | list, err := misskey.SearchStatusByHashtag(ctx, c.Param("hashtag"),
75 | limit, c.QueryParam("max_id"), c.QueryParam("since_id"), c.QueryParam("min_id"))
76 | if err != nil {
77 | return err
78 | }
79 | return c.JSON(http.StatusOK, utils.SliceIfNull(list))
80 | }
81 |
--------------------------------------------------------------------------------
/internal/api/v1/trends.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/gizmo-ds/misstodon/internal/misstodon"
8 | "github.com/gizmo-ds/misstodon/internal/utils"
9 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
10 | "github.com/labstack/echo/v4"
11 | )
12 |
13 | func TrendsRouter(e *echo.Group) {
14 | group := e.Group("/trends")
15 | group.GET("/tags", TrendsTagsHandler)
16 | group.GET("/statuses", TrendsStatusHandler)
17 | }
18 |
19 | func TrendsTagsHandler(c echo.Context) error {
20 | limit := 10
21 | if v, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
22 | limit = v
23 | if limit > 20 {
24 | limit = 20
25 | }
26 | }
27 | offset, _ := strconv.Atoi(c.QueryParam("offset"))
28 |
29 | ctx, _ := misstodon.ContextWithEchoContext(c)
30 |
31 | tags, err := misskey.TrendsTags(ctx, limit, offset)
32 | if err != nil {
33 | return err
34 | }
35 | return c.JSON(http.StatusOK, utils.SliceIfNull(tags))
36 | }
37 |
38 | func TrendsStatusHandler(c echo.Context) error {
39 | limit := 20
40 | if v, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
41 | limit = v
42 | if limit > 30 {
43 | limit = 30
44 | }
45 | }
46 | offset, _ := strconv.Atoi(c.QueryParam("offset"))
47 | ctx, _ := misstodon.ContextWithEchoContext(c)
48 | statuses, err := misskey.TrendsStatus(ctx, limit, offset)
49 | if err != nil {
50 | return err
51 | }
52 | return c.JSON(http.StatusOK, utils.SliceIfNull(statuses))
53 | }
54 |
--------------------------------------------------------------------------------
/internal/api/v2/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gizmo-ds/misstodon/21bf379c5f8547486494792885c3350c51dc78ec/internal/api/v2/.gitkeep
--------------------------------------------------------------------------------
/internal/api/v2/media.go:
--------------------------------------------------------------------------------
1 | package v2
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gizmo-ds/misstodon/internal/misstodon"
7 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
8 | "github.com/labstack/echo/v4"
9 | )
10 |
11 | func MediaRouter(e *echo.Group) {
12 | group := e.Group("/media")
13 | group.POST("", MediaUploadHandler)
14 | }
15 |
16 | func MediaUploadHandler(c echo.Context) error {
17 | file, err := c.FormFile("file")
18 | if err != nil {
19 | return c.JSON(http.StatusBadRequest, echo.Map{"error": err.Error()})
20 | }
21 | description := c.FormValue("description")
22 |
23 | ctx, err := misstodon.ContextWithEchoContext(c, true)
24 | if err != nil {
25 | return err
26 | }
27 | ma, err := misskey.MediaUpload(ctx, file, description)
28 | if err != nil {
29 | return err
30 | }
31 | return c.JSON(http.StatusOK, ma)
32 | }
33 |
--------------------------------------------------------------------------------
/internal/api/wellknown/wellknown.go:
--------------------------------------------------------------------------------
1 | package wellknown
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gizmo-ds/misstodon/internal/api/httperror"
7 | "github.com/gizmo-ds/misstodon/internal/api/middleware"
8 | "github.com/gizmo-ds/misstodon/internal/utils"
9 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
10 | "github.com/labstack/echo/v4"
11 | )
12 |
13 | func Router(e *echo.Group) {
14 | group := e.Group("/.well-known", middleware.CORS())
15 | group.GET("/nodeinfo", NodeInfoHandler)
16 | group.GET("/webfinger", WebFingerHandler)
17 | group.GET("/host-meta", HostMetaHandler)
18 | group.GET("/change-password", ChangePasswordHandler)
19 | }
20 |
21 | func NodeInfoHandler(c echo.Context) error {
22 | server := c.Get("proxy-server").(string)
23 | href := "https://" + c.Request().Host + "/nodeinfo/2.0"
24 | if server != "" {
25 | href += "?server=" + server
26 | }
27 | return c.JSON(http.StatusOK, utils.Map{
28 | "links": []utils.StrMap{
29 | {
30 | "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
31 | "href": href,
32 | },
33 | },
34 | })
35 | }
36 |
37 | func WebFingerHandler(c echo.Context) error {
38 | resource := c.QueryParam("resource")
39 | if resource == "" {
40 | return c.JSON(http.StatusBadRequest, httperror.ServerError{
41 | Error: "resource is required",
42 | })
43 | }
44 | return misskey.WebFinger(c.Get("proxy-server").(string), resource, c.Response().Writer)
45 | }
46 |
47 | func HostMetaHandler(c echo.Context) error {
48 | return misskey.HostMeta(c.Get("proxy-server").(string), c.Response().Writer)
49 | }
50 |
51 | func ChangePasswordHandler(c echo.Context) error {
52 | server := c.Get("proxy-server").(string)
53 | return c.Redirect(http.StatusMovedPermanently, utils.JoinURL(server, "/settings/security"))
54 | }
55 |
--------------------------------------------------------------------------------
/internal/database/buntdb/db.go:
--------------------------------------------------------------------------------
1 | package buntdb
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "time"
7 |
8 | "github.com/tidwall/buntdb"
9 | )
10 |
11 | type Database struct {
12 | db *buntdb.DB
13 | }
14 |
15 | func NewDatabase(address string) *Database {
16 | if _, err := os.Stat(filepath.Dir(address)); os.IsNotExist(err) {
17 | if err = os.MkdirAll(filepath.Dir(address), 0755); err != nil {
18 | panic(err)
19 | }
20 | }
21 | db, err := buntdb.Open(address)
22 | if err != nil {
23 | panic(err)
24 | }
25 | return &Database{db}
26 | }
27 |
28 | func (d *Database) Get(server, key string) (string, bool) {
29 | var value string
30 | if err := d.db.View(func(tx *buntdb.Tx) error {
31 | var err error
32 | value, err = tx.Get(server + ":" + key)
33 | return err
34 | }); err != nil {
35 | return "", false
36 | }
37 | return value, true
38 | }
39 |
40 | func (d *Database) Set(server, key, value string, expire int64) error {
41 | return d.db.Update(func(tx *buntdb.Tx) error {
42 | opt := &buntdb.SetOptions{
43 | Expires: true,
44 | TTL: time.Unix(expire, 0).Sub(time.Now()),
45 | }
46 | if expire < 0 {
47 | opt.Expires = false
48 | opt.TTL = 0
49 | }
50 | _, _, err := tx.Set(server+":"+key, value, opt)
51 | return err
52 | })
53 | }
54 |
55 | func (d *Database) Close() error {
56 | return d.db.Close()
57 | }
58 |
--------------------------------------------------------------------------------
/internal/database/database.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "github.com/gizmo-ds/misstodon/internal/database/buntdb"
5 | )
6 |
7 | type DbType = string
8 |
9 | const (
10 | DbTypeMemory DbType = "memory"
11 | DbTypeBuntDb DbType = "buntdb"
12 | )
13 |
14 | type Database interface {
15 | Get(server, key string) (string, bool)
16 | Set(server, key, value string, expire int64) error
17 | Close() error
18 | }
19 |
20 | func NewDatabase(dbType, address string) Database {
21 | switch dbType {
22 | case DbTypeBuntDb:
23 | return buntdb.NewDatabase(address)
24 | case DbTypeMemory:
25 | return buntdb.NewDatabase(":memory:")
26 | default:
27 | panic("unknown database type")
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/internal/global/buildinfo.go:
--------------------------------------------------------------------------------
1 | package global
2 |
3 | var (
4 | AppVersion = "development"
5 | )
6 |
--------------------------------------------------------------------------------
/internal/global/config.go:
--------------------------------------------------------------------------------
1 | package global
2 |
3 | import (
4 | "github.com/gizmo-ds/misstodon/internal/database"
5 | "github.com/jinzhu/configor"
6 | )
7 |
8 | type config struct {
9 | Proxy struct {
10 | FallbackServer string `toml:"fallback_server" yaml:"fallback_server" env:"MISSTODON_FALLBACK_SERVER"`
11 | } `toml:"proxy" yaml:"proxy"`
12 | Server struct {
13 | BindAddress string `toml:"bind_address" yaml:"bind_address" env:"MISSTODON_SERVER_BIND_ADDRESS"`
14 | AutoTLS bool `toml:"auto_tls" yaml:"auto_tls" env:"MISSTODON_SERVER_AUTO_TLS"`
15 | Domain string `toml:"domain" yaml:"domain" env:"MISSTODON_SERVER_DOMAIN"`
16 | TlsCertFile string `toml:"tls_cert_file" yaml:"tls_cert_file" env:"MISSTODON_SERVER_TLS_CERT_FILE"`
17 | TlsKeyFile string `toml:"tls_key_file" yaml:"tls_key_file" env:"MISSTODON_SERVER_TLS_KEY_FILE"`
18 | } `toml:"server" yaml:"server"`
19 | Logger struct {
20 | Level int8 `toml:"level" yaml:"level" env:"MISSTODON_LOGGER_LEVEL"`
21 | ConsoleWriter bool `toml:"console_writer" yaml:"console_writer" env:"MISSTODON_LOGGER_CONSOLE_WRITER"`
22 | RequestLogger bool `toml:"request_logger" yaml:"request_logger" env:"MISSTODON_LOGGER_REQUEST_LOGGER"`
23 | Filename string `toml:"filename" yaml:"filename" env:"MISSTODON_LOGGER_FILENAME"`
24 | MaxAge int `toml:"max_age" yaml:"max_age" env:"MISSTODON_LOGGER_MAX_AGE"`
25 | MaxBackups int `toml:"max_backups" yaml:"max_backups" env:"MISSTODON_LOGGER_MAX_BACKUPS"`
26 | } `toml:"logger" yaml:"logger"`
27 | Database struct {
28 | Type database.DbType `toml:"type" yaml:"type" env:"MISSTODON_DATABASE_TYPE"`
29 | Address string `toml:"address" yaml:"address" env:"MISSTODON_DATABASE_ADDRESS"`
30 | Port int `toml:"port" yaml:"port" env:"MISSTODON_DATABASE_PORT"`
31 | User string `toml:"user" yaml:"user" env:"MISSTODON_DATABASE_USER"`
32 | Password string `toml:"password" yaml:"password" env:"MISSTODON_DATABASE_PASSWORD"`
33 | DbName string `toml:"db_name" yaml:"db_name" env:"MISSTODON_DATABASE_DBNAME"`
34 | } `toml:"database" yaml:"database"`
35 | }
36 |
37 | var Config config
38 |
39 | func LoadConfig(filename string) error {
40 | return configor.
41 | New(&configor.Config{Environment: "production"}).
42 | Load(&Config, filename)
43 | }
44 |
--------------------------------------------------------------------------------
/internal/global/database.go:
--------------------------------------------------------------------------------
1 | package global
2 |
3 | import "github.com/gizmo-ds/misstodon/internal/database"
4 |
5 | var DB database.Database
6 |
--------------------------------------------------------------------------------
/internal/misstodon/context.go:
--------------------------------------------------------------------------------
1 | package misstodon
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 | "sync"
7 | "time"
8 |
9 | "github.com/gizmo-ds/misstodon/internal/utils"
10 | "github.com/labstack/echo/v4"
11 | )
12 |
13 | type Ctx struct {
14 | m sync.Map
15 | }
16 |
17 | type Context interface {
18 | ProxyServer() string
19 | Token() *string
20 | UserID() *string
21 | HOST() *string
22 | }
23 |
24 | func ContextWithEchoContext(eCtx echo.Context, tokenRequired ...bool) (*Ctx, error) {
25 | c := &Ctx{}
26 | if server, ok := eCtx.Get("proxy-server").(string); ok {
27 | c.SetProxyServer(server)
28 | }
29 | token, _ := utils.GetHeaderToken(eCtx.Request().Header)
30 | tokenArr := strings.Split(token, ".")
31 | if len(tokenArr) >= 2 {
32 | c.SetUserID(tokenArr[0])
33 | c.SetToken(tokenArr[1])
34 | }
35 | if len(tokenRequired) > 0 && tokenRequired[0] {
36 | if token == "" {
37 | return nil, echo.NewHTTPError(http.StatusUnauthorized, "the access token is invalid")
38 | }
39 | if len(tokenArr) < 2 {
40 | return nil, echo.NewHTTPError(http.StatusUnauthorized, "the access token is invalid")
41 | }
42 | }
43 | c.SetHOST(eCtx.Request().Host)
44 | return c, nil
45 | }
46 |
47 | func ContextWithValues(proxyServer, token string) *Ctx {
48 | c := &Ctx{}
49 | c.SetProxyServer(proxyServer)
50 | c.SetToken(token)
51 | return c
52 | }
53 |
54 | func (*Ctx) Deadline() (deadline time.Time, ok bool) {
55 | return
56 | }
57 |
58 | func (*Ctx) Done() <-chan struct{} {
59 | return nil
60 | }
61 |
62 | func (*Ctx) Err() error {
63 | return nil
64 | }
65 |
66 | func (c *Ctx) Value(key any) any {
67 | if val, ok := c.m.Load(key); ok {
68 | return val
69 | }
70 | return nil
71 | }
72 |
73 | func (c *Ctx) SetValue(key any, val any) {
74 | c.m.Store(key, val)
75 | }
76 |
77 | func (c *Ctx) String(key string) *string {
78 | if val, ok := c.m.Load(key); ok {
79 | valStr := val.(string)
80 | return &valStr
81 | }
82 | return nil
83 | }
84 |
85 | func (c *Ctx) ProxyServer() string {
86 | return *c.String("proxy-server")
87 | }
88 |
89 | func (c *Ctx) SetProxyServer(val string) {
90 | c.SetValue("proxy-server", val)
91 | }
92 |
93 | func (c *Ctx) Token() *string {
94 | return c.String("token")
95 | }
96 |
97 | func (c *Ctx) SetToken(val string) {
98 | c.SetValue("token", val)
99 | }
100 |
101 | func (c *Ctx) UserID() *string {
102 | return c.String("user_id")
103 | }
104 |
105 | func (c *Ctx) SetUserID(val string) {
106 | c.SetValue("user_id", val)
107 | }
108 |
109 | func (c *Ctx) HOST() *string {
110 | return c.String("host")
111 | }
112 |
113 | func (c *Ctx) SetHOST(val string) {
114 | c.SetValue("host", val)
115 | }
116 |
--------------------------------------------------------------------------------
/internal/utils/http.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 |
7 | "github.com/pkg/errors"
8 | )
9 |
10 | // GetHeaderToken extracts the token from the Authorization header, if any.
11 | func GetHeaderToken(header http.Header) (string, error) {
12 | auth := header.Get("Authorization")
13 | if auth == "" {
14 | return "", errors.New("Authorization header is required")
15 | }
16 | if !strings.Contains(auth, "Bearer ") {
17 | return "", errors.New("Authorization header must be Bearer")
18 | }
19 | return strings.Split(auth, " ")[1], nil
20 | }
21 |
22 | func JoinURL(server string, p ...string) string {
23 | u := strings.Join(append([]string{server}, p...), "")
24 | if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
25 | u = "https://" + u
26 | }
27 | return u
28 | }
29 |
--------------------------------------------------------------------------------
/internal/utils/map.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | type (
4 | Map = map[string]any
5 | StrMap = map[string]string
6 | )
7 |
--------------------------------------------------------------------------------
/internal/utils/utils.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "sort"
5 | "strconv"
6 | "strings"
7 | )
8 |
9 | // Contains returns true if the list contains the item, false otherwise.
10 | func Contains[T comparable](list []T, item T) bool {
11 | for _, i := range list {
12 | if i == item {
13 | return true
14 | }
15 | }
16 | return false
17 | }
18 |
19 | // StrEvaluation evaluates a list of strings and returns the first non-empty
20 | // string, or an empty string if no non-empty strings are found.
21 | func StrEvaluation(str ...string) (v string, ok bool) {
22 | for _, s := range str {
23 | if s != "" {
24 | return s, true
25 | }
26 | }
27 | return
28 | }
29 |
30 | // Unique returns a new list containing only the unique elements of list.
31 | // The order of the elements is preserved.
32 | func Unique[T comparable](list []T) []T {
33 | var result []T
34 | t := make(map[T]struct{})
35 | for _, e := range list {
36 | t[e] = struct{}{}
37 | }
38 | for e := range t {
39 | result = append(result, e)
40 | }
41 | return result
42 | }
43 |
44 | // AcctInfo splits an account string into a username and host.
45 | func AcctInfo(acct string) (username, host string) {
46 | _acct := acct
47 | if strings.Contains(_acct, "acct:") {
48 | _acct = strings.TrimPrefix(_acct, "acct:")
49 | }
50 | if _acct[0] == '@' {
51 | _acct = _acct[1:]
52 | }
53 | if !strings.Contains(_acct, "@") {
54 | username = _acct
55 | } else {
56 | arr := strings.Split(_acct, "@")
57 | username = arr[0]
58 | host = arr[1]
59 | }
60 | return
61 | }
62 |
63 | func GetMentions(text string) []string {
64 | var result []string
65 | for _, s := range strings.Split(text, " ") {
66 | if strings.HasPrefix(s, "@") {
67 | result = append(result, s)
68 | }
69 | }
70 | return result
71 | }
72 |
73 | // SliceIfNull returns the given slice if it is not nil, or an empty slice if it is nil.
74 | func SliceIfNull[T any](slice []T) []T {
75 | if slice == nil {
76 | return []T{}
77 | }
78 | return slice
79 | }
80 |
81 | type accountField struct {
82 | Name string `json:"name"`
83 | Value string `json:"value"`
84 | VerifiedAt *string
85 | }
86 |
87 | // GetFieldsAttributes converts a map of fields to a slice of accountFields
88 | // The map of fields is expected to have keys in the form fields_attributes[][]
89 | // Where is an integer and is one of "name" or "value".
90 | // The order of the accountFields in the returned slice is determined by the order of the values.
91 | func GetFieldsAttributes(values map[string][]string) (fields []accountField) {
92 | var m = make(map[int]*accountField)
93 | var keys []int
94 | for k, v := range values {
95 | ok, index, tag := func(field string) (ok bool, index int, tag string) {
96 | if len(field) < (17+3+6) ||
97 | field[:17] != "fields_attributes" ||
98 | field[17] != '[' ||
99 | field[len(field)-1] != ']' {
100 | return
101 | }
102 | field = field[18 : len(field)-1]
103 | if !strings.Contains(field, "][") {
104 | return
105 | }
106 | parts := strings.Split(field, "][")
107 | if len(parts) != 2 || (parts[0] == "" || parts[1] == "") {
108 | return
109 | }
110 | var err error
111 | index, err = strconv.Atoi(parts[0])
112 | if err != nil {
113 | return
114 | }
115 | ok = true
116 | tag = parts[1]
117 | return
118 | }(k)
119 | if !ok {
120 | continue
121 | }
122 | if _, e := m[index]; !e {
123 | m[index] = &accountField{}
124 | }
125 | switch tag {
126 | case "name":
127 | m[index].Name = v[0]
128 | case "value":
129 | m[index].Value = v[0]
130 | }
131 | }
132 | for i, f := range m {
133 | if f.Name == "" {
134 | continue
135 | }
136 | keys = append(keys, i)
137 | }
138 | sort.Ints(keys)
139 | for _, k := range keys {
140 | fields = append(fields, *m[k])
141 | }
142 | return
143 | }
144 |
145 | func NumRangeLimit[T int | int64](i, min, max T) T {
146 | if i < min {
147 | return min
148 | }
149 | if i > max {
150 | return max
151 | }
152 | return i
153 | }
154 |
--------------------------------------------------------------------------------
/internal/utils/utils_test.go:
--------------------------------------------------------------------------------
1 | package utils_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/gizmo-ds/misstodon/internal/utils"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestAcctInfo(t *testing.T) {
11 | username, host := utils.AcctInfo("@gizmo_ds@misskey.moe")
12 | assert.Equal(t, username, "gizmo_ds")
13 | assert.Equal(t, host, "misskey.moe")
14 |
15 | username, host = utils.AcctInfo("@banana")
16 | assert.Equal(t, username, "banana")
17 | assert.Equal(t, host, "")
18 |
19 | username, host = utils.AcctInfo("user@misskey.io")
20 | assert.Equal(t, username, "user")
21 | assert.Equal(t, host, "misskey.io")
22 | }
23 |
24 | func TestGetMentions(t *testing.T) {
25 | mentions := utils.GetMentions("Hello @gizmo_ds@misskey.moe")
26 | assert.Equal(t, len(mentions), 1)
27 | assert.Equal(t, mentions[0], "@gizmo_ds@misskey.moe")
28 |
29 | mentions = utils.GetMentions("@user@misskey.io")
30 | assert.Equal(t, len(mentions), 1)
31 | assert.Equal(t, mentions[0], "@user@misskey.io")
32 |
33 | mentions = utils.GetMentions("@banana")
34 | assert.Equal(t, len(mentions), 1)
35 | assert.Equal(t, mentions[0], "@banana")
36 | }
37 |
38 | func TestGetFieldsAttributes(t *testing.T) {
39 | values := map[string][]string{
40 | "display_name": {"cy"},
41 | "note": {"hello"},
42 | "fields_attributes[0][name]": {"GitHub"},
43 | "fields_attributes[0][value]": {"https://github.com"},
44 | "fields_attributes[1][name]": {"Twitter"},
45 | "fields_attributes[1][value]": {"https://twitter.com"},
46 | "fields_attributes[3][name]": {"Google"},
47 | "fields_attributes[3][value]": {"https://google.com"},
48 | "fields_attributes[name]": {"Google"},
49 | "fields_attributes[value]": {"https://google.com"},
50 | }
51 | fields := utils.GetFieldsAttributes(values)
52 | assert.Equal(t, 3, len(fields))
53 | assert.Equal(t, "GitHub", fields[0].Name)
54 | assert.Equal(t, "https://github.com", fields[0].Value)
55 | assert.Equal(t, "Twitter", fields[1].Name)
56 | assert.Equal(t, "https://twitter.com", fields[1].Value)
57 | assert.Equal(t, "Google", fields[2].Name)
58 | assert.Equal(t, "https://google.com", fields[2].Value)
59 | }
60 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "misstodon",
3 | "scripts": {
4 | "build": "esbuild pkg/mfm/parse.ts --bundle --format=esm --platform=node --target=es2017 --minify --outfile=pkg/mfm/out.js"
5 | },
6 | "private": true,
7 | "author": "Gizmo",
8 | "license": "AGPL-3.0",
9 | "devDependencies": {
10 | "esbuild": "^0.25.0"
11 | },
12 | "dependencies": {
13 | "mfm-js": "^0.23.3"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/pkg/httpclient/httpclient.go:
--------------------------------------------------------------------------------
1 | package httpclient
2 |
3 | import (
4 | "io"
5 | "net/http"
6 | )
7 |
8 | type (
9 | Client interface {
10 | SetHeader(header, value string)
11 | R() Request
12 | }
13 | Request interface {
14 | SetBaseURL(url string) Request
15 | SetBody(body any) Request
16 | SetQueryParam(param string, value string) Request
17 | SetFormData(data map[string]string) Request
18 | SetDoNotParseResponse(parse bool) Request
19 | SetMultipartField(param string, fileName string, contentType string, reader io.Reader) Request
20 | SetResult(res any) Request
21 | Get(url string) (Response, error)
22 | Post(url string) (Response, error)
23 | Patch(url string) (Response, error)
24 | Delete(url string) (Response, error)
25 | }
26 | Response interface {
27 | RawBody() io.ReadCloser
28 | Body() []byte
29 | String() string
30 | StatusCode() int
31 | Header() http.Header
32 | }
33 | )
34 |
--------------------------------------------------------------------------------
/pkg/httpclient/resty.go:
--------------------------------------------------------------------------------
1 | package httpclient
2 |
3 | import (
4 | "io"
5 |
6 | "github.com/gizmo-ds/misstodon/internal/utils"
7 | "github.com/go-resty/resty/v2"
8 | )
9 |
10 | func NewRestyClient() Client {
11 | return &RestyClient{client: resty.New()}
12 | }
13 |
14 | type (
15 | RestyClient struct {
16 | client *resty.Client
17 | }
18 | RestyRequest struct {
19 | baseURL string
20 | req *resty.Request
21 | }
22 | )
23 |
24 | func (c RestyClient) SetHeader(header, value string) {
25 | c.client.SetHeader(header, value)
26 | }
27 |
28 | func (c RestyClient) R() Request {
29 | return &RestyRequest{req: c.client.R()}
30 | }
31 |
32 | func (r RestyRequest) SetBaseURL(url string) Request {
33 | r.baseURL = url
34 | return r
35 | }
36 |
37 | func (r RestyRequest) SetBody(body any) Request {
38 | r.req = r.req.SetBody(body)
39 | return r
40 | }
41 |
42 | func (r RestyRequest) SetQueryParam(param, value string) Request {
43 | r.req = r.req.SetQueryParam(param, value)
44 | return r
45 | }
46 |
47 | func (r RestyRequest) SetFormData(data map[string]string) Request {
48 | r.req = r.req.SetFormData(data)
49 | return r
50 | }
51 |
52 | func (r RestyRequest) SetDoNotParseResponse(parse bool) Request {
53 | r.req = r.req.SetDoNotParseResponse(parse)
54 | return r
55 | }
56 |
57 | func (r RestyRequest) SetMultipartField(param, fileName, contentType string, reader io.Reader) Request {
58 | r.req = r.req.SetMultipartField(param, fileName, contentType, reader)
59 | return r
60 | }
61 |
62 | func (r RestyRequest) SetResult(res any) Request {
63 | r.req = r.req.SetResult(res)
64 | return r
65 | }
66 |
67 | func (r RestyRequest) Get(url string) (Response, error) {
68 | u := url
69 | if r.baseURL != "" {
70 | u = utils.JoinURL(r.baseURL, url)
71 | }
72 | return r.req.Get(u)
73 | }
74 |
75 | func (r RestyRequest) Post(url string) (Response, error) {
76 | u := url
77 | if r.baseURL != "" {
78 | u = utils.JoinURL(r.baseURL, url)
79 | }
80 | return r.req.Post(u)
81 | }
82 |
83 | func (r RestyRequest) Patch(url string) (Response, error) {
84 | u := url
85 | if r.baseURL != "" {
86 | u = utils.JoinURL(r.baseURL, url)
87 | }
88 | return r.req.Patch(u)
89 | }
90 |
91 | func (r RestyRequest) Delete(url string) (Response, error) {
92 | u := url
93 | if r.baseURL != "" {
94 | u = utils.JoinURL(r.baseURL, url)
95 | }
96 | return r.req.Delete(u)
97 | }
98 |
--------------------------------------------------------------------------------
/pkg/mfm/.gitignore:
--------------------------------------------------------------------------------
1 | out.js
--------------------------------------------------------------------------------
/pkg/mfm/mastodon.go:
--------------------------------------------------------------------------------
1 | package mfm
2 |
3 | import "golang.org/x/net/html"
4 |
5 | func MastodonHashtagHandler(node *html.Node, m MfmNode, serverUrl string) {
6 | a := &html.Node{
7 | Type: html.ElementNode,
8 | Data: "a",
9 | }
10 | a.Attr = append(a.Attr,
11 | html.Attribute{
12 | Key: "href",
13 | Val: serverUrl + "/tags/" + m.Props["hashtag"].(string),
14 | },
15 | html.Attribute{Key: "class", Val: "mention hashtag"},
16 | html.Attribute{Key: "rel", Val: "nofollow noopener noreferrer"},
17 | html.Attribute{Key: "target", Val: "_blank"},
18 | )
19 | tag := &html.Node{
20 | Type: html.ElementNode,
21 | Data: "span",
22 | }
23 | tag.AppendChild(&html.Node{
24 | Type: html.TextNode,
25 | Data: m.Props["hashtag"].(string),
26 | })
27 | a.AppendChild(&html.Node{
28 | Type: html.TextNode,
29 | Data: "#",
30 | })
31 | a.AppendChild(tag)
32 | node.AppendChild(a)
33 | }
34 |
--------------------------------------------------------------------------------
/pkg/mfm/mfm.go:
--------------------------------------------------------------------------------
1 | //go:generate npm run build
2 | package mfm
3 |
4 | import (
5 | "bytes"
6 | _ "embed"
7 | "encoding/json"
8 | "strings"
9 |
10 | "github.com/dop251/goja"
11 | "github.com/gizmo-ds/misstodon/internal/utils"
12 | "github.com/rs/zerolog/log"
13 | "golang.org/x/net/html"
14 | )
15 |
16 | //go:embed out.js
17 | var script string
18 | var DefaultMfmOption = Option{
19 | Url: "https://misskey.io",
20 | }
21 |
22 | var vm = goja.New()
23 | var parseText func(string) string
24 |
25 | func init() {
26 | _, err := vm.RunString(script)
27 | if err != nil {
28 | log.Fatal().Err(err).Msg("Failed to run mfm.js")
29 | }
30 | if err = vm.ExportTo(vm.Get("parse"), &parseText); err != nil {
31 | log.Fatal().Err(err).Msg("Failed to export parse function")
32 | }
33 | }
34 |
35 | // Parse parses MFM to nodes.
36 | func Parse(text string) ([]MfmNode, error) {
37 | var nodes []MfmNode
38 | err := json.Unmarshal([]byte(parseText(text)), &nodes)
39 | return nodes, err
40 | }
41 |
42 | // ToHtml converts MFM to HTML.
43 | func ToHtml(text string, option ...Option) (string, error) {
44 | nodes, err := Parse(text)
45 | if err != nil {
46 | return "", err
47 | }
48 | return toHtml(nodes, option...)
49 | }
50 |
51 | func toHtml(nodes []MfmNode, option ...Option) (string, error) {
52 | node := &html.Node{
53 | Type: html.ElementNode,
54 | Data: "p",
55 | }
56 | if len(option) == 0 {
57 | option = append(option, DefaultMfmOption)
58 | }
59 |
60 | appendChildren(node, nodes, option...)
61 |
62 | var buf bytes.Buffer
63 | if err := html.Render(&buf, node); err != nil {
64 | return "", err
65 | }
66 | h := buf.String()
67 | // NOTE: misskey的br标签不符合XHTML 1.1,需要替换为
68 | h = strings.ReplaceAll(h, "
", "
")
69 | return h, nil
70 | }
71 |
72 | func appendChildren(parent *html.Node, children []MfmNode, option ...Option) {
73 | for _, child := range children {
74 | switch child.Type {
75 | case nodeTypePlain:
76 | n := &html.Node{
77 | Type: html.ElementNode,
78 | Data: "span",
79 | }
80 | appendChildren(n, child.Children, option...)
81 | parent.AppendChild(n)
82 |
83 | case nodeTypeText:
84 | n := &html.Node{
85 | Type: html.ElementNode,
86 | Data: "span",
87 | }
88 | var text, ok = "", false
89 | if text, ok = child.Props["text"].(string); !ok {
90 | return
91 | }
92 | text = strings.ReplaceAll(text, "\r", "")
93 |
94 | arr := strings.Split(text, "\n")
95 | for i := 0; i < len(arr); i++ {
96 | if i > 0 && i < len(arr) {
97 | n.AppendChild(&html.Node{
98 | Type: html.ElementNode,
99 | Data: "br",
100 | })
101 | }
102 | n.AppendChild(&html.Node{
103 | Type: html.TextNode,
104 | Data: arr[i],
105 | })
106 | }
107 |
108 | appendChildren(n, child.Children, option...)
109 | parent.AppendChild(n)
110 |
111 | case nodeTypeBold:
112 | n := &html.Node{
113 | Type: html.ElementNode,
114 | Data: "b",
115 | }
116 | appendChildren(n, child.Children, option...)
117 | parent.AppendChild(n)
118 |
119 | case nodeTypeQuote:
120 | n := &html.Node{
121 | Type: html.ElementNode,
122 | Data: "blockquote",
123 | }
124 | appendChildren(n, child.Children, option...)
125 | parent.AppendChild(n)
126 |
127 | case nodeTypeInlineCode:
128 | n := &html.Node{
129 | Type: html.ElementNode,
130 | Data: "code",
131 | }
132 | n.AppendChild(&html.Node{
133 | Type: html.TextNode,
134 | Data: child.Props["code"].(string),
135 | })
136 | parent.AppendChild(n)
137 |
138 | case nodeTypeSearch:
139 | a := &html.Node{
140 | Type: html.ElementNode,
141 | Data: "a",
142 | }
143 | a.Attr = append(a.Attr, html.Attribute{
144 | Key: "href",
145 | Val: "https://www.google.com/search?q=" + child.Props["query"].(string),
146 | })
147 | a.AppendChild(&html.Node{
148 | Type: html.TextNode,
149 | Data: child.Props["content"].(string),
150 | })
151 | parent.AppendChild(a)
152 |
153 | case nodeTypeMathBlock:
154 | n := &html.Node{
155 | Type: html.ElementNode,
156 | Data: "code",
157 | }
158 | n.AppendChild(&html.Node{
159 | Type: html.TextNode,
160 | Data: child.Props["formula"].(string),
161 | })
162 | parent.AppendChild(n)
163 |
164 | case nodeTypeCenter:
165 | n := &html.Node{
166 | Type: html.ElementNode,
167 | Data: "div",
168 | }
169 | appendChildren(n, child.Children, option...)
170 | parent.AppendChild(n)
171 |
172 | case nodeTypeFn:
173 | n := &html.Node{
174 | Type: html.ElementNode,
175 | Data: "i",
176 | }
177 | appendChildren(n, child.Children, option...)
178 | parent.AppendChild(n)
179 |
180 | case nodeTypeSmall:
181 | n := &html.Node{
182 | Type: html.ElementNode,
183 | Data: "small",
184 | }
185 | appendChildren(n, child.Children, option...)
186 | parent.AppendChild(n)
187 |
188 | case nodeTypeStrike:
189 | n := &html.Node{
190 | Type: html.ElementNode,
191 | Data: "del",
192 | }
193 | appendChildren(n, child.Children, option...)
194 | parent.AppendChild(n)
195 |
196 | case nodeTypeItalic:
197 | n := &html.Node{
198 | Type: html.ElementNode,
199 | Data: "i",
200 | }
201 | appendChildren(n, child.Children, option...)
202 | parent.AppendChild(n)
203 |
204 | case nodeTypeBlockCode: // NOTE: 当前版本的mfm.js(0.23.3)不支持, 所以下面的代码没有进行测试
205 | pre := &html.Node{
206 | Type: html.ElementNode,
207 | Data: "pre",
208 | }
209 | inner := &html.Node{
210 | Type: html.ElementNode,
211 | Data: "code",
212 | }
213 | inner.AppendChild(&html.Node{
214 | Type: html.TextNode,
215 | Data: child.Props["code"].(string),
216 | })
217 | pre.AppendChild(inner)
218 | parent.AppendChild(pre)
219 |
220 | case nodeTypeEmojiCode:
221 | parent.AppendChild(&html.Node{
222 | Type: html.TextNode,
223 | Data: "\u200B:" + child.Props["name"].(string) + ":\u200B",
224 | })
225 |
226 | case nodeTypeUnicodeEmoji:
227 | parent.AppendChild(&html.Node{
228 | Type: html.TextNode,
229 | Data: child.Props["emoji"].(string),
230 | })
231 |
232 | case nodeTypeHashtag:
233 | if option[0].HashtagHandler != nil {
234 | option[0].HashtagHandler(parent, child, option[0].Url)
235 | break
236 | }
237 | a := &html.Node{
238 | Type: html.ElementNode,
239 | Data: "a",
240 | }
241 | hashtag := child.Props["hashtag"].(string)
242 | a.Attr = append(a.Attr, html.Attribute{
243 | Key: "href",
244 | Val: option[0].Url + "/tags/" + hashtag,
245 | })
246 | a.Attr = append(a.Attr, html.Attribute{
247 | Key: "rel",
248 | Val: "tag",
249 | })
250 | a.AppendChild(&html.Node{
251 | Type: html.TextNode,
252 | Data: "#" + hashtag,
253 | })
254 | parent.AppendChild(a)
255 |
256 | case nodeTypeMathInline:
257 | n := &html.Node{
258 | Type: html.ElementNode,
259 | Data: "code",
260 | }
261 | n.AppendChild(&html.Node{
262 | Type: html.TextNode,
263 | Data: child.Props["formula"].(string),
264 | })
265 | parent.AppendChild(n)
266 |
267 | case nodeTypeLink:
268 | a := &html.Node{
269 | Type: html.ElementNode,
270 | Data: "a",
271 | }
272 | a.Attr = append(a.Attr, html.Attribute{
273 | Key: "href",
274 | Val: child.Props["url"].(string),
275 | })
276 | appendChildren(a, child.Children, option...)
277 | parent.AppendChild(a)
278 |
279 | case nodeTypeMention:
280 | a := &html.Node{
281 | Type: html.ElementNode,
282 | Data: "a",
283 | }
284 | acct := child.Props["acct"].(string)
285 | username, host := utils.AcctInfo(acct)
286 | if host == "" {
287 | host = option[0].Url[8:]
288 | }
289 | a.Attr = append(a.Attr,
290 | html.Attribute{
291 | Key: "href",
292 | Val: "https://" + host + "/@" + username,
293 | },
294 | html.Attribute{
295 | Key: "class",
296 | Val: "u-url mention",
297 | })
298 | a.AppendChild(&html.Node{
299 | Type: html.TextNode,
300 | Data: acct,
301 | })
302 | parent.AppendChild(a)
303 |
304 | case nodeTypeUrl:
305 | a := &html.Node{
306 | Type: html.ElementNode,
307 | Data: "a",
308 | }
309 | a.Attr = append(a.Attr, html.Attribute{
310 | Key: "href",
311 | Val: child.Props["url"].(string),
312 | })
313 | a.AppendChild(&html.Node{
314 | Type: html.TextNode,
315 | Data: child.Props["url"].(string),
316 | })
317 | parent.AppendChild(a)
318 |
319 | default:
320 | log.Warn().Str("type", string(child.Type)).Msg("unknown node type")
321 | }
322 | }
323 | }
324 |
--------------------------------------------------------------------------------
/pkg/mfm/mfm_test.go:
--------------------------------------------------------------------------------
1 | package mfm_test
2 |
3 | import (
4 | "os"
5 | "testing"
6 |
7 | "github.com/gizmo-ds/misstodon/pkg/mfm"
8 | "github.com/rs/zerolog"
9 | "github.com/rs/zerolog/log"
10 | "github.com/stretchr/testify/assert"
11 | )
12 |
13 | func TestMain(m *testing.M) {
14 | log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr})
15 | m.Run()
16 | }
17 |
18 | func TestParse(t *testing.T) {
19 | _, err := mfm.Parse("Hello, world!")
20 | assert.NoError(t, err)
21 | }
22 |
23 | func TestToHtml(t *testing.T) {
24 | t.Run("Plain", func(t *testing.T) {
25 | s, err := mfm.ToHtml("Hello, world!")
26 | assert.NoError(t, err)
27 | assert.Equal(t,
28 | "Hello, world!
", s)
29 | })
30 | t.Run("Quote", func(t *testing.T) {
31 | s, err := mfm.ToHtml("> abc")
32 | assert.NoError(t, err)
33 | assert.Equal(t,
34 | `abc
`, s)
35 | })
36 | t.Run("InlineCode", func(t *testing.T) {
37 | s, err := mfm.ToHtml("`abc`")
38 | assert.NoError(t, err)
39 | assert.Equal(t,
40 | `abc
`, s)
41 | })
42 | t.Run("Search", func(t *testing.T) {
43 | s, err := mfm.ToHtml("MFM 書き方 Search")
44 | assert.NoError(t, err)
45 | assert.Equal(t,
46 | `MFM 書き方 Search
`, s)
47 | })
48 | t.Run("Text", func(t *testing.T) {
49 | s, err := mfm.ToHtml("hello world")
50 | assert.NoError(t, err)
51 | assert.Equal(t, s,
52 | `hello world
`)
53 | })
54 | // NOTE: 当前版本的mfm.js(0.23.3)不支持, 这里使用了与当前版本行为一致的测试用例
55 | t.Run("BlockCode", func(t *testing.T) {
56 | s, err := mfm.ToHtml("```js\nabc\n````")
57 | assert.NoError(t, err)
58 | assert.Equal(t,
59 | "```js
abc
````
", s)
60 | })
61 | t.Run("MathBlock", func(t *testing.T) {
62 | s, err := mfm.ToHtml("\\[a = 1\\]")
63 | assert.NoError(t, err)
64 | assert.Equal(t,
65 | "a = 1
", s)
66 |
67 | s, err = mfm.ToHtml("\\[\na = 2\n\\]")
68 | assert.NoError(t, err)
69 | assert.Equal(t,
70 | "a = 2
", s)
71 | })
72 | t.Run("Center", func(t *testing.T) {
73 | s, err := mfm.ToHtml("abc")
74 | assert.NoError(t, err)
75 | assert.Equal(t,
76 | "abc
", s)
77 |
78 | s, err = mfm.ToHtml("\nabc\ndef\n")
79 | assert.NoError(t, err)
80 | assert.Equal(t,
81 | "abc
def
", s)
82 | })
83 | t.Run("Fn?", func(t *testing.T) {
84 | s, err := mfm.ToHtml("***big!***")
85 | assert.NoError(t, err)
86 | assert.Equal(t,
87 | "big!
", s)
88 | })
89 | t.Run("Bold", func(t *testing.T) {
90 | s, err := mfm.ToHtml("**bold**")
91 | assert.NoError(t, err)
92 | assert.Equal(t,
93 | "bold
", s)
94 |
95 | s, err = mfm.ToHtml("__bold__")
96 | assert.NoError(t, err)
97 | assert.Equal(t,
98 | "bold
", s)
99 |
100 | s, err = mfm.ToHtml("bold")
101 | assert.NoError(t, err)
102 | assert.Equal(t,
103 | "bold
", s)
104 | })
105 | t.Run("Small", func(t *testing.T) {
106 | s, err := mfm.ToHtml("small")
107 | assert.NoError(t, err)
108 | assert.Equal(t,
109 | "small
", s)
110 | })
111 | t.Run("Strike", func(t *testing.T) {
112 | s, err := mfm.ToHtml("~~strike~~")
113 | assert.NoError(t, err)
114 | assert.Equal(t,
115 | "strike
", s)
116 |
117 | s, err = mfm.ToHtml("strike")
118 | assert.NoError(t, err)
119 | assert.Equal(t,
120 | "strike
", s)
121 | })
122 | t.Run("Italic", func(t *testing.T) {
123 | s, err := mfm.ToHtml("italic")
124 | assert.NoError(t, err)
125 | assert.Equal(t,
126 | "italic
", s)
127 |
128 | s, err = mfm.ToHtml("*italic*")
129 | assert.NoError(t, err)
130 | assert.Equal(t,
131 | "italic
", s)
132 |
133 | s, err = mfm.ToHtml("_italic_")
134 | assert.NoError(t, err)
135 | assert.Equal(t,
136 | "italic
", s)
137 | })
138 | t.Run("EmojiCode", func(t *testing.T) {
139 | s, err := mfm.ToHtml(":thinking_ai:")
140 | assert.NoError(t, err)
141 | assert.Equal(t,
142 | "\u200B:thinking_ai:\u200B
", s)
143 | })
144 | t.Run("UnicodeEmoji", func(t *testing.T) {
145 | s, err := mfm.ToHtml("$[shake 🍮]")
146 | assert.NoError(t, err)
147 | assert.Equal(t,
148 | "🍮
", s)
149 |
150 | s, err = mfm.ToHtml("$[spin.alternate 🍮]")
151 | assert.NoError(t, err)
152 | assert.Equal(t,
153 | "🍮
", s)
154 |
155 | s, err = mfm.ToHtml("$[shake.speed=1s 🍮]")
156 | assert.NoError(t, err)
157 | assert.Equal(t,
158 | "🍮
", s)
159 |
160 | s, err = mfm.ToHtml("$[flip.h,v MisskeyでFediverseの世界が広がります]")
161 | assert.NoError(t, err)
162 | assert.Equal(t,
163 | "MisskeyでFediverseの世界が広がります
", s)
164 | })
165 | t.Run("Hashtag", func(t *testing.T) {
166 | s, err := mfm.ToHtml("#hello")
167 | assert.NoError(t, err)
168 | assert.Equal(t,
169 | "#hello
", s)
170 | })
171 | t.Run("MathInline", func(t *testing.T) {
172 | s, err := mfm.ToHtml("\\(y = 2x\\)")
173 | assert.NoError(t, err)
174 | assert.Equal(t,
175 | "y = 2x
", s)
176 | })
177 | t.Run("Link", func(t *testing.T) {
178 | s, err := mfm.ToHtml("[Misskey.io](https://misskey.io/)")
179 | assert.NoError(t, err)
180 | assert.Equal(t,
181 | "Misskey.io
", s)
182 |
183 | s, err = mfm.ToHtml("?[Misskey.io](https://misskey.io/)")
184 | assert.NoError(t, err)
185 | assert.Equal(t,
186 | "Misskey.io
", s)
187 | })
188 | t.Run("Mention", func(t *testing.T) {
189 | s, err := mfm.ToHtml("@user@misskey.io")
190 | assert.NoError(t, err)
191 | assert.Equal(t,
192 | "@user@misskey.io
", s)
193 |
194 | s, err = mfm.ToHtml("@user")
195 | assert.NoError(t, err)
196 | assert.Equal(t,
197 | "@user
", s)
198 |
199 | s, err = mfm.ToHtml("@gizmo_ds@misskey.moe")
200 | assert.NoError(t, err)
201 | assert.Equal(t,
202 | "@gizmo_ds@misskey.moe
", s)
203 | })
204 | t.Run("URL", func(t *testing.T) {
205 | s, err := mfm.ToHtml("https://misskey.io/@ai")
206 | assert.NoError(t, err)
207 | assert.Equal(t,
208 | "https://misskey.io/@ai
", s)
209 |
210 | s, err = mfm.ToHtml("http://hoge.jp/abc")
211 | assert.NoError(t, err)
212 | assert.Equal(t,
213 | "http://hoge.jp/abc
", s)
214 |
215 | s, err = mfm.ToHtml("")
216 | assert.NoError(t, err)
217 | assert.Equal(t,
218 | "https://misskey.io/@ai
", s)
219 |
220 | s, err = mfm.ToHtml("")
221 | assert.NoError(t, err)
222 | assert.Equal(t,
223 | "http://藍.jp/abc
", s)
224 | })
225 | }
226 |
227 | func TestCustomHashtagHandler(t *testing.T) {
228 | s, err := mfm.ToHtml("#hello", mfm.Option{
229 | Url: "https://misskey.io",
230 | HashtagHandler: mfm.MastodonHashtagHandler,
231 | })
232 | assert.NoError(t, err)
233 | assert.Equal(t,
234 | `#hello
`, s)
235 | }
236 |
--------------------------------------------------------------------------------
/pkg/mfm/models.go:
--------------------------------------------------------------------------------
1 | package mfm
2 |
3 | import "golang.org/x/net/html"
4 |
5 | type mfmNodeType string
6 |
7 | const (
8 | nodeTypeBold mfmNodeType = "bold"
9 | nodeTypeSmall mfmNodeType = "small"
10 | nodeTypeStrike mfmNodeType = "strike"
11 | nodeTypeItalic mfmNodeType = "italic"
12 | nodeTypeFn mfmNodeType = "fn"
13 | nodeTypeBlockCode mfmNodeType = "blockCode"
14 | nodeTypeCenter mfmNodeType = "center"
15 | nodeTypeEmojiCode mfmNodeType = "emojiCode"
16 | nodeTypeUnicodeEmoji mfmNodeType = "unicodeEmoji"
17 | nodeTypeHashtag mfmNodeType = "hashtag"
18 | nodeTypeInlineCode mfmNodeType = "inlineCode"
19 | nodeTypeMathInline mfmNodeType = "mathInline"
20 | nodeTypeMathBlock mfmNodeType = "mathBlock"
21 | nodeTypeLink mfmNodeType = "link"
22 | nodeTypeMention mfmNodeType = "mention"
23 | nodeTypeQuote mfmNodeType = "quote"
24 | nodeTypeText mfmNodeType = "text"
25 | nodeTypeUrl mfmNodeType = "url"
26 | nodeTypeSearch mfmNodeType = "search"
27 | nodeTypePlain mfmNodeType = "plain"
28 | )
29 |
30 | type (
31 | MfmNode struct {
32 | Type mfmNodeType
33 | Props map[string]any
34 | Children []MfmNode
35 | }
36 | Option struct {
37 | Url string
38 | HashtagHandler func(*html.Node, MfmNode, string)
39 | }
40 | )
41 |
--------------------------------------------------------------------------------
/pkg/mfm/parse.ts:
--------------------------------------------------------------------------------
1 | import { parse } from "mfm-js"
2 |
3 | globalThis["parse"] = (text: string) => JSON.stringify(parse(text))
4 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/Account.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type Account struct {
4 | ID string `json:"id"`
5 | Username string `json:"username"`
6 | Acct string `json:"acct"`
7 | DisplayName string `json:"display_name"`
8 | Locked bool `json:"locked"`
9 | Bot bool `json:"bot"`
10 | Discoverable bool `json:"discoverable"`
11 | Group bool `json:"group"`
12 | CreatedAt string `json:"created_at"`
13 | Note string `json:"note"`
14 | Url string `json:"url"`
15 | Avatar string `json:"avatar"`
16 | AvatarStatic string `json:"avatar_static"`
17 | Header string `json:"header"`
18 | HeaderStatic string `json:"header_static"`
19 | FollowersCount int `json:"followers_count"`
20 | FollowingCount int `json:"following_count"`
21 | StatusesCount int `json:"statuses_count"`
22 | LastStatusAt *string `json:"last_status_at"`
23 | Emojis []CustomEmoji `json:"emojis"`
24 | Moved *Account `json:"moved,omitempty"`
25 | Suspended *bool `json:"suspended,omitempty"`
26 | Limited *bool `json:"limited,omitempty"`
27 | Fields []AccountField `json:"fields"`
28 | }
29 |
30 | type AccountField struct {
31 | Name string `json:"name"`
32 | Value string `json:"value"`
33 | VerifiedAt *string `json:"verified_at,omitempty"`
34 | }
35 |
36 | type CredentialAccount struct {
37 | Account
38 | Source struct {
39 | Privacy StatusVisibility `json:"privacy"`
40 | Sensitive bool `json:"sensitive"`
41 | Language string `json:"language"`
42 | Note string `json:"note"`
43 | Fields []AccountField `json:"fields"`
44 | FollowRequestsCount int `json:"follow_requests_count"`
45 | } `json:"source"`
46 | Role *struct {
47 | Id string `json:"id"`
48 | Name string `json:"name"`
49 | Color string `json:"color"`
50 | Position int `json:"position"`
51 | Permissions int `json:"permissions"`
52 | Highlighted bool `json:"highlighted"`
53 | CreatedAt string `json:"created_at"`
54 | UpdatedAt string `json:"updated_at"`
55 | } `json:"role,omitempty"`
56 | }
57 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/Application.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | var (
4 | ApplicationPermissionRead = []string{
5 | MkAppPermissionReadAccount,
6 | MkAppPermissionReadBlocks,
7 | MkAppPermissionReadFavorites,
8 | MkAppPermissionReadFollowing,
9 | MkAppPermissionReadMutes,
10 | MkAppPermissionReadNotifications,
11 | MkAppPermissionReadMessaging,
12 | MkAppPermissionReadDrive,
13 | MkAppPermissionReadReactions,
14 | }
15 | ApplicationPermissionWrite = []string{
16 | MkAppPermissionWriteAccount,
17 | MkAppPermissionWriteBlocks,
18 | MkAppPermissionWriteMessaging,
19 | MkAppPermissionWriteMutes,
20 | MkAppPermissionWriteDrive,
21 | MkAppPermissionWriteNotifications,
22 | MkAppPermissionWriteNotes,
23 | MkAppPermissionWriteFavorites,
24 | MkAppPermissionWriteReactions,
25 | }
26 | ApplicationPermissionFollow = []string{
27 | MkAppPermissionReadBlocks,
28 | MkAppPermissionWriteBlocks,
29 | MkAppPermissionWriteFollowing,
30 | MkAppPermissionReadFollowing,
31 | }
32 | )
33 |
34 | type Application struct {
35 | ID string `json:"id"`
36 | Name string `json:"name"`
37 | Website *string `json:"website"`
38 | VapidKey string `json:"vapid_key"`
39 | ClientID *string `json:"client_id"`
40 | ClientSecret *string `json:"client_secret"`
41 | RedirectUri string `json:"redirect_uri"`
42 | }
43 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/Context.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type Context struct {
4 | Ancestors []Status `json:"ancestors"`
5 | Descendants []Status `json:"descendants"`
6 | }
7 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/CustomEmoji.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type CustomEmoji struct {
4 | Shortcode string `json:"shortcode"`
5 | Url string `json:"url"`
6 | StaticUrl string `json:"static_url"`
7 | VisibleInPicker bool `json:"visible_in_picker"`
8 | Category string `json:"category"`
9 | }
10 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/Instance.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type (
4 | Instance struct {
5 | Uri string `json:"uri"`
6 | Title string `json:"title"`
7 | ShortDescription string `json:"short_description"`
8 | Description string `json:"description"`
9 | Email string `json:"email"`
10 | Version string `json:"version"`
11 | Urls InstanceUrls `json:"urls"`
12 | Stats struct {
13 | UserCount int `json:"user_count"`
14 | StatusCount int `json:"status_count"`
15 | DomainCount int `json:"domain_count"`
16 | } `json:"stats"`
17 | Thumbnail string `json:"thumbnail"`
18 | Languages any `json:"languages"`
19 | Registrations bool `json:"registrations"`
20 | ApprovalRequired bool `json:"approval_required"`
21 | InvitesEnabled bool `json:"invites_enabled"`
22 | Configuration struct {
23 | Statuses struct {
24 | MaxCharacters int `json:"max_characters"`
25 | MaxMediaAttachments int `json:"max_media_attachments"`
26 | CharactersReservedPerUrl int `json:"characters_reserved_per_url"`
27 | } `json:"statuses"`
28 | MediaAttachments struct {
29 | SupportedMimeTypes []string `json:"supported_mime_types"`
30 | } `json:"media_attachments"`
31 | } `json:"configuration"`
32 | ContactAccount *Account `json:"contact_account,omitempty"`
33 | Rules []InstanceRule `json:"rules"`
34 | }
35 | InstanceUrls struct {
36 | StreamingApi string `json:"streaming_api"`
37 | }
38 | InstanceRule struct {
39 | ID string `json:"id"`
40 | Text string `json:"text"`
41 | }
42 | )
43 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/MediaAttachment.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type MediaAttachment struct {
4 | ID string `json:"id"`
5 | BlurHash string `json:"blurhash"`
6 | Url string `json:"url"`
7 | Type string `json:"type"`
8 | TextUrl *string `json:"text_url"`
9 | RemoteUrl string `json:"remote_url"`
10 | PreviewUrl string `json:"preview_url"`
11 | Description *string `json:"description"`
12 | Meta struct {
13 | Small struct {
14 | Aspect float64 `json:"aspect"`
15 | Width int `json:"width"`
16 | Height int `json:"height"`
17 | Size string `json:"size"`
18 | } `json:"small"`
19 | Original struct {
20 | Aspect float64 `json:"aspect"`
21 | Width int `json:"width"`
22 | Height int `json:"height"`
23 | Size string `json:"size"`
24 | } `json:"original"`
25 | } `json:"meta"`
26 | }
27 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/MkApplication.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | // https://github.com/misskey-dev/misskey/blob/26ae2dfc0f494c377abd878c00044049fcd2bf37/packages/backend/src/misc/api-permissions.ts#L1
4 | const (
5 | MkAppPermissionReadAccount = "read:account"
6 | MkAppPermissionWriteAccount = "write:account"
7 | MkAppPermissionReadBlocks = "read:blocks"
8 | MkAppPermissionWriteBlocks = "write:blocks"
9 | MkAppPermissionReadDrive = "read:drive"
10 | MkAppPermissionWriteDrive = "write:drive"
11 | MkAppPermissionReadFavorites = "read:favorites"
12 | MkAppPermissionWriteFavorites = "write:favorites"
13 | MkAppPermissionReadFollowing = "read:following"
14 | MkAppPermissionWriteFollowing = "write:following"
15 | MkAppPermissionReadMessaging = "read:messaging"
16 | MkAppPermissionWriteMessaging = "write:messaging"
17 | MkAppPermissionReadMutes = "read:mutes"
18 | MkAppPermissionWriteMutes = "write:mutes"
19 | MkAppPermissionWriteNotes = "write:notes"
20 | MkAppPermissionReadNotifications = "read:notifications"
21 | MkAppPermissionWriteNotifications = "write:notifications"
22 | MkAppPermissionReadReactions = "read:reactions"
23 | MkAppPermissionWriteReactions = "write:reactions"
24 | MkAppPermissionWriteVotes = "write:votes"
25 | MkAppPermissionReadPages = "read:pages"
26 | MkAppPermissionWritePages = "write:pages"
27 | MkAppPermissionReadPageLikes = "read:page-likes"
28 | MkAppPermissionWritePageLikes = "write:page-likes"
29 | MkAppPermissionReadUserGroups = "read:user-groups"
30 | MkAppPermissionWriteUserGroups = "write:user-groups"
31 | MkAppPermissionReadChannels = "read:channels"
32 | MkAppPermissionWriteChannels = "write:channels"
33 | MkAppPermissionReadGallery = "read:gallery"
34 | MkAppPermissionWriteGallery = "write:gallery"
35 | MkAppPermissionReadGalleryLikes = "read:gallery-likes"
36 | MkAppPermissionWriteGalleryLikes = "write:gallery-likes"
37 | )
38 |
39 | type MkApplication struct {
40 | ID string `json:"id"`
41 | Name string `json:"name"`
42 | CallbackUrl string `json:"callbackUrl"`
43 | Permission []string `json:"permission"`
44 | Secret string `json:"secret"`
45 | IsAuthorized bool `json:"isAuthorized"`
46 | }
47 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/MkEmoji.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type MkEmoji struct {
4 | Aliases []string `json:"aliases"`
5 | Name string `json:"name"`
6 | Category *string `json:"category"`
7 | Url string `json:"url"`
8 | }
9 |
10 | func (e MkEmoji) ToCustomEmoji() CustomEmoji {
11 | r := CustomEmoji{
12 | Shortcode: e.Name,
13 | Url: e.Url,
14 | StaticUrl: e.Url,
15 | VisibleInPicker: true,
16 | }
17 | if e.Category != nil {
18 | r.Category = *e.Category
19 | }
20 | return r
21 | }
22 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/MkFile.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | )
7 |
8 | type MkFile struct {
9 | ID string `json:"id"`
10 | ThumbnailUrl string `json:"thumbnailUrl"`
11 | Type string `json:"type"`
12 | Url string `json:"url"`
13 | Name string `json:"name"`
14 | IsSensitive bool `json:"isSensitive"`
15 | Size int64 `json:"size"`
16 | Md5 string `json:"md5"`
17 | CreatedAt string `json:"createdAt"`
18 | BlurHash *string `json:"blurhash"`
19 | Properties struct {
20 | Width int `json:"width"`
21 | Height int `json:"height"`
22 | } `json:"properties"`
23 | }
24 | type MkFolder struct {
25 | Id string `json:"id"`
26 | Name string `json:"name"`
27 | CreatedAt string `json:"createdAt"`
28 | ParentId *string `json:"parentId"`
29 | }
30 |
31 | func (f *MkFile) ToMediaAttachment() MediaAttachment {
32 | a := MediaAttachment{
33 | ID: f.ID,
34 | Url: f.Url,
35 | RemoteUrl: f.Url,
36 | PreviewUrl: f.ThumbnailUrl,
37 | }
38 | a.Meta.Original.Width = f.Properties.Width
39 | a.Meta.Original.Height = f.Properties.Height
40 | if f.Properties.Width > 0 && f.Properties.Height > 0 {
41 | a.Meta.Original.Aspect = float64(f.Properties.Width) / float64(f.Properties.Height)
42 | a.Meta.Original.Size = fmt.Sprintf("%vx%v", f.Properties.Width, f.Properties.Height)
43 | }
44 | if f.BlurHash != nil {
45 | a.BlurHash = *f.BlurHash
46 | }
47 | t := strings.Split(f.Type, "/")[0]
48 | switch t {
49 | case "image":
50 | if f.Type == "image/gif" {
51 | a.Type = "gifv"
52 | } else {
53 | a.Type = "image"
54 | }
55 | case "application":
56 | if f.Type == "application/ogg" {
57 | a.Type = "audio"
58 | } else {
59 | a.Type = "unknown"
60 | }
61 | default:
62 | a.Type = t
63 | }
64 | return a
65 | }
66 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/MkMeta.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type MkMeta struct {
4 | MaintainerName string `json:"maintainerName"`
5 | MaintainerEmail string `json:"maintainerEmail"`
6 | Version string `json:"version"`
7 | Name string `json:"name"`
8 | URI string `json:"uri"`
9 | Description string `json:"description"`
10 | Langs []string `json:"langs"`
11 | TosUrl any `json:"tosUrl"`
12 | RepositoryUrl string `json:"repositoryUrl"`
13 | FeedbackUrl string `json:"feedbackUrl"`
14 | DisableRegistration bool `json:"disableRegistration"`
15 | EmailRequiredForSignup bool `json:"emailRequiredForSignup"`
16 | EnableHCaptcha bool `json:"enableHcaptcha"`
17 | HCaptchaSiteKey string `json:"hcaptchaSiteKey"`
18 | EnableRecaptcha bool `json:"enableRecaptcha"`
19 | RecaptchaSiteKey any `json:"recaptchaSiteKey"`
20 | EnableTurnstile bool `json:"enableTurnstile"`
21 | TurnstileSiteKey any `json:"turnstileSiteKey"`
22 | SwPublicKey string `json:"swPublickey"`
23 | ThemeColor string `json:"themeColor"`
24 | MascotImageUrl string `json:"mascotImageUrl"`
25 | BannerUrl string `json:"bannerUrl"`
26 | ErrorImageUrl string `json:"errorImageUrl"`
27 | IconUrl string `json:"iconUrl"`
28 | BackgroundImageUrl string `json:"backgroundImageUrl"`
29 | LogoImageUrl any `json:"logoImageUrl"`
30 | MaxNoteTextLength int `json:"maxNoteTextLength"`
31 | EnableEmail bool `json:"enableEmail"`
32 | EnableTwitterIntegration bool `json:"enableTwitterIntegration"`
33 | EnableGithubIntegration bool `json:"enableGithubIntegration"`
34 | EnableDiscordIntegration bool `json:"enableDiscordIntegration"`
35 | EnableServiceWorker bool `json:"enableServiceWorker"`
36 | TranslatorAvailable bool `json:"translatorAvailable"`
37 | Policies struct {
38 | GtlAvailable bool `json:"gtlAvailable"`
39 | LtlAvailable bool `json:"ltlAvailable"`
40 | CanPublicNote bool `json:"canPublicNote"`
41 | CanInvite bool `json:"canInvite"`
42 | CanManageCustomEmojis bool `json:"canManageCustomEmojis"`
43 | DriveCapacityMb int `json:"driveCapacityMb"`
44 | PinLimit int `json:"pinLimit"`
45 | AntennaLimit int `json:"antennaLimit"`
46 | WordMuteLimit int `json:"wordMuteLimit"`
47 | WebhookLimit int `json:"webhookLimit"`
48 | ClipLimit int `json:"clipLimit"`
49 | NoteEachClipsLimit int `json:"noteEachClipsLimit"`
50 | UserListLimit int `json:"userListLimit"`
51 | UserEachUserListsLimit int `json:"userEachUserListsLimit"`
52 | RateLimitFactor int `json:"rateLimitFactor"`
53 | } `json:"policies"`
54 | PinnedPages []string `json:"pinnedPages"`
55 | PinnedClipID any `json:"pinnedClipId"`
56 | CacheRemoteFiles bool `json:"cacheRemoteFiles"`
57 | RequireSetup bool `json:"requireSetup"`
58 | ProxyAccountName any `json:"proxyAccountName"`
59 | Features struct {
60 | Registration bool `json:"registration"`
61 | EmailRequiredForSignup bool `json:"emailRequiredForSignup"`
62 | Elasticsearch bool `json:"elasticsearch"`
63 | HCaptcha bool `json:"hcaptcha"`
64 | Recaptcha bool `json:"recaptcha"`
65 | Turnstile bool `json:"turnstile"`
66 | ObjectStorage bool `json:"objectStorage"`
67 | Twitter bool `json:"twitter"`
68 | Github bool `json:"github"`
69 | Discord bool `json:"discord"`
70 | ServiceWorker bool `json:"serviceWorker"`
71 | MiAuth bool `json:"miauth"`
72 | } `json:"features"`
73 | }
74 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/MkNote.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "github.com/gizmo-ds/misstodon/internal/misstodon"
5 | "github.com/gizmo-ds/misstodon/internal/utils"
6 | "github.com/gizmo-ds/misstodon/pkg/mfm"
7 | )
8 |
9 | type MkNoteVisibility string
10 |
11 | const (
12 | MkNoteVisibilityPublic MkNoteVisibility = "public"
13 | MkNoteVisibilityHome MkNoteVisibility = "home"
14 | // MkNoteVisibilityFollow MkNoteVisibility = "follow"
15 | MkNoteVisibilityFollow MkNoteVisibility = "followers"
16 | MkNoteVisibilitySpecif MkNoteVisibility = "specified"
17 | )
18 |
19 | type MkNote struct {
20 | ID string `json:"id"`
21 | CreatedAt string `json:"createdAt"`
22 | ReplyID *string `json:"replyId"`
23 | ThreadId *string `json:"threadId"`
24 | Text *string `json:"text"`
25 | Name *string `json:"name"`
26 | Cw *string `json:"cw"`
27 | UserId string `json:"userId"`
28 | User *MkUser `json:"user"`
29 | LocalOnly bool `json:"localOnly"`
30 | Reply *MkNote `json:"reply"`
31 | ReNote *MkNote `json:"renote"`
32 | ReNoteId *string `json:"renoteId"`
33 | ReNoteCount int `json:"renoteCount"`
34 | RepliesCount int `json:"repliesCount"`
35 | Reactions map[string]int `json:"reactions"`
36 | Visibility MkNoteVisibility `json:"visibility"`
37 | Uri *string `json:"uri"`
38 | Url *string `json:"url"`
39 | Score int `json:"score"`
40 | FileIds []string `json:"fileIds"`
41 | Files []MkFile `json:"files"`
42 | Tags []string `json:"tags"`
43 | MyReaction string `json:"myReaction"`
44 | }
45 |
46 | func (n *MkNote) ToStatus(ctx misstodon.Context) Status {
47 | uid := ctx.UserID()
48 | s := Status{
49 | ID: n.ID,
50 | Url: utils.JoinURL(ctx.ProxyServer(), "/notes/", n.ID),
51 | Uri: utils.JoinURL(ctx.ProxyServer(), "/notes/", n.ID),
52 | CreatedAt: n.CreatedAt,
53 | Emojis: []struct{}{},
54 | MediaAttachments: []MediaAttachment{},
55 | Mentions: []StatusMention{},
56 | ReBlogsCount: n.ReNoteCount,
57 | RepliesCount: n.RepliesCount,
58 | Favourited: n.MyReaction != "",
59 | }
60 | s.FavouritesCount = func() int {
61 | var count int
62 | for _, r := range n.Reactions {
63 | count += r
64 | }
65 | return count
66 | }()
67 | for _, tag := range n.Tags {
68 | s.Tags = append(s.Tags, StatusTag{
69 | Name: tag,
70 | Url: utils.JoinURL(ctx.ProxyServer(), "/tags/", tag),
71 | })
72 | }
73 | if n.Text != nil {
74 | s.Content = *n.Text
75 | if content, err := mfm.ToHtml(*n.Text, mfm.Option{
76 | Url: utils.JoinURL(ctx.ProxyServer()),
77 | HashtagHandler: mfm.MastodonHashtagHandler,
78 | }); err == nil {
79 | s.Content = content
80 | }
81 | }
82 | if n.User != nil {
83 | a, err := n.User.ToAccount(ctx)
84 | if err == nil {
85 | s.Account = a
86 | }
87 | }
88 | s.Visibility = n.Visibility.ToStatusVisibility()
89 | for _, file := range n.Files {
90 | if file.IsSensitive {
91 | s.Sensitive = true
92 | }
93 | // NOTE: Misskey does not return width and height of media files.
94 | if file.Properties.Width <= 0 && file.Properties.Height <= 0 {
95 | file.Properties.Width = 1920
96 | file.Properties.Height = 1080
97 | }
98 | s.MediaAttachments = append(s.MediaAttachments, file.ToMediaAttachment())
99 | }
100 | if n.Cw != nil {
101 | s.SpoilerText = *n.Cw
102 | }
103 | if n.ReNote != nil {
104 | re := n.ReNote.ToStatus(ctx)
105 | s.ReBlog = &re
106 | }
107 | if uid != nil {
108 | s.ReBlogged = n.ReNote != nil && n.UserId == *uid
109 | }
110 | return s
111 | }
112 |
113 | func (v MkNoteVisibility) ToStatusVisibility() StatusVisibility {
114 | switch v {
115 | case MkNoteVisibilityPublic:
116 | return StatusVisibilityPublic
117 | case MkNoteVisibilityHome:
118 | return StatusVisibilityUnlisted
119 | case MkNoteVisibilityFollow:
120 | return StatusVisibilityPrivate
121 | default:
122 | return StatusVisibilityDirect
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/MkNotification.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "github.com/gizmo-ds/misstodon/internal/misstodon"
5 | )
6 |
7 | type MkNotificationType string
8 |
9 | const (
10 | MkNotificationTypeNote MkNotificationType = "note"
11 | MkNotificationTypeFollow MkNotificationType = "follow"
12 | MkNotificationTypeAchievementEarned MkNotificationType = "achievementEarned"
13 | MkNotificationTypeReceiveFollowRequest MkNotificationType = "receiveFollowRequest"
14 | MkNotificationTypeFollowRequestAccepted MkNotificationType = "followRequestAccepted"
15 | MkNotificationTypeReceiveReaction MkNotificationType = "reaction"
16 | MkNotificationTypeReceiveRenote MkNotificationType = "renote"
17 | MkNotificationTypeReply MkNotificationType = "reply"
18 | MkNotificationTypeMention MkNotificationType = "mention"
19 |
20 | MkNotificationTypeUnknown MkNotificationType = "unknown"
21 | )
22 |
23 | type MkNotification struct {
24 | Id string `json:"id"`
25 | Type MkNotificationType `json:"type"`
26 | UserId *string `json:"userId,omitempty"`
27 | CreatedAt string `json:"createdAt"`
28 | User *MkUser `json:"user,omitempty"`
29 | Note *MkNote `json:"note,omitempty"`
30 | Reaction *string `json:"reaction,omitempty"`
31 | Choice *int `json:"choice,omitempty"`
32 | Invitation any `json:"invitation"`
33 | Body any `json:"body"`
34 | Header *string `json:"header,omitempty"`
35 | Icon *string `json:"icon,omitempty"`
36 | Achievement string `json:"achievement"`
37 | }
38 |
39 | func (n MkNotification) ToNotification(ctx misstodon.Context) (Notification, error) {
40 | r := Notification{
41 | Id: n.Id,
42 | Type: n.Type.ToNotificationType(),
43 | CreatedAt: n.CreatedAt,
44 | }
45 | var err error
46 | if n.User != nil {
47 | r.Account, err = n.User.ToAccount(ctx)
48 | if err != nil {
49 | return r, err
50 | }
51 | }
52 | if n.Note != nil {
53 | status := n.Note.ToStatus(ctx)
54 | r.Status = &status
55 | }
56 | return r, err
57 | }
58 |
59 | func (t MkNotificationType) ToNotificationType() NotificationType {
60 | switch t {
61 | case MkNotificationTypeNote:
62 | return NotificationTypeStatus
63 | case MkNotificationTypeFollow:
64 | return NotificationTypeFollow
65 | case MkNotificationTypeReceiveFollowRequest:
66 | return NotificationTypeFollowRequest
67 | case MkNotificationTypeReceiveReaction:
68 | return NotificationTypeFavourite
69 | case MkNotificationTypeReceiveRenote:
70 | return NotificationTypeReblog
71 | case MkNotificationTypeReply, MkNotificationTypeMention:
72 | return NotificationTypeMention
73 | default:
74 | return NotificationTypeUnknown
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/MkRelation.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type MkRelation struct {
4 | ID string `json:"id"`
5 | IsFollowing bool `json:"isFollowing"`
6 | IsFollowed bool `json:"isFollowed"`
7 | HasPendingFollowRequestFromYou bool `json:"hasPendingFollowRequestFromYou"`
8 | HasPendingFollowRequestToYou bool `json:"hasPendingFollowRequestToYou"`
9 | IsBlocking bool `json:"isBlocking"`
10 | IsBlocked bool `json:"isBlocked"`
11 | IsMuted bool `json:"isMuted"`
12 | }
13 |
14 | func (r MkRelation) ToRelationship() Relationship {
15 | return Relationship{
16 | ID: r.ID,
17 | Following: r.IsFollowing,
18 | FollowedBy: r.IsFollowed,
19 | Requested: r.HasPendingFollowRequestFromYou,
20 | Languages: []string{},
21 | Blocking: r.IsBlocking,
22 | BlockedBy: r.IsBlocked,
23 | Muting: r.IsMuted,
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/MkStats.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type MkStats struct {
4 | NotesCount int `json:"notesCount"`
5 | UsersCount int `json:"usersCount"`
6 | OriginalUsersCount int `json:"originalUsersCount"`
7 | OriginalNotesCount int `json:"originalNotesCount"`
8 | ReactionsCount int `json:"reactionsCount"`
9 | Instances int `json:"instances"`
10 | DriveUsageLocal int `json:"driveUsageLocal"`
11 | DriveUsageRemote int `json:"driveUsageRemote"`
12 | }
13 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/MkStreamMessage.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type MkStreamMessage struct {
4 | Type string `json:"type"`
5 | }
6 |
7 | func (m MkStreamMessage) ToStreamEvent() StreamEvent {
8 | return StreamEvent{
9 | Event: m.Type,
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/MkUser.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/gizmo-ds/misstodon/internal/misstodon"
7 | "github.com/gizmo-ds/misstodon/internal/utils"
8 | "github.com/gizmo-ds/misstodon/pkg/mfm"
9 | "github.com/pkg/errors"
10 | )
11 |
12 | type MkUser struct {
13 | ID string `json:"id"`
14 | Username string `json:"username"`
15 | Name string `json:"name"`
16 | Host *string `json:"host,omitempty"`
17 | Location *string `json:"location"`
18 | Description *string `json:"description"`
19 | IsBot bool `json:"isBot"`
20 | IsLocked bool `json:"isLocked"`
21 | CreatedAt string `json:"createdAt,omitempty"`
22 | UpdatedAt *string `json:"updatedAt"`
23 | FollowersCount int `json:"followersCount"`
24 | FollowingCount int `json:"followingCount"`
25 | NotesCount int `json:"notesCount"`
26 | AvatarUrl string `json:"avatarUrl"`
27 | BannerUrl string `json:"bannerUrl"`
28 | Fields []AccountField `json:"fields"`
29 | Instance MkInstance `json:"instance"`
30 | Mentions []string `json:"mentions"`
31 | IsMuted bool `json:"isMuted"`
32 | IsBlocked bool `json:"isBlocked"`
33 | IsBlocking bool `json:"isBlocking"`
34 | IsFollowing bool `json:"isFollowing"`
35 | IsFollowed bool `json:"isFollowed"`
36 | FfVisibility MkNoteVisibility `json:"ffVisibility,omitempty"`
37 | }
38 |
39 | type MkInstance struct {
40 | Name string `json:"name"`
41 | SoftwareName string `json:"softwareName"`
42 | SoftwareVersion string `json:"softwareVersion"`
43 | ThemeColor string `json:"themeColor"`
44 | IconUrl string `json:"iconUrl"`
45 | FaviconUrl string `json:"faviconUrl"`
46 | }
47 |
48 | func (u *MkUser) ToAccount(ctx misstodon.Context) (Account, error) {
49 | var info Account
50 | var err error
51 | host := ctx.ProxyServer()
52 | if u.Host != nil {
53 | host = *u.Host
54 | }
55 | info = Account{
56 | ID: u.ID,
57 | Username: u.Username,
58 | Acct: u.Username + "@" + host,
59 | DisplayName: u.Name,
60 | Locked: u.IsLocked,
61 | Bot: u.IsBot,
62 | Url: "https://" + host + "/@" + u.Username,
63 | Avatar: u.AvatarUrl,
64 | AvatarStatic: u.AvatarUrl,
65 | Header: u.BannerUrl,
66 | HeaderStatic: u.BannerUrl,
67 | FollowersCount: u.FollowersCount,
68 | FollowingCount: u.FollowingCount,
69 | StatusesCount: u.NotesCount,
70 | Emojis: []CustomEmoji{},
71 | Fields: append([]AccountField{}, u.Fields...),
72 | CreatedAt: u.CreatedAt,
73 | Limited: &u.IsMuted,
74 | }
75 | if info.DisplayName == "" {
76 | info.DisplayName = info.Username
77 | }
78 | _lastStatusAt := u.UpdatedAt
79 | if _lastStatusAt != nil {
80 | lastStatusAt, err := time.Parse(time.RFC3339, *_lastStatusAt)
81 | if err != nil {
82 | return info, errors.WithStack(err)
83 | }
84 | t := lastStatusAt.Format("2006-01-02")
85 | info.LastStatusAt = &t
86 | }
87 | if u.Description != nil {
88 | info.Note, err = mfm.ToHtml(*u.Description, mfm.Option{
89 | Url: utils.JoinURL(ctx.ProxyServer()),
90 | HashtagHandler: mfm.MastodonHashtagHandler,
91 | })
92 | if err != nil {
93 | return info, errors.WithStack(err)
94 | }
95 | }
96 | return info, nil
97 | }
98 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/NodeInfo.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type (
4 | NodeInfo struct {
5 | Version string `json:"version"`
6 | Software NodeInfoSoftware `json:"software"`
7 | Protocols []string `json:"protocols"`
8 | Services NodeInfoServices `json:"services"`
9 | Usage NodeInfoUsage `json:"usage"`
10 | OpenRegistrations bool `json:"openRegistrations"`
11 | Metadata any `json:"metadata"`
12 | }
13 | NodeInfoSoftware struct {
14 | Name string `json:"name"`
15 | Version string `json:"version"`
16 | }
17 | NodeInfoServices struct {
18 | Inbound []string `json:"inbound"`
19 | Outbound []string `json:"outbound"`
20 | }
21 | NodeInfoUsage struct {
22 | Users struct {
23 | Total int `json:"total"`
24 | ActiveMonth int `json:"activeMonth"`
25 | ActiveHalfyear int `json:"activeHalfyear"`
26 | } `json:"users"`
27 | LocalPosts int `json:"localPosts"`
28 | }
29 | )
30 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/Notification.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type NotificationType string
4 |
5 | const (
6 | NotificationTypeMention NotificationType = "mention"
7 | NotificationTypeStatus NotificationType = "status"
8 | NotificationTypeReblog NotificationType = "reblog"
9 | NotificationTypeFollow NotificationType = "follow"
10 | NotificationTypeFollowRequest NotificationType = "follow_request "
11 | NotificationTypeFavourite NotificationType = "favourite"
12 | NotificationTypePoll NotificationType = "poll"
13 | NotificationTypeUpdate NotificationType = "update"
14 | NotificationTypeAdminSignUp NotificationType = "admin.sign_up"
15 | NotificationTypeAdminReport NotificationType = "admin.report"
16 |
17 | NotificationTypeUnknown NotificationType = "unknown"
18 | )
19 |
20 | type Notification struct {
21 | Id string `json:"id"`
22 | Type NotificationType `json:"type"`
23 | CreatedAt string `json:"created_at"`
24 | Account Account `json:"account"`
25 | Status *Status `json:"status,omitempty"`
26 | // FIXME: not implemented
27 | Report any `json:"report,omitempty"`
28 | }
29 |
30 | func (t NotificationType) ToMkNotificationType() MkNotificationType {
31 | switch t {
32 | case NotificationTypeStatus:
33 | return MkNotificationTypeNote
34 | case NotificationTypeFollow:
35 | return MkNotificationTypeFollow
36 | case NotificationTypeFollowRequest:
37 | return MkNotificationTypeReceiveReaction
38 | case NotificationTypeFavourite:
39 | return MkNotificationTypeReceiveReaction
40 | case NotificationTypeReblog:
41 | return MkNotificationTypeReceiveRenote
42 | case NotificationTypeMention:
43 | return MkNotificationTypeMention
44 | default:
45 | return MkNotificationTypeUnknown
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/Relationship.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type Relationship struct {
4 | ID string `json:"id"`
5 | Following bool `json:"following"`
6 | FollowedBy bool `json:"followed_by"`
7 | // ShowingReblogs bool `json:"showing_reblogs"`
8 | Requested bool `json:"requested"`
9 | Languages []string `json:"languages"`
10 | Blocking bool `json:"blocking"`
11 | BlockedBy bool `json:"blocked_by"`
12 | Muting bool `json:"muting"`
13 | // MutingNotifications bool `json:"muting_notifications"`
14 | // DomainBlocking bool `json:"domain_blocking"`
15 | // Endorsed bool `json:"endorsed"`
16 | // Notifying bool `json:"notifying"`
17 | Note string `json:"note"`
18 | }
19 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/Status.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import "time"
4 |
5 | type StatusVisibility string
6 |
7 | const (
8 | // StatusVisibilityPublic Visible to everyone, shown in public timelines.
9 | StatusVisibilityPublic StatusVisibility = "public"
10 | // StatusVisibilityUnlisted Visible to public, but not included in public timelines.
11 | StatusVisibilityUnlisted StatusVisibility = "unlisted"
12 | // StatusVisibilityPrivate Visible to followers only, and to any mentioned users.
13 | StatusVisibilityPrivate StatusVisibility = "private"
14 | // StatusVisibilityDirect Visible only to mentioned users.
15 | StatusVisibilityDirect StatusVisibility = "direct"
16 | )
17 |
18 | type (
19 | Status struct {
20 | ID string `json:"id"`
21 | Uri string `json:"uri"`
22 | Url string `json:"url"`
23 | Visibility StatusVisibility `json:"visibility"`
24 | Tags []StatusTag `json:"tags"`
25 | CreatedAt string `json:"created_at"`
26 | EditedAt *string `json:"edited_at"`
27 | Content string `json:"content"`
28 | MediaAttachments []MediaAttachment `json:"media_attachments"`
29 | Card *struct{} `json:"card"`
30 | Emojis []struct{} `json:"emojis"`
31 | Account Account `json:"account"`
32 | Sensitive bool `json:"sensitive"`
33 | SpoilerText string `json:"spoiler_text"`
34 | Bookmarked bool `json:"bookmarked"`
35 | Favourited bool `json:"favourited"`
36 | FavouritesCount int `json:"favourites_count"`
37 | InReplyToAccountId *string `json:"in_reply_to_account_id"`
38 | InReplyToID *string `json:"in_reply_to_id"`
39 | Language *string `json:"language"`
40 | Mentions []StatusMention `json:"mentions"`
41 | Muted bool `json:"muted"`
42 | Poll *struct{} `json:"poll"`
43 | ReBlog *Status `json:"reblog"`
44 | ReBlogged bool `json:"reblogged"`
45 | ReBlogsCount int `json:"reblogs_count"`
46 | RepliesCount int `json:"replies_count"`
47 | }
48 | StatusTag struct {
49 | Name string `json:"name"`
50 | Url string `json:"url"`
51 | }
52 | StatusMention struct {
53 | Id string `json:"id"`
54 | Username string `json:"username"`
55 | Url string `json:"url"`
56 | Acct string `json:"acct"`
57 | }
58 | ScheduledStatus struct {
59 | ID string `json:"id"`
60 | ScheduledAt time.Time `json:"scheduled_at"`
61 | Params struct {
62 | Text string `json:"text"`
63 | Poll *string `json:"poll"`
64 | MediaIDs any `json:"media_ids"` // []string | string | null
65 | Sensitive *bool `json:"sensitive"`
66 | SpoilerText *string `json:"spoiler_text"`
67 | Visibility StatusVisibility `json:"visibility"`
68 | InReplyToID *int `json:"in_reply_to_id"` // ID of the Status that will be replied to.
69 | Language *string `json:"language"`
70 | ApplicationID int `json:"application_id"`
71 | ScheduledAt any `json:"scheduled_at"`
72 | Idempotency *string `json:"idempotency"`
73 | WithRateLimit bool `json:"with_rate_limit"`
74 | } `json:"params"`
75 | MediaAttachments []MediaAttachment `json:"media_attachments"`
76 | }
77 | )
78 |
79 | func (v StatusVisibility) ToMkNoteVisibility() MkNoteVisibility {
80 | switch v {
81 | case StatusVisibilityPublic:
82 | return MkNoteVisibilityPublic
83 | case StatusVisibilityUnlisted:
84 | return MkNoteVisibilityHome
85 | case StatusVisibilityPrivate:
86 | return MkNoteVisibilityFollow
87 | default:
88 | return MkNoteVisibilitySpecif
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/StreamEvent.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type StreamEvent struct {
4 | Event string `json:"event"`
5 | }
6 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/Tag.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type Tag struct {
4 | Name string `json:"name"`
5 | Url string `json:"url"`
6 | History []struct {
7 | Day string `json:"day"`
8 | Uses string `json:"uses"`
9 | Accounts string `json:"accounts"`
10 | } `json:"history"`
11 | Following bool `json:"following,omitempty"`
12 | }
13 |
--------------------------------------------------------------------------------
/pkg/misstodon/models/Timelines.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type TimelinePublicType = string
4 |
5 | const (
6 | TimelinePublicTypeLocal TimelinePublicType = "local"
7 | TimelinePublicTypeRemote TimelinePublicType = "remote"
8 | )
9 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/.env.example:
--------------------------------------------------------------------------------
1 | TEST_SERVER=misskey.io
2 | TEST_TOKEN=
3 | TEST_ACCT=AureoleArk@misskey.io
4 | TEST_USER_ID=7rkrg1wo1a
5 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/.gitignore:
--------------------------------------------------------------------------------
1 | .env
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/accounts_test.go:
--------------------------------------------------------------------------------
1 | package misskey_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/gizmo-ds/misstodon/internal/misstodon"
7 | "github.com/gizmo-ds/misstodon/internal/utils"
8 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestLookup(t *testing.T) {
13 | if testServer == "" || testAcct == "" {
14 | t.Skip("TEST_SERVER and TEST_ACCT are required")
15 | }
16 | ctx := misstodon.ContextWithValues(testServer, "")
17 | info, err := misskey.AccountsLookup(ctx, testAcct)
18 | assert.NoError(t, err)
19 | assert.Equal(t, testAcct, info.Acct)
20 | }
21 |
22 | func TestAccountMute(t *testing.T) {
23 | if _, ok := utils.StrEvaluation(testServer, testUserID, testToken); !ok {
24 | t.Skip("TEST_SERVER and TEST_USER_ID and TEST_TOKEN are required")
25 | }
26 | ctx := misstodon.ContextWithValues(testServer, testToken)
27 | err := misskey.AccountMute(ctx, testUserID, 10*60)
28 | assert.NoError(t, err)
29 |
30 | account, err := misskey.AccountGet(ctx, testUserID)
31 | assert.NoError(t, err)
32 | assert.Equal(t, true, *account.Limited)
33 | }
34 |
35 | func TestAccountUnmute(t *testing.T) {
36 | if _, ok := utils.StrEvaluation(testServer, testUserID, testToken); !ok {
37 | t.Skip("TEST_SERVER and TEST_USER_ID and TEST_TOKEN are required")
38 | }
39 |
40 | ctx := misstodon.ContextWithValues(testServer, testToken)
41 | err := misskey.AccountUnmute(ctx, testUserID)
42 | assert.NoError(t, err)
43 |
44 | account, err := misskey.AccountGet(ctx, testUserID)
45 | assert.NoError(t, err)
46 | assert.Equal(t, false, *account.Limited)
47 | }
48 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/application.go:
--------------------------------------------------------------------------------
1 | package misskey
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 |
7 | "github.com/gizmo-ds/misstodon/internal/misstodon"
8 | "github.com/gizmo-ds/misstodon/internal/utils"
9 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
10 | "github.com/pkg/errors"
11 | )
12 |
13 | func ApplicationCreate(ctx misstodon.Context, clientName, redirectUris, scopes, website string) (models.Application, error) {
14 | var permissions []string
15 | var app models.Application
16 | arr := strings.Split(scopes, " ")
17 | for _, scope := range arr {
18 | switch scope {
19 | case "read":
20 | permissions = append(permissions, models.ApplicationPermissionRead...)
21 | case "write":
22 | permissions = append(permissions, models.ApplicationPermissionWrite...)
23 | case "follow":
24 | permissions = append(permissions, models.ApplicationPermissionFollow...)
25 | case "push":
26 | // FIXME: 未实现WebPushAPI
27 | default:
28 | permissions = append(permissions, scope)
29 | }
30 | }
31 | permissions = utils.Unique(permissions)
32 | var result models.MkApplication
33 | resp, err := client.R().
34 | SetBaseURL(ctx.ProxyServer()).
35 | SetBody(map[string]any{
36 | "name": clientName,
37 | "description": website,
38 | "callbackUrl": redirectUris,
39 | "permission": permissions,
40 | }).
41 | SetResult(&result).
42 | Post("/api/app/create")
43 | if err != nil {
44 | return app, err
45 | }
46 | if resp.StatusCode() != http.StatusOK {
47 | return app, errors.New("failed to create application")
48 | }
49 | app = models.Application{
50 | ID: result.ID,
51 | Name: result.Name,
52 | RedirectUri: result.CallbackUrl,
53 | ClientID: &result.ID,
54 | ClientSecret: &result.Secret,
55 | // FIXME: 未实现WebPushAPI
56 | VapidKey: "",
57 | }
58 | return app, nil
59 | }
60 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/drive.go:
--------------------------------------------------------------------------------
1 | package misskey
2 |
3 | import (
4 | "io"
5 | "net/http"
6 |
7 | "github.com/gizmo-ds/misstodon/internal/misstodon"
8 | "github.com/gizmo-ds/misstodon/internal/utils"
9 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
10 | "github.com/pkg/errors"
11 | )
12 |
13 | func driveFileCreate(ctx misstodon.Context, filename string, content io.Reader) (models.MkFile, error) {
14 | var file models.MkFile
15 | // find folder
16 | folders, err := driveFolders(ctx)
17 | if err != nil {
18 | return file, err
19 | }
20 | var saveFolder *models.MkFolder
21 | for _, folder := range folders {
22 | if folder.Name == "misstodon" {
23 | saveFolder = &folder
24 | break
25 | }
26 | }
27 |
28 | // create folder if not exists
29 | if saveFolder == nil {
30 | folder, err := driveFolderCreate(ctx, "misstodon")
31 | if err != nil {
32 | return file, err
33 | }
34 | saveFolder = &folder
35 | }
36 |
37 | resp, err := client.R().
38 | SetBaseURL(ctx.ProxyServer()).
39 | SetFormData(map[string]string{
40 | "folderId": saveFolder.Id,
41 | "name": filename,
42 | "i": *ctx.Token(),
43 | "force": "true",
44 | "isSensitive": "false",
45 | }).
46 | SetMultipartField("file", filename, "application/octet-stream", content).
47 | SetResult(&file).
48 | Post("/api/drive/files/create")
49 | if err != nil {
50 | return file, err
51 | }
52 | if resp.StatusCode() != http.StatusOK {
53 | return file, errors.New("failed to verify credentials")
54 | }
55 | return file, nil
56 | }
57 |
58 | func driveFolders(ctx misstodon.Context) (folders []models.MkFolder, err error) {
59 | resp, err := client.R().
60 | SetBaseURL(ctx.ProxyServer()).
61 | SetBody(utils.Map{"i": ctx.Token(), "limit": 100}).
62 | SetResult(&folders).
63 | Post("/api/drive/folders")
64 | if err != nil {
65 | return
66 | }
67 | if resp.StatusCode() != http.StatusOK {
68 | return folders, errors.New("failed to verify credentials")
69 | }
70 | return
71 | }
72 |
73 | func driveFolderCreate(ctx misstodon.Context, name string) (models.MkFolder, error) {
74 | var folder models.MkFolder
75 | resp, err := client.R().
76 | SetBaseURL(ctx.ProxyServer()).
77 | SetBody(utils.Map{"name": name, "i": ctx.Token()}).
78 | SetResult(&folder).
79 | Post("/api/drive/folders/create")
80 | if err != nil {
81 | return folder, err
82 | }
83 | if resp.StatusCode() != http.StatusOK {
84 | return folder, errors.New("failed to verify credentials")
85 | }
86 | return folder, nil
87 | }
88 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/errors.go:
--------------------------------------------------------------------------------
1 | package misskey
2 |
3 | import "errors"
4 |
5 | var (
6 | ErrNotFound = errors.New("not found")
7 | ErrAcctIsInvalid = errors.New("acct format is invalid")
8 | ErrRateLimit = errors.New("rate limit")
9 | )
10 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/httpclient.go:
--------------------------------------------------------------------------------
1 | package misskey
2 |
3 | import (
4 | "github.com/gizmo-ds/misstodon/pkg/httpclient"
5 | )
6 |
7 | var client = httpclient.NewRestyClient()
8 |
9 | func SetHeader(header, value string) {
10 | client.SetHeader(header, value)
11 | }
12 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/instance.go:
--------------------------------------------------------------------------------
1 | package misskey
2 |
3 | import (
4 | "net/http"
5 | "net/url"
6 |
7 | "github.com/duke-git/lancet/v2/slice"
8 | "github.com/gizmo-ds/misstodon/internal/misstodon"
9 | "github.com/gizmo-ds/misstodon/internal/utils"
10 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
11 | "github.com/pkg/errors"
12 | )
13 |
14 | // SupportedMimeTypes is a list of supported mime types
15 | //
16 | // https://github.com/misskey-dev/misskey/blob/79212bbd375705f0fd658dd5b50b47f77d622fb8/packages/backend/src/const.ts#L25
17 | var SupportedMimeTypes = []string{
18 | "image/png",
19 | "image/gif",
20 | "image/jpeg",
21 | "image/webp",
22 | "image/avif",
23 | "image/apng",
24 | "image/bmp",
25 | "image/tiff",
26 | "image/x-icon",
27 | "audio/opus",
28 | "video/ogg",
29 | "audio/ogg",
30 | "application/ogg",
31 | "video/quicktime",
32 | "video/mp4",
33 | "audio/mp4",
34 | "video/x-m4v",
35 | "audio/x-m4a",
36 | "video/3gpp",
37 | "video/3gpp2",
38 | "video/mpeg",
39 | "audio/mpeg",
40 | "video/webm",
41 | "audio/webm",
42 | "audio/aac",
43 | "audio/x-flac",
44 | "audio/vnd.wave",
45 | }
46 |
47 | func Instance(ctx misstodon.Context, version string) (models.Instance, error) {
48 | var info models.Instance
49 | var serverInfo models.MkMeta
50 | resp, err := client.R().
51 | SetBaseURL(ctx.ProxyServer()).
52 | SetBody(map[string]any{
53 | "detail": false,
54 | }).
55 | SetResult(&serverInfo).
56 | Post("/api/meta")
57 | if err != nil {
58 | return info, err
59 | }
60 | serverUrl, err := url.Parse(serverInfo.URI)
61 | if err != nil {
62 | return info, err
63 | }
64 | if resp.StatusCode() != http.StatusOK {
65 | return info, errors.New("Failed to get instance info")
66 | }
67 | info = models.Instance{
68 | Uri: serverUrl.Host,
69 | Title: serverInfo.Name,
70 | Description: serverInfo.Description,
71 | ShortDescription: serverInfo.Description,
72 | Email: serverInfo.MaintainerEmail,
73 | Version: version,
74 | Thumbnail: serverInfo.BannerUrl,
75 | Registrations: !serverInfo.DisableRegistration,
76 | InvitesEnabled: serverInfo.Policies.CanInvite,
77 | Rules: []models.InstanceRule{},
78 | Languages: serverInfo.Langs,
79 | }
80 | // TODO: 需要先实现 `/streaming`
81 | // info.Urls.StreamingApi = serverInfo.StreamingAPI
82 | if info.Languages == nil {
83 | info.Languages = []string{}
84 | }
85 | info.Configuration.Statuses.MaxCharacters = serverInfo.MaxNoteTextLength
86 | // NOTE: misskey没有相关限制, 此处返回固定值
87 | info.Configuration.Statuses.MaxMediaAttachments = 4
88 | // NOTE: misskey没有相关设置, 此处返回固定值
89 | info.Configuration.Statuses.CharactersReservedPerUrl = 23
90 | info.Configuration.MediaAttachments.SupportedMimeTypes = SupportedMimeTypes
91 |
92 | var serverStats models.MkStats
93 | resp, err = client.R().
94 | SetBaseURL(ctx.ProxyServer()).
95 | SetBody(map[string]any{}).
96 | SetResult(&serverStats).
97 | Post("/api/stats")
98 | if err != nil {
99 | return info, err
100 | }
101 | if resp.StatusCode() != http.StatusOK {
102 | return info, errors.New("Failed to get instance info")
103 | }
104 | info.Stats.UserCount = serverStats.OriginalUsersCount
105 | info.Stats.StatusCount = serverStats.OriginalNotesCount
106 | info.Stats.DomainCount = serverStats.Instances
107 | return info, err
108 | }
109 |
110 | func InstancePeers(ctx misstodon.Context) ([]string, error) { return nil, nil }
111 |
112 | func InstanceCustomEmojis(ctx misstodon.Context) ([]models.CustomEmoji, error) {
113 | var emojis struct {
114 | Emojis []models.MkEmoji `json:"emojis"`
115 | }
116 | resp, err := client.R().
117 | SetBaseURL(ctx.ProxyServer()).
118 | SetResult(&emojis).
119 | SetBody(utils.Map{}).
120 | Post("/api/emojis")
121 | if err != nil {
122 | return nil, err
123 | }
124 | if err != nil {
125 | return nil, errors.WithStack(err)
126 | }
127 | if err = isucceed(resp, http.StatusOK); err != nil {
128 | return nil, errors.WithStack(err)
129 | }
130 | return slice.Map(emojis.Emojis, func(_ int, e models.MkEmoji) models.CustomEmoji {
131 | return e.ToCustomEmoji()
132 | }), nil
133 | }
134 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/instance_test.go:
--------------------------------------------------------------------------------
1 | package misskey_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/gizmo-ds/misstodon/internal/misstodon"
7 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | func TestInstance(t *testing.T) {
12 | if testServer == "" {
13 | t.Skip("TEST_SERVER is required")
14 | }
15 | ctx := misstodon.ContextWithValues(testServer, "")
16 | info, err := misskey.Instance(ctx, "development")
17 | assert.NoError(t, err)
18 | assert.Equal(t, testServer, info.Uri)
19 | }
20 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/media.go:
--------------------------------------------------------------------------------
1 | package misskey
2 |
3 | import (
4 | "mime/multipart"
5 |
6 | "github.com/gizmo-ds/misstodon/internal/misstodon"
7 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
8 | "github.com/pkg/errors"
9 | )
10 |
11 | func MediaUpload(ctx misstodon.Context, file *multipart.FileHeader, description string) (models.MediaAttachment, error) {
12 | var ma models.MediaAttachment
13 | if file == nil {
14 | return ma, errors.New("file is nil")
15 | }
16 | f, err := file.Open()
17 | if err != nil {
18 | return ma, err
19 | }
20 | defer f.Close()
21 |
22 | fileInfo, err := driveFileCreate(ctx, file.Filename, f)
23 | if err != nil {
24 | return ma, err
25 | }
26 | ma = fileInfo.ToMediaAttachment()
27 | return ma, nil
28 | }
29 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/misskey_test.go:
--------------------------------------------------------------------------------
1 | package misskey_test
2 |
3 | import (
4 | "os"
5 | "testing"
6 |
7 | "github.com/joho/godotenv"
8 | "github.com/rs/zerolog"
9 | "github.com/rs/zerolog/log"
10 | )
11 |
12 | var (
13 | testServer string
14 | testToken string
15 | testAcct string
16 | testUserID string
17 | )
18 |
19 | func TestMain(m *testing.M) {
20 | log.Logger = zerolog.New(zerolog.ConsoleWriter{
21 | Out: os.Stderr,
22 | TimeFormat: "2006-01-02 15:04:05",
23 | })
24 | if err := godotenv.Load(); err != nil {
25 | log.Error().Err(err).Msg("failed to load .env file")
26 | return
27 | }
28 | testServer = os.Getenv("TEST_SERVER")
29 | testToken = os.Getenv("TEST_TOKEN")
30 | testAcct = os.Getenv("TEST_ACCT")
31 | testUserID = os.Getenv("TEST_USER_ID")
32 | m.Run()
33 | }
34 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/notifications.go:
--------------------------------------------------------------------------------
1 | package misskey
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/duke-git/lancet/v2/slice"
7 | "github.com/gizmo-ds/misstodon/internal/misstodon"
8 | "github.com/gizmo-ds/misstodon/internal/utils"
9 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
10 | "github.com/pkg/errors"
11 | )
12 |
13 | func NotificationsGet(ctx misstodon.Context,
14 | limit int, sinceId, minId, maxId string,
15 | types, excludeTypes []models.NotificationType, accountId string,
16 | ) ([]models.Notification, error) {
17 | limit = utils.NumRangeLimit(limit, 1, 100)
18 |
19 | body := makeBody(ctx, utils.Map{"limit": limit})
20 | if v, ok := utils.StrEvaluation(sinceId, minId); ok {
21 | body["sinceId"] = v
22 | }
23 | if maxId != "" {
24 | body["untilId"] = maxId
25 | }
26 | _excludeTypes := slice.Map(excludeTypes,
27 | func(_ int, item models.NotificationType) models.MkNotificationType {
28 | return item.ToMkNotificationType()
29 | })
30 | _excludeTypes = append(_excludeTypes, models.MkNotificationTypeAchievementEarned)
31 | if slice.Contain(_excludeTypes, models.MkNotificationTypeMention) {
32 | _excludeTypes = append(_excludeTypes, models.MkNotificationTypeReply)
33 | }
34 | body["excludeTypes"] = _excludeTypes
35 | _includeTypes := slice.Map(types,
36 | func(_ int, item models.NotificationType) models.MkNotificationType {
37 | return item.ToMkNotificationType()
38 | })
39 | if slice.Contain(_includeTypes, models.MkNotificationTypeMention) {
40 | _includeTypes = append(_includeTypes, models.MkNotificationTypeReply)
41 | }
42 | if len(_includeTypes) > 0 {
43 | body["includeTypes"] = _includeTypes
44 | }
45 |
46 | var result []models.MkNotification
47 | resp, err := client.R().
48 | SetBaseURL(ctx.ProxyServer()).
49 | SetBody(body).
50 | SetResult(&result).
51 | Post("/api/i/notifications")
52 | if err != nil {
53 | return nil, errors.WithStack(err)
54 | }
55 | if err = isucceed(resp, http.StatusOK); err != nil {
56 | return nil, errors.WithStack(err)
57 | }
58 | notifications := slice.Map(result, func(_ int, item models.MkNotification) models.Notification {
59 | n, err := item.ToNotification(ctx)
60 | if err == nil {
61 | return n
62 | }
63 | return models.Notification{Type: models.NotificationTypeUnknown}
64 | })
65 | notifications = slice.Filter(notifications, func(_ int, item models.Notification) bool {
66 | return item.Type != models.NotificationTypeUnknown
67 | })
68 | return notifications, nil
69 | }
70 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/oauth.go:
--------------------------------------------------------------------------------
1 | package misskey
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gizmo-ds/misstodon/internal/misstodon"
7 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
8 | "github.com/pkg/errors"
9 | )
10 |
11 | func OAuthAuthorize(ctx misstodon.Context, secret string) (string, error) {
12 | var result struct {
13 | Token string `json:"token"`
14 | Url string `json:"url"`
15 | }
16 | resp, err := client.R().
17 | SetBaseURL(ctx.ProxyServer()).
18 | SetBody(map[string]any{
19 | "appSecret": secret,
20 | }).
21 | SetResult(&result).
22 | Post("/api/auth/session/generate")
23 | if err != nil {
24 | return "", errors.WithStack(err)
25 | }
26 | if resp.StatusCode() != http.StatusOK {
27 | return "", errors.New("failed to authorize")
28 | }
29 | return result.Url, nil
30 | }
31 |
32 | func OAuthToken(ctx misstodon.Context, token, secret string) (string, string, error) {
33 | var result struct {
34 | AccessToken string `json:"accessToken"`
35 | User models.MkUser
36 | }
37 | resp, err := client.R().
38 | SetBaseURL(ctx.ProxyServer()).
39 | SetBody(map[string]any{
40 | "appSecret": secret,
41 | "token": token,
42 | }).
43 | SetResult(&result).
44 | Post("/api/auth/session/userkey")
45 | if err != nil {
46 | return "", "", errors.WithStack(err)
47 | }
48 | if resp.StatusCode() != http.StatusOK {
49 | return "", "", errors.New("failed to get access_token")
50 | }
51 | return result.AccessToken, result.User.ID, nil
52 | }
53 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/statuses.go:
--------------------------------------------------------------------------------
1 | package misskey
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/duke-git/lancet/v2/slice"
8 | "github.com/gizmo-ds/misstodon/internal/misstodon"
9 | "github.com/gizmo-ds/misstodon/internal/utils"
10 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
11 | "github.com/pkg/errors"
12 | )
13 |
14 | func StatusSingle(ctx misstodon.Context, statusID string) (models.Status, error) {
15 | var status models.Status
16 | var note models.MkNote
17 | body := makeBody(ctx, utils.Map{"noteId": statusID})
18 | resp, err := client.R().
19 | SetBaseURL(ctx.ProxyServer()).
20 | SetBody(body).
21 | SetResult(¬e).
22 | Post("/api/notes/show")
23 | if err != nil {
24 | return status, errors.WithStack(err)
25 | }
26 | if err = isucceed(resp, http.StatusOK); err != nil {
27 | return status, errors.WithStack(err)
28 | }
29 | status = note.ToStatus(ctx)
30 | if ctx.Token() != nil {
31 | state, err := getNoteState(ctx.ProxyServer(), *ctx.Token(), status.ID)
32 | if err != nil {
33 | return status, err
34 | }
35 | status.Bookmarked = state.IsFavorited
36 | status.Muted = state.IsMutedThread
37 | }
38 | return status, err
39 | }
40 |
41 | type noteState struct {
42 | IsFavorited bool `json:"isFavorited"`
43 | IsMutedThread bool `json:"isMutedThread"`
44 | }
45 |
46 | func getNoteState(server, token, noteId string) (noteState, error) {
47 | var state noteState
48 | resp, err := client.R().
49 | SetBaseURL(server).
50 | SetBody(utils.Map{"i": token, "noteId": noteId}).
51 | SetResult(&state).
52 | Post("/api/notes/state")
53 | if err != nil {
54 | return state, errors.WithStack(err)
55 | }
56 | if err = isucceed(resp, http.StatusOK); err != nil {
57 | return state, errors.WithStack(err)
58 | }
59 | return state, nil
60 | }
61 |
62 | func StatusFavourite(ctx misstodon.Context, id string) (models.Status, error) {
63 | status, err := StatusSingle(ctx, id)
64 | if err != nil {
65 | return status, errors.WithStack(err)
66 | }
67 | resp, err := client.R().
68 | SetBaseURL(ctx.ProxyServer()).
69 | SetBody(makeBody(ctx, utils.Map{
70 | "noteId": id,
71 | "reaction": "⭐",
72 | })).
73 | Post("/api/notes/reactions/create")
74 | if err != nil {
75 | return status, errors.WithStack(err)
76 | }
77 | if err = isucceed(resp, http.StatusNoContent, "ALREADY_REACTED"); err != nil {
78 | return status, errors.WithStack(err)
79 | }
80 | status.Favourited = true
81 | status.FavouritesCount += 1
82 | return status, nil
83 | }
84 |
85 | func StatusUnFavourite(ctx misstodon.Context, id string) (models.Status, error) {
86 | status, err := StatusSingle(ctx, id)
87 | if err != nil {
88 | return status, errors.WithStack(err)
89 | }
90 | resp, err := client.R().
91 | SetBaseURL(ctx.ProxyServer()).
92 | SetBody(makeBody(ctx, utils.Map{"noteId": id})).
93 | Post("/api/notes/reactions/delete")
94 | if err != nil {
95 | return status, errors.WithStack(err)
96 | }
97 | if err = isucceed(resp, http.StatusNoContent, "NOT_REACTED"); err != nil {
98 | return status, errors.WithStack(err)
99 | }
100 | status.Favourited = false
101 | status.FavouritesCount -= 1
102 | return status, nil
103 | }
104 |
105 | func StatusBookmark(ctx misstodon.Context, id string) (models.Status, error) {
106 | status, err := StatusSingle(ctx, id)
107 | if err != nil {
108 | return status, errors.WithStack(err)
109 | }
110 | resp, err := client.R().
111 | SetBaseURL(ctx.ProxyServer()).
112 | SetBody(makeBody(ctx, utils.Map{"noteId": id})).
113 | Post("/api/notes/favorites/create")
114 | if err != nil {
115 | return status, errors.WithStack(err)
116 | }
117 | if err = isucceed(resp, http.StatusNoContent, "ALREADY_FAVORITED"); err != nil {
118 | return status, errors.WithStack(err)
119 | }
120 | status.Bookmarked = true
121 | return status, nil
122 | }
123 |
124 | func StatusUnBookmark(ctx misstodon.Context, id string) (models.Status, error) {
125 | status, err := StatusSingle(ctx, id)
126 | if err != nil {
127 | return status, errors.WithStack(err)
128 | }
129 | resp, err := client.R().
130 | SetBaseURL(ctx.ProxyServer()).
131 | SetBody(makeBody(ctx, utils.Map{"noteId": id})).
132 | Post("/api/notes/favorites/delete")
133 | if err != nil {
134 | return status, errors.WithStack(err)
135 | }
136 | if err = isucceed(resp, http.StatusNoContent, "NOT_FAVORITED"); err != nil {
137 | return status, errors.WithStack(err)
138 | }
139 | status.Bookmarked = false
140 | return status, nil
141 | }
142 |
143 | // StatusBookmarks
144 | // NOTE: 为了减少请求数量, 不支持 Bookmarked
145 | func StatusBookmarks(ctx misstodon.Context,
146 | limit int, sinceId, minId, maxId string) ([]models.Status, error) {
147 | type favorite struct {
148 | ID string `json:"id"`
149 | CreatedAt string `json:"createdAt"`
150 | Note models.MkNote `json:"note"`
151 | }
152 | var result []favorite
153 | body := makeBody(ctx, utils.Map{"limit": limit})
154 | if v, ok := utils.StrEvaluation(sinceId, minId); ok {
155 | body["sinceId"] = v
156 | }
157 | if maxId != "" {
158 | body["untilId"] = maxId
159 | }
160 | resp, err := client.R().
161 | SetBaseURL(ctx.ProxyServer()).
162 | SetBody(body).
163 | SetResult(&result).
164 | Post("/api/i/favorites")
165 | if err != nil {
166 | return nil, errors.WithStack(err)
167 | }
168 | if err = isucceed(resp, http.StatusOK); err != nil {
169 | return nil, errors.WithStack(err)
170 | }
171 | status := slice.Map(result, func(_ int, item favorite) models.Status {
172 | s := item.Note.ToStatus(ctx)
173 | s.Bookmarked = true
174 | return s
175 | })
176 | return status, nil
177 | }
178 |
179 | // PostNewStatus 发送新的 Status
180 | // FIXME: Poll 未实现
181 | func PostNewStatus(ctx misstodon.Context,
182 | status *string, poll any, mediaIds []string, inReplyToId string,
183 | sensitive bool, spoilerText string,
184 | visibility models.StatusVisibility, language string,
185 | scheduledAt time.Time,
186 | ) (models.Status, error) {
187 | body := makeBody(ctx, utils.Map{"localOnly": false})
188 | var noteMentions []string
189 | if status != nil && *status != "" {
190 | body["text"] = *status
191 | noteMentions = append(noteMentions, utils.GetMentions(*status)...)
192 | }
193 | if sensitive {
194 | if spoilerText != "" {
195 | body["cw"] = spoilerText
196 | } else {
197 | body["cw"] = "Sensitive"
198 | }
199 | }
200 | body["visibility"] = visibility.ToMkNoteVisibility()
201 | if visibility == models.StatusVisibilityDirect {
202 | var visibleUserIds []string
203 | for _, m := range noteMentions {
204 | a, err := AccountsLookup(ctx, m)
205 | if err != nil {
206 | return models.Status{}, err
207 | }
208 | visibleUserIds = append(visibleUserIds, a.ID)
209 | }
210 | if len(visibleUserIds) > 0 {
211 | body["visibleUserIds"] = visibleUserIds
212 | }
213 | }
214 | if len(mediaIds) > 0 {
215 | body["mediaIds"] = mediaIds
216 | }
217 | if inReplyToId != "" {
218 | body["replyId"] = inReplyToId
219 | }
220 | var result struct {
221 | CreatedNote models.MkNote `json:"createdNote"`
222 | }
223 | resp, err := client.R().
224 | SetBaseURL(ctx.ProxyServer()).
225 | SetBody(body).
226 | SetResult(&result).
227 | Post("/api/notes/create")
228 | if err != nil {
229 | return models.Status{}, errors.WithStack(err)
230 | }
231 | if err = isucceed(resp, http.StatusOK); err != nil {
232 | return models.Status{}, errors.WithStack(err)
233 | }
234 | return result.CreatedNote.ToStatus(ctx), nil
235 | }
236 |
237 | func SearchStatusByHashtag(ctx misstodon.Context,
238 | hashtag string,
239 | limit int, maxId, sinceId, minId string) ([]models.Status, error) {
240 | body := makeBody(ctx, utils.Map{"limit": limit})
241 | if v, ok := utils.StrEvaluation(sinceId, minId); ok {
242 | body["sinceId"] = v
243 | }
244 | if maxId != "" {
245 | body["untilId"] = maxId
246 | }
247 | body["tag"] = hashtag
248 | var result []models.MkNote
249 | _, err := client.R().
250 | SetBaseURL(ctx.ProxyServer()).
251 | SetBody(body).
252 | SetResult(&result).
253 | Post("/api/notes/search-by-tag")
254 | if err != nil {
255 | return nil, err
256 | }
257 | var list []models.Status
258 | for _, note := range result {
259 | list = append(list, note.ToStatus(ctx))
260 | }
261 | return list, nil
262 | }
263 |
264 | func StatusContext(ctx misstodon.Context, id string) (models.Context, error) {
265 | result := models.Context{
266 | Ancestors: make([]models.Status, 0),
267 | Descendants: make([]models.Status, 0),
268 | }
269 | s, err := StatusSingle(ctx, id)
270 | if err != nil {
271 | return result, err
272 | }
273 | result, err = statusContext(ctx, s)
274 | return result, err
275 | }
276 |
277 | func statusContext(ctx misstodon.Context, status models.Status) (models.Context, error) {
278 | result := models.Context{
279 | Ancestors: make([]models.Status, 0),
280 | Descendants: make([]models.Status, 0),
281 | }
282 | if status.RepliesCount > 0 {
283 | notes, err := noteChildren(ctx, status.ID, 30, "", "")
284 | if err == nil {
285 | result.Descendants = slice.Map(notes, func(_ int, item models.MkNote) models.Status {
286 | return item.ToStatus(ctx)
287 | })
288 | }
289 | lc := status.RepliesCount / 100
290 | if status.RepliesCount%100 > 0 {
291 | lc++
292 | }
293 | for i := 0; i < lc; i++ {
294 | notes, err = noteConversation(ctx, status.ID, 100, i*100)
295 | if err == nil {
296 | arr := slice.Map(notes, func(_ int, item models.MkNote) models.Status {
297 | return item.ToStatus(ctx)
298 | })
299 | result.Ancestors = append(result.Ancestors, arr...)
300 | }
301 | }
302 | }
303 | return result, nil
304 | }
305 |
306 | func noteConversation(ctx misstodon.Context, id string, limit, offset int) ([]models.MkNote, error) {
307 | body := makeBody(ctx, utils.Map{
308 | "limit": limit,
309 | "offset": offset,
310 | "noteId": id,
311 | })
312 | var result []models.MkNote
313 | resp, err := client.R().
314 | SetBaseURL(ctx.ProxyServer()).
315 | SetBody(body).
316 | SetResult(&result).
317 | Post("/api/notes/conversation")
318 | if err != nil {
319 | return nil, err
320 | }
321 | if err = isucceed(resp, http.StatusOK); err != nil {
322 | return nil, errors.WithStack(err)
323 | }
324 | return result, nil
325 | }
326 |
327 | func noteChildren(ctx misstodon.Context, id string, limit int, sinceId, untilId string) ([]models.MkNote, error) {
328 | body := makeBody(ctx, utils.Map{
329 | "limit": limit,
330 | "noteId": id,
331 | })
332 | if sinceId != "" {
333 | body["sinceId"] = sinceId
334 | }
335 | if untilId != "" {
336 | body["untilId"] = untilId
337 | }
338 | var result []models.MkNote
339 | resp, err := client.R().
340 | SetBaseURL(ctx.ProxyServer()).
341 | SetBody(body).
342 | SetResult(&result).
343 | Post("/api/notes/children")
344 | if err != nil {
345 | return nil, err
346 | }
347 | if err = isucceed(resp, http.StatusOK); err != nil {
348 | return nil, errors.WithStack(err)
349 | }
350 | return result, nil
351 | }
352 |
353 | func StatusReblog(ctx misstodon.Context, reNoteId string, visibility models.StatusVisibility) (models.Status, error) {
354 | body := makeBody(ctx, utils.Map{"renoteId": reNoteId})
355 | body["visibility"] = visibility.ToMkNoteVisibility()
356 | var result struct {
357 | CreatedNote models.MkNote `json:"createdNote"`
358 | }
359 | resp, err := client.R().
360 | SetBaseURL(ctx.ProxyServer()).
361 | SetBody(body).
362 | SetResult(&result).
363 | Post("/api/notes/create")
364 | if err != nil {
365 | return models.Status{}, errors.WithStack(err)
366 | }
367 | if err = isucceed(resp, http.StatusOK); err != nil {
368 | return models.Status{}, errors.WithStack(err)
369 | }
370 | return result.CreatedNote.ToStatus(ctx), nil
371 | }
372 |
373 | func StatusDelete(ctx misstodon.Context, id string) (models.Status, error) {
374 | status, err := StatusSingle(ctx, id)
375 | if err != nil {
376 | return status, err
377 | }
378 | body := makeBody(ctx, utils.Map{"noteId": id})
379 | resp, err := client.R().
380 | SetBaseURL(ctx.ProxyServer()).
381 | SetBody(body).
382 | Post("/api/notes/delete")
383 | if err != nil {
384 | return status, errors.WithStack(err)
385 | }
386 | if err = isucceed(resp, http.StatusNoContent); err != nil {
387 | return status, errors.WithStack(err)
388 | }
389 | return status, nil
390 | }
391 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/streaming/streaming.go:
--------------------------------------------------------------------------------
1 | package streaming
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "time"
7 |
8 | "github.com/gizmo-ds/misstodon/internal/misstodon"
9 | "github.com/gizmo-ds/misstodon/internal/utils"
10 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
11 | "github.com/gorilla/websocket"
12 | "github.com/rs/xid"
13 | )
14 |
15 | func Streaming(ctx context.Context, mCtx misstodon.Context, token string, ch chan<- models.StreamEvent) error {
16 | u := fmt.Sprintf("wss://%s/streaming?i=%s&_t=%d", mCtx.ProxyServer(), token, time.Now().Unix())
17 | conn, _, err := websocket.DefaultDialer.Dial(u, nil)
18 | if err != nil {
19 | return err
20 | }
21 | defer conn.Close()
22 |
23 | _ = conn.WriteJSON(utils.Map{
24 | "type": "connect",
25 | "body": utils.Map{
26 | "channel": "main",
27 | "id": xid.New().String(),
28 | },
29 | })
30 |
31 | done := false
32 | go func() {
33 | select {
34 | case <-ctx.Done():
35 | done = true
36 | _ = conn.Close()
37 | }
38 | }()
39 |
40 | for {
41 | var v models.MkStreamMessage
42 | if err = conn.ReadJSON(&v); err != nil {
43 | if _, ok := err.(*websocket.CloseError); ok {
44 | return nil
45 | }
46 | if done {
47 | return nil
48 | }
49 | return err
50 | }
51 | select {
52 | case <-ctx.Done():
53 | return nil
54 | default:
55 | }
56 | if done {
57 | return nil
58 | }
59 | ch <- v.ToStreamEvent()
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/timelines.go:
--------------------------------------------------------------------------------
1 | package misskey
2 |
3 | import (
4 | "github.com/duke-git/lancet/v2/slice"
5 | "github.com/gizmo-ds/misstodon/internal/misstodon"
6 | "github.com/gizmo-ds/misstodon/internal/utils"
7 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
8 | "github.com/pkg/errors"
9 | )
10 |
11 | func TimelinePublic(ctx misstodon.Context,
12 | timelineType models.TimelinePublicType, onlyMedia bool,
13 | limit int, maxId, minId string) ([]models.Status, error) {
14 | body := makeBody(ctx, utils.Map{
15 | "withFiles": onlyMedia,
16 | "limit": limit,
17 | })
18 | if minId != "" {
19 | body["sinceId"] = minId
20 | }
21 | if maxId != "" {
22 | body["untilId"] = maxId
23 | }
24 | var u string
25 | switch timelineType {
26 | case models.TimelinePublicTypeLocal:
27 | u = "/api/notes/local-timeline"
28 | case models.TimelinePublicTypeRemote:
29 | u = "/api/notes/global-timeline"
30 | default:
31 | err := errors.New("invalid timeline type")
32 | return nil, err
33 | }
34 | var result []models.MkNote
35 | _, err := client.R().
36 | SetBaseURL(ctx.ProxyServer()).
37 | SetBody(body).
38 | SetResult(&result).
39 | Post(u)
40 | if err != nil {
41 | return nil, err
42 | }
43 | list := slice.Map(result, func(_ int, n models.MkNote) models.Status { return n.ToStatus(ctx) })
44 | return list, nil
45 | }
46 |
47 | func TimelineHome(ctx misstodon.Context,
48 | limit int, maxId, minId string) ([]models.Status, error) {
49 | body := makeBody(ctx, utils.Map{"limit": limit})
50 | if minId != "" {
51 | body["sinceId"] = minId
52 | }
53 | if maxId != "" {
54 | body["untilId"] = maxId
55 | }
56 | var result []models.MkNote
57 | _, err := client.R().
58 | SetBaseURL(ctx.ProxyServer()).
59 | SetBody(body).
60 | SetResult(&result).
61 | Post("/api/notes/timeline")
62 | if err != nil {
63 | return nil, err
64 | }
65 | list := slice.Map(result, func(_ int, n models.MkNote) models.Status { return n.ToStatus(ctx) })
66 | return list, nil
67 | }
68 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/timelines_test.go:
--------------------------------------------------------------------------------
1 | package misskey_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/gizmo-ds/misstodon/internal/misstodon"
7 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
8 | "github.com/gizmo-ds/misstodon/pkg/misstodon/provider/misskey"
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestTimelinePublic(t *testing.T) {
13 | if testServer == "" {
14 | t.Skip("TEST_SERVER is required")
15 | }
16 | ctx := misstodon.ContextWithValues(testServer, testToken)
17 | list, err := misskey.TimelinePublic(
18 | ctx,
19 | models.TimelinePublicTypeLocal, false,
20 | 30, "", "")
21 | assert.NoError(t, err)
22 | t.Log(len(list))
23 | }
24 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/trends.go:
--------------------------------------------------------------------------------
1 | package misskey
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 | "time"
7 |
8 | "github.com/gizmo-ds/misstodon/internal/misstodon"
9 | "github.com/gizmo-ds/misstodon/internal/utils"
10 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
11 | )
12 |
13 | func TrendsTags(ctx misstodon.Context, limit, offset int) ([]models.Tag, error) {
14 | var result []struct {
15 | Tag string `json:"tag"`
16 | UsersCount int `json:"usersCount"`
17 | }
18 | _, err := client.R().
19 | SetBaseURL(ctx.ProxyServer()).
20 | SetBody(makeBody(ctx, nil)).
21 | SetResult(&result).
22 | Post("/api/hashtags/trend")
23 | if err != nil {
24 | return nil, err
25 | }
26 | var tags []models.Tag
27 | for _, r := range result {
28 | tag := models.Tag{
29 | Name: r.Tag,
30 | Url: utils.JoinURL(*ctx.HOST(), "/tags/", r.Tag),
31 | History: []struct {
32 | Day string `json:"day"`
33 | Uses string `json:"uses"`
34 | Accounts string `json:"accounts"`
35 | }{
36 | {
37 | Day: fmt.Sprint(time.Now().Unix()),
38 | Uses: strconv.Itoa(r.UsersCount),
39 | Accounts: strconv.Itoa(r.UsersCount),
40 | },
41 | },
42 | }
43 | tags = append(tags, tag)
44 | }
45 | return tags, nil
46 | }
47 |
48 | func TrendsStatus(ctx misstodon.Context, limit, offset int) ([]models.Status, error) {
49 | var statuses []models.Status
50 | var result []models.MkNote
51 | _, err := client.R().
52 | SetBaseURL(ctx.ProxyServer()).
53 | SetBody(makeBody(ctx, utils.Map{"limit": limit})).
54 | SetResult(&result).
55 | Post("/api/notes/featured")
56 | if err != nil {
57 | return nil, err
58 | }
59 | for _, note := range result {
60 | statuses = append(statuses, note.ToStatus(ctx))
61 | }
62 | return statuses, nil
63 | }
64 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/utils.go:
--------------------------------------------------------------------------------
1 | package misskey
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "net/http"
7 | "strings"
8 |
9 | "github.com/gizmo-ds/misstodon/internal/misstodon"
10 | "github.com/gizmo-ds/misstodon/internal/utils"
11 | "github.com/gizmo-ds/misstodon/pkg/httpclient"
12 | )
13 |
14 | var (
15 | ErrUnauthorized = errors.New("invalid token")
16 | )
17 |
18 | type ServerError struct {
19 | Code int
20 | Message string
21 | }
22 |
23 | func (e ServerError) Error() string {
24 | return e.Message
25 | }
26 |
27 | func isucceed(resp httpclient.Response, statusCode int, codes ...string) error {
28 | switch resp.StatusCode() {
29 | case http.StatusOK, http.StatusNoContent, statusCode:
30 | return nil
31 | case http.StatusUnauthorized, http.StatusForbidden:
32 | return ErrUnauthorized
33 | case http.StatusNotFound:
34 | return ErrNotFound
35 | case http.StatusTooManyRequests:
36 | return ErrRateLimit
37 | }
38 |
39 | var result struct {
40 | Error struct {
41 | Code string `json:"code"`
42 | Msg string `json:"message"`
43 | } `json:"error"`
44 | }
45 | if strings.Contains(resp.Header().Get("Content-Type"), "application/json") {
46 | body := resp.Body()
47 | if body != nil {
48 | err := json.Unmarshal(body, &result)
49 | if err != nil {
50 | return ServerError{Code: 500, Message: err.Error()}
51 | }
52 | }
53 | }
54 | if utils.Contains(codes, result.Error.Code) {
55 | return nil
56 | }
57 | return errors.New(result.Error.Msg)
58 | }
59 |
60 | func makeBody(ctx misstodon.Context, m utils.Map) utils.Map {
61 | r := utils.Map{}
62 | token := ctx.Token()
63 | if token != nil && *token != "" {
64 | r["i"] = token
65 | }
66 | for k, v := range m {
67 | r[k] = v
68 | }
69 | return r
70 | }
71 |
--------------------------------------------------------------------------------
/pkg/misstodon/provider/misskey/wellknown.go:
--------------------------------------------------------------------------------
1 | package misskey
2 |
3 | import (
4 | "io"
5 | "net/http"
6 |
7 | "github.com/gizmo-ds/misstodon/pkg/misstodon/models"
8 | )
9 |
10 | func NodeInfo(server string, ni models.NodeInfo) (models.NodeInfo, error) {
11 | var result models.NodeInfo
12 | _, err := client.R().
13 | SetBaseURL(server).
14 | SetResult(&result).
15 | Get("/nodeinfo/2.0")
16 | if err != nil {
17 | return ni, err
18 | }
19 | ni.Usage = result.Usage
20 | ni.OpenRegistrations = result.OpenRegistrations
21 | ni.Metadata = result.Metadata
22 | return ni, err
23 | }
24 |
25 | func WebFinger(server, resource string, writer http.ResponseWriter) error {
26 | resp, err := client.R().
27 | SetBaseURL(server).
28 | SetDoNotParseResponse(true).
29 | SetQueryParam("resource", resource).
30 | Get("/.well-known/webfinger")
31 | if err != nil {
32 | return err
33 | }
34 | defer resp.RawBody().Close()
35 | writer.Header().Set("Content-Type", resp.Header().Get("Content-Type"))
36 | writer.WriteHeader(resp.StatusCode())
37 | _, err = io.Copy(writer, resp.RawBody())
38 | return err
39 | }
40 |
41 | func HostMeta(server string, writer http.ResponseWriter) error {
42 | resp, err := client.R().
43 | SetBaseURL(server).
44 | SetDoNotParseResponse(true).
45 | Get("/.well-known/host-meta")
46 | if err != nil {
47 | return err
48 | }
49 | defer resp.RawBody().Close()
50 | writer.Header().Set("Content-Type", resp.Header().Get("Content-Type"))
51 | writer.WriteHeader(resp.StatusCode())
52 | _, err = io.Copy(writer, resp.RawBody())
53 | return err
54 | }
55 |
--------------------------------------------------------------------------------
/pnpm-lock.yaml:
--------------------------------------------------------------------------------
1 | lockfileVersion: '9.0'
2 |
3 | settings:
4 | autoInstallPeers: true
5 | excludeLinksFromLockfile: false
6 |
7 | importers:
8 |
9 | .:
10 | dependencies:
11 | mfm-js:
12 | specifier: ^0.23.3
13 | version: 0.23.3
14 | devDependencies:
15 | esbuild:
16 | specifier: ^0.25.0
17 | version: 0.25.0
18 |
19 | packages:
20 |
21 | '@esbuild/aix-ppc64@0.25.0':
22 | resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==}
23 | engines: {node: '>=18'}
24 | cpu: [ppc64]
25 | os: [aix]
26 |
27 | '@esbuild/android-arm64@0.25.0':
28 | resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==}
29 | engines: {node: '>=18'}
30 | cpu: [arm64]
31 | os: [android]
32 |
33 | '@esbuild/android-arm@0.25.0':
34 | resolution: {integrity: sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==}
35 | engines: {node: '>=18'}
36 | cpu: [arm]
37 | os: [android]
38 |
39 | '@esbuild/android-x64@0.25.0':
40 | resolution: {integrity: sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==}
41 | engines: {node: '>=18'}
42 | cpu: [x64]
43 | os: [android]
44 |
45 | '@esbuild/darwin-arm64@0.25.0':
46 | resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==}
47 | engines: {node: '>=18'}
48 | cpu: [arm64]
49 | os: [darwin]
50 |
51 | '@esbuild/darwin-x64@0.25.0':
52 | resolution: {integrity: sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==}
53 | engines: {node: '>=18'}
54 | cpu: [x64]
55 | os: [darwin]
56 |
57 | '@esbuild/freebsd-arm64@0.25.0':
58 | resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==}
59 | engines: {node: '>=18'}
60 | cpu: [arm64]
61 | os: [freebsd]
62 |
63 | '@esbuild/freebsd-x64@0.25.0':
64 | resolution: {integrity: sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==}
65 | engines: {node: '>=18'}
66 | cpu: [x64]
67 | os: [freebsd]
68 |
69 | '@esbuild/linux-arm64@0.25.0':
70 | resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==}
71 | engines: {node: '>=18'}
72 | cpu: [arm64]
73 | os: [linux]
74 |
75 | '@esbuild/linux-arm@0.25.0':
76 | resolution: {integrity: sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==}
77 | engines: {node: '>=18'}
78 | cpu: [arm]
79 | os: [linux]
80 |
81 | '@esbuild/linux-ia32@0.25.0':
82 | resolution: {integrity: sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==}
83 | engines: {node: '>=18'}
84 | cpu: [ia32]
85 | os: [linux]
86 |
87 | '@esbuild/linux-loong64@0.25.0':
88 | resolution: {integrity: sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==}
89 | engines: {node: '>=18'}
90 | cpu: [loong64]
91 | os: [linux]
92 |
93 | '@esbuild/linux-mips64el@0.25.0':
94 | resolution: {integrity: sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==}
95 | engines: {node: '>=18'}
96 | cpu: [mips64el]
97 | os: [linux]
98 |
99 | '@esbuild/linux-ppc64@0.25.0':
100 | resolution: {integrity: sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==}
101 | engines: {node: '>=18'}
102 | cpu: [ppc64]
103 | os: [linux]
104 |
105 | '@esbuild/linux-riscv64@0.25.0':
106 | resolution: {integrity: sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==}
107 | engines: {node: '>=18'}
108 | cpu: [riscv64]
109 | os: [linux]
110 |
111 | '@esbuild/linux-s390x@0.25.0':
112 | resolution: {integrity: sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==}
113 | engines: {node: '>=18'}
114 | cpu: [s390x]
115 | os: [linux]
116 |
117 | '@esbuild/linux-x64@0.25.0':
118 | resolution: {integrity: sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==}
119 | engines: {node: '>=18'}
120 | cpu: [x64]
121 | os: [linux]
122 |
123 | '@esbuild/netbsd-arm64@0.25.0':
124 | resolution: {integrity: sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==}
125 | engines: {node: '>=18'}
126 | cpu: [arm64]
127 | os: [netbsd]
128 |
129 | '@esbuild/netbsd-x64@0.25.0':
130 | resolution: {integrity: sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==}
131 | engines: {node: '>=18'}
132 | cpu: [x64]
133 | os: [netbsd]
134 |
135 | '@esbuild/openbsd-arm64@0.25.0':
136 | resolution: {integrity: sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==}
137 | engines: {node: '>=18'}
138 | cpu: [arm64]
139 | os: [openbsd]
140 |
141 | '@esbuild/openbsd-x64@0.25.0':
142 | resolution: {integrity: sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==}
143 | engines: {node: '>=18'}
144 | cpu: [x64]
145 | os: [openbsd]
146 |
147 | '@esbuild/sunos-x64@0.25.0':
148 | resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==}
149 | engines: {node: '>=18'}
150 | cpu: [x64]
151 | os: [sunos]
152 |
153 | '@esbuild/win32-arm64@0.25.0':
154 | resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==}
155 | engines: {node: '>=18'}
156 | cpu: [arm64]
157 | os: [win32]
158 |
159 | '@esbuild/win32-ia32@0.25.0':
160 | resolution: {integrity: sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==}
161 | engines: {node: '>=18'}
162 | cpu: [ia32]
163 | os: [win32]
164 |
165 | '@esbuild/win32-x64@0.25.0':
166 | resolution: {integrity: sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==}
167 | engines: {node: '>=18'}
168 | cpu: [x64]
169 | os: [win32]
170 |
171 | esbuild@0.25.0:
172 | resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==}
173 | engines: {node: '>=18'}
174 | hasBin: true
175 |
176 | mfm-js@0.23.3:
177 | resolution: {integrity: sha512-o8scYmbey6rMUmWAlT3k3ntt6khaCLdxlmHhAWV5wTTMj2OK1atQvZfRUq0SIVm1Jig08qlZg/ps71xUqrScNA==}
178 |
179 | twemoji-parser@14.0.0:
180 | resolution: {integrity: sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==}
181 |
182 | snapshots:
183 |
184 | '@esbuild/aix-ppc64@0.25.0':
185 | optional: true
186 |
187 | '@esbuild/android-arm64@0.25.0':
188 | optional: true
189 |
190 | '@esbuild/android-arm@0.25.0':
191 | optional: true
192 |
193 | '@esbuild/android-x64@0.25.0':
194 | optional: true
195 |
196 | '@esbuild/darwin-arm64@0.25.0':
197 | optional: true
198 |
199 | '@esbuild/darwin-x64@0.25.0':
200 | optional: true
201 |
202 | '@esbuild/freebsd-arm64@0.25.0':
203 | optional: true
204 |
205 | '@esbuild/freebsd-x64@0.25.0':
206 | optional: true
207 |
208 | '@esbuild/linux-arm64@0.25.0':
209 | optional: true
210 |
211 | '@esbuild/linux-arm@0.25.0':
212 | optional: true
213 |
214 | '@esbuild/linux-ia32@0.25.0':
215 | optional: true
216 |
217 | '@esbuild/linux-loong64@0.25.0':
218 | optional: true
219 |
220 | '@esbuild/linux-mips64el@0.25.0':
221 | optional: true
222 |
223 | '@esbuild/linux-ppc64@0.25.0':
224 | optional: true
225 |
226 | '@esbuild/linux-riscv64@0.25.0':
227 | optional: true
228 |
229 | '@esbuild/linux-s390x@0.25.0':
230 | optional: true
231 |
232 | '@esbuild/linux-x64@0.25.0':
233 | optional: true
234 |
235 | '@esbuild/netbsd-arm64@0.25.0':
236 | optional: true
237 |
238 | '@esbuild/netbsd-x64@0.25.0':
239 | optional: true
240 |
241 | '@esbuild/openbsd-arm64@0.25.0':
242 | optional: true
243 |
244 | '@esbuild/openbsd-x64@0.25.0':
245 | optional: true
246 |
247 | '@esbuild/sunos-x64@0.25.0':
248 | optional: true
249 |
250 | '@esbuild/win32-arm64@0.25.0':
251 | optional: true
252 |
253 | '@esbuild/win32-ia32@0.25.0':
254 | optional: true
255 |
256 | '@esbuild/win32-x64@0.25.0':
257 | optional: true
258 |
259 | esbuild@0.25.0:
260 | optionalDependencies:
261 | '@esbuild/aix-ppc64': 0.25.0
262 | '@esbuild/android-arm': 0.25.0
263 | '@esbuild/android-arm64': 0.25.0
264 | '@esbuild/android-x64': 0.25.0
265 | '@esbuild/darwin-arm64': 0.25.0
266 | '@esbuild/darwin-x64': 0.25.0
267 | '@esbuild/freebsd-arm64': 0.25.0
268 | '@esbuild/freebsd-x64': 0.25.0
269 | '@esbuild/linux-arm': 0.25.0
270 | '@esbuild/linux-arm64': 0.25.0
271 | '@esbuild/linux-ia32': 0.25.0
272 | '@esbuild/linux-loong64': 0.25.0
273 | '@esbuild/linux-mips64el': 0.25.0
274 | '@esbuild/linux-ppc64': 0.25.0
275 | '@esbuild/linux-riscv64': 0.25.0
276 | '@esbuild/linux-s390x': 0.25.0
277 | '@esbuild/linux-x64': 0.25.0
278 | '@esbuild/netbsd-arm64': 0.25.0
279 | '@esbuild/netbsd-x64': 0.25.0
280 | '@esbuild/openbsd-arm64': 0.25.0
281 | '@esbuild/openbsd-x64': 0.25.0
282 | '@esbuild/sunos-x64': 0.25.0
283 | '@esbuild/win32-arm64': 0.25.0
284 | '@esbuild/win32-ia32': 0.25.0
285 | '@esbuild/win32-x64': 0.25.0
286 |
287 | mfm-js@0.23.3:
288 | dependencies:
289 | twemoji-parser: 14.0.0
290 |
291 | twemoji-parser@14.0.0: {}
292 |
--------------------------------------------------------------------------------