├── .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://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fafdian.com%2Fapi%2Fuser%2Fget-profile%3Fuser_id%3D75e549844b5111ed8df552540025c377&query=%24.data.user.name&label=%E7%88%B1%E5%8F%91%E7%94%B5&color=%23946ce6)](https://afdian.com/a/gizmo) 2 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/gizmo-ds/misstodon?style=flat-square) 3 | [![Build images](https://img.shields.io/github/actions/workflow/status/gizmo-ds/misstodon/images.yaml?branch=main&label=docker%20image&style=flat-square)](https://github.com/gizmo-ds/misstodon/actions/workflows/images.yaml) 4 | [![License](https://img.shields.io/github/license/gizmo-ds/misstodon?style=flat-square)](./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://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fafdian.com%2Fapi%2Fuser%2Fget-profile%3Fuser_id%3D75e549844b5111ed8df552540025c377&query=%24.data.user.name&label=%E7%88%B1%E5%8F%91%E7%94%B5&color=%23946ce6)](https://afdian.com/a/gizmo) 4 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/gizmo-ds/misstodon?style=flat-square) 5 | [![Build images](https://img.shields.io/github/actions/workflow/status/gizmo-ds/misstodon/images.yaml?branch=main&label=docker%20image&style=flat-square)](https://github.com/gizmo-ds/misstodon/actions/workflows/images.yaml) 6 | [![License](https://img.shields.io/github/license/gizmo-ds/misstodon?style=flat-square)](./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 | [![Sponsors](https://afdian-connect.deno.dev/sponsor.svg)](https://afdian.com/a/gizmo) 158 | 159 | ## Contributors 160 | 161 | ![Contributors](https://contributors.aika.dev/gizmo-ds/misstodon/contributors.svg?align=left) 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 | --------------------------------------------------------------------------------