diff --git a/.dockerignore b/.dockerignore index ac2851ee..b04dde4a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,6 +11,7 @@ !.git !proto !scripts/run-clustered.sh +!dashboard # Re-exclude host build artifact directories **/bin diff --git a/.github/workflows/dashboard b/.github/workflows/dashboard new file mode 100644 index 00000000..9defc499 --- /dev/null +++ b/.github/workflows/dashboard @@ -0,0 +1,34 @@ +name: Build dashboard Docker image + +on: + push: + branches: [main] + paths: + - 'dashboard/' + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + packages: write + if: github.repository == 'xSke/PluralKit' + steps: + - uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.CR_PAT }} + - uses: actions/checkout@v2 + - run: echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + - uses: docker/build-push-action@v2 + with: + # https://github.com/docker/build-push-action/issues/378 + context: . + file: Dockerfile.dashboard + push: true + tags: | + ghcr.io/pluralkit/dashboard:${{ env.BRANCH_NAME }} + ghcr.io/pluralkit/dashboard:${{ github.sha }} + ghcr.io/pluralkit/dashboard:latest + cache-from: type=registry,ref=ghcr.io/pluralkit/dashboard:${{ env.BRANCH_NAME }} + cache-to: type=inline diff --git a/Dockerfile.dashboard b/Dockerfile.dashboard new file mode 100644 index 00000000..51bc1c09 --- /dev/null +++ b/Dockerfile.dashboard @@ -0,0 +1,17 @@ +FROM alpine:latest as builder + +RUN apk add nodejs-current yarn go git + +COPY dashboard/ /build +WORKDIR /build + +RUN yarn install --frozen-lockfile +RUN yarn build + +RUN go build -ldflags "-X dashboard/main.version=$(git rev-parse HEAD)" + +FROM alpine:latest + +COPY --from=builder /build/dashboard /bin/dashboard + +ENTRYPOINT /bin/dashboard \ No newline at end of file diff --git a/PluralKit.API/APIJsonExt.cs b/PluralKit.API/APIJsonExt.cs index 91386bc3..d906ff2a 100644 --- a/PluralKit.API/APIJsonExt.cs +++ b/PluralKit.API/APIJsonExt.cs @@ -25,6 +25,18 @@ public static class APIJsonExt return o; } + + public static JObject EmbedJson(string title, string type) + { + var o = new JObject(); + + o.Add("type", "rich"); + o.Add("provider_name", "PluralKit " + type); + o.Add("provider_url", "https://pluralkit.me"); + o.Add("title", title); + + return o; + } } public struct FrontersReturnNew diff --git a/PluralKit.API/Controllers/v2/GroupControllerV2.cs b/PluralKit.API/Controllers/v2/GroupControllerV2.cs index 01eee1e2..495f4407 100644 --- a/PluralKit.API/Controllers/v2/GroupControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupControllerV2.cs @@ -97,6 +97,21 @@ public class GroupControllerV2: PKControllerBase return Ok(group.ToJson(ContextFor(group), system.Hid)); } + [HttpGet("groups/{groupRef}/oembed.json")] + public async Task GroupEmbed(string groupRef) + { + var group = await ResolveGroup(groupRef); + if (group == null) + throw Errors.GroupNotFound; + var system = await _repo.GetSystem(group.System); + + var name = group.NameFor(LookupContext.ByNonOwner); + if (system.Name != null) + name += $" ({system.Name})"; + + return Ok(APIJsonExt.EmbedJson(name, "Group")); + } + [HttpPatch("groups/{groupRef}")] public async Task DoGroupPatch(string groupRef, [FromBody] JObject data) { diff --git a/PluralKit.API/Controllers/v2/MemberControllerV2.cs b/PluralKit.API/Controllers/v2/MemberControllerV2.cs index dfa1eb87..b4ad7fb9 100644 --- a/PluralKit.API/Controllers/v2/MemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/MemberControllerV2.cs @@ -79,6 +79,21 @@ public class MemberControllerV2: PKControllerBase return Ok(member.ToJson(ContextFor(member), systemStr: system.Hid)); } + [HttpGet("members/{memberRef}/oembed.json")] + public async Task MemberEmbed(string memberRef) + { + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFound; + var system = await _repo.GetSystem(member.System); + + var name = member.NameFor(LookupContext.ByNonOwner); + if (system.Name != null) + name += $" ({system.Name})"; + + return Ok(APIJsonExt.EmbedJson(name, "Member")); + } + [HttpPatch("members/{memberRef}")] public async Task DoMemberPatch(string memberRef, [FromBody] JObject data) { diff --git a/PluralKit.API/Controllers/v2/SystemControllerV2.cs b/PluralKit.API/Controllers/v2/SystemControllerV2.cs index 4842e0e9..8990a98e 100644 --- a/PluralKit.API/Controllers/v2/SystemControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SystemControllerV2.cs @@ -20,6 +20,16 @@ public class SystemControllerV2: PKControllerBase return Ok(system.ToJson(ContextFor(system))); } + [HttpGet("{systemRef}/oembed.json")] + public async Task SystemEmbed(string systemRef) + { + var system = await ResolveSystem(systemRef); + if (system == null) + throw Errors.SystemNotFound; + + return Ok(APIJsonExt.EmbedJson(system.Name ?? $"System with ID `{system.Hid}`", "System")); + } + [HttpPatch("{systemRef}")] public async Task DoSystemPatch(string systemRef, [FromBody] JObject data) { diff --git a/dashboard/.gitignore b/dashboard/.gitignore index 763301fc..176e82f5 100644 --- a/dashboard/.gitignore +++ b/dashboard/.gitignore @@ -1,2 +1,3 @@ dist/ -node_modules/ \ No newline at end of file +node_modules/ +dashboard \ No newline at end of file diff --git a/dashboard/go.mod b/dashboard/go.mod new file mode 100644 index 00000000..a646e295 --- /dev/null +++ b/dashboard/go.mod @@ -0,0 +1,5 @@ +module dashboard + +go 1.18 + +require github.com/go-chi/chi v1.5.4 // indirect diff --git a/dashboard/go.sum b/dashboard/go.sum new file mode 100644 index 00000000..874ed9a3 --- /dev/null +++ b/dashboard/go.sum @@ -0,0 +1,2 @@ +github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= +github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= diff --git a/dashboard/index.html b/dashboard/index.html index 7ffc05a3..cf1c484c 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -5,6 +5,7 @@ PluralKit | home + diff --git a/dashboard/main.go b/dashboard/main.go new file mode 100644 index 00000000..3e7a6bd6 --- /dev/null +++ b/dashboard/main.go @@ -0,0 +1,139 @@ +package main + +import ( + "embed" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/go-chi/chi" +) + +//go:embed dist/* +var fs embed.FS + +type entity struct { + AvatarURL *string `json:"avatar_url"` + IconURL *string `json:"icon_url"` + Description *string `json:"description"` + Color *string `json:"color"` +} + +var baseURL = "https://api.pluralkit.me/v2" + +var version = "dev" + +func main() { + r := chi.NewRouter() + + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("X-PluralKit-Version", version) + next.ServeHTTP(rw, r) + }) + }) + + r.NotFound(notFoundHandler) + + r.Get("/profile/{type}/{id}", func(rw http.ResponseWriter, r *http.Request) { + defer func() { + if a := recover(); a != nil { + notFoundHandler(rw, r) + return + } + }() + createEmbed(rw, r) + }) + + http.ListenAndServe(":8080", r) +} + +func notFoundHandler(rw http.ResponseWriter, r *http.Request) { + var data []byte + var err error + + // lol + if strings.HasSuffix(r.URL.Path, ".js") { + data, err = fs.ReadFile("dist" + r.URL.Path) + rw.Header().Add("content-type", "application/javascript") + } else if strings.HasSuffix(r.URL.Path, ".css") { + data, err = fs.ReadFile("dist" + r.URL.Path) + rw.Header().Add("content-type", "text/css") + } else if strings.HasSuffix(r.URL.Path, ".map") { + data, err = fs.ReadFile("dist" + r.URL.Path) + } else { + data, err = fs.ReadFile("dist/index.html") + rw.Header().Add("content-type", "text/html") + data = []byte(strings.Replace(string(data), ``, "", 1)) + } + + if err != nil { + panic(err) + } + + rw.Write(data) +} + +// explanation for createEmbed: +// we don't care about errors, we just want to return a HTML page as soon as possible +// `panic(nil)` is caught by upstream, which then returns the raw HTML page + +func createEmbed(rw http.ResponseWriter, r *http.Request) { + entityType := chi.URLParam(r, "type") + id := chi.URLParam(r, "id") + + var path string + + switch entityType { + case "s": + path = "/systems/" + id + case "m": + path = "/members/" + id + case "g": + path = "/groups/" + id + default: + panic(nil) + } + + res, err := http.Get(baseURL + path) + if err != nil { + panic(nil) + } + if res.StatusCode != 200 { + panic(nil) + } + + var data entity + body, _ := io.ReadAll(res.Body) + err = json.Unmarshal(body, &data) + if err != nil { + panic(nil) + } + + text := fmt.Sprintf(`%s`, baseURL, path, "\n") + + if data.AvatarURL != nil { + text += fmt.Sprintf(`%s`, *data.AvatarURL, "\n") + } else if data.IconURL != nil { + text += fmt.Sprintf(`%s`, *data.IconURL, "\n") + } + + if data.Description != nil { + text += fmt.Sprintf(`%s`, *data.Description, "\n") + } + + if data.Color != nil { + text += fmt.Sprintf(`%s`, *data.Color, "\n") + } + + html, err := fs.ReadFile("dist/index.html") + if err != nil { + panic(nil) + } + html = []byte(strings.Replace(string(html), ``, text, 1)) + + rw.Header().Add("content-type", "text/html") + rw.Write(html) +}