feat(dashboard): add opengraph/oembed, Docker build
This commit is contained in:
		| @@ -11,6 +11,7 @@ | |||||||
| !.git | !.git | ||||||
| !proto | !proto | ||||||
| !scripts/run-clustered.sh | !scripts/run-clustered.sh | ||||||
|  | !dashboard | ||||||
|  |  | ||||||
| # Re-exclude host build artifact directories | # Re-exclude host build artifact directories | ||||||
| **/bin | **/bin | ||||||
|   | |||||||
							
								
								
									
										34
									
								
								.github/workflows/dashboard
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								.github/workflows/dashboard
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 | ||||||
							
								
								
									
										17
									
								
								Dockerfile.dashboard
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								Dockerfile.dashboard
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
| @@ -25,6 +25,18 @@ public static class APIJsonExt | |||||||
|  |  | ||||||
|         return o; |         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 | public struct FrontersReturnNew | ||||||
|   | |||||||
| @@ -97,6 +97,21 @@ public class GroupControllerV2: PKControllerBase | |||||||
|         return Ok(group.ToJson(ContextFor(group), system.Hid)); |         return Ok(group.ToJson(ContextFor(group), system.Hid)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("groups/{groupRef}/oembed.json")] | ||||||
|  |     public async Task<IActionResult> 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}")] |     [HttpPatch("groups/{groupRef}")] | ||||||
|     public async Task<IActionResult> DoGroupPatch(string groupRef, [FromBody] JObject data) |     public async Task<IActionResult> DoGroupPatch(string groupRef, [FromBody] JObject data) | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -79,6 +79,21 @@ public class MemberControllerV2: PKControllerBase | |||||||
|         return Ok(member.ToJson(ContextFor(member), systemStr: system.Hid)); |         return Ok(member.ToJson(ContextFor(member), systemStr: system.Hid)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("members/{memberRef}/oembed.json")] | ||||||
|  |     public async Task<IActionResult> 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}")] |     [HttpPatch("members/{memberRef}")] | ||||||
|     public async Task<IActionResult> DoMemberPatch(string memberRef, [FromBody] JObject data) |     public async Task<IActionResult> DoMemberPatch(string memberRef, [FromBody] JObject data) | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -20,6 +20,16 @@ public class SystemControllerV2: PKControllerBase | |||||||
|         return Ok(system.ToJson(ContextFor(system))); |         return Ok(system.ToJson(ContextFor(system))); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{systemRef}/oembed.json")] | ||||||
|  |     public async Task<IActionResult> 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}")] |     [HttpPatch("{systemRef}")] | ||||||
|     public async Task<IActionResult> DoSystemPatch(string systemRef, [FromBody] JObject data) |     public async Task<IActionResult> DoSystemPatch(string systemRef, [FromBody] JObject data) | ||||||
|     { |     { | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								dashboard/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								dashboard/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,2 +1,3 @@ | |||||||
| dist/ | dist/ | ||||||
| node_modules/ | node_modules/ | ||||||
|  | dashboard | ||||||
							
								
								
									
										5
									
								
								dashboard/go.mod
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								dashboard/go.mod
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | module dashboard | ||||||
|  |  | ||||||
|  | go 1.18 | ||||||
|  |  | ||||||
|  | require github.com/go-chi/chi v1.5.4 // indirect | ||||||
							
								
								
									
										2
									
								
								dashboard/go.sum
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								dashboard/go.sum
									
									
									
									
									
										Normal file
									
								
							| @@ -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= | ||||||
| @@ -5,6 +5,7 @@ | |||||||
|     <link rel="icon" href="./myriad.png" /> |     <link rel="icon" href="./myriad.png" /> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||||
|     <title>PluralKit | home</title> |     <title>PluralKit | home</title> | ||||||
|  |     <!-- extra data --> | ||||||
|     <link rel="stylesheet" href="/styles/generic.scss" /> |     <link rel="stylesheet" href="/styles/generic.scss" /> | ||||||
|     <link rel="stylesheet" href="/styles/dark.scss" /> |     <link rel="stylesheet" href="/styles/dark.scss" /> | ||||||
|     <link rel="stylesheet" href="/styles/light.scss" /> |     <link rel="stylesheet" href="/styles/light.scss" /> | ||||||
|   | |||||||
							
								
								
									
										139
									
								
								dashboard/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								dashboard/main.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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), `<!-- extra 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(`<link type="application/json+oembed" href="%s/%s/oembed.json" />%s`, baseURL, path, "\n") | ||||||
|  |  | ||||||
|  | 	if data.AvatarURL != nil { | ||||||
|  | 		text += fmt.Sprintf(`<meta content='%s' property='og:image'>%s`, *data.AvatarURL, "\n") | ||||||
|  | 	} else if data.IconURL != nil { | ||||||
|  | 		text += fmt.Sprintf(`<meta content='%s' property='og:image'>%s`, *data.IconURL, "\n") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if data.Description != nil { | ||||||
|  | 		text += fmt.Sprintf(`<meta content="%s" property="og:description">%s`, *data.Description, "\n") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if data.Color != nil { | ||||||
|  | 		text += fmt.Sprintf(`<meta name="theme-color" content="#%s">%s`, *data.Color, "\n") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	html, err := fs.ReadFile("dist/index.html") | ||||||
|  | 	if err != nil { | ||||||
|  | 		panic(nil) | ||||||
|  | 	} | ||||||
|  | 	html = []byte(strings.Replace(string(html), `<!-- extra data -->`, text, 1)) | ||||||
|  |  | ||||||
|  | 	rw.Header().Add("content-type", "text/html") | ||||||
|  | 	rw.Write(html) | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user