diff --git a/CHANGELOG.md b/CHANGELOG.md index bc54c4bb..7dfa11ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Added link preview support for 7tv emote links. (#155) - Skip lilliput if image is below maxThumbnailSize. (#184) ## 1.2.0 diff --git a/internal/resolvers/default/resolver.go b/internal/resolvers/default/resolver.go index 15ed36b2..65fd8edd 100644 --- a/internal/resolvers/default/resolver.go +++ b/internal/resolvers/default/resolver.go @@ -12,6 +12,7 @@ import ( "github.com/Chatterino/api/internal/resolvers/imgur" "github.com/Chatterino/api/internal/resolvers/livestreamfails" "github.com/Chatterino/api/internal/resolvers/oembed" + "github.com/Chatterino/api/internal/resolvers/seventv" "github.com/Chatterino/api/internal/resolvers/supinic" "github.com/Chatterino/api/internal/resolvers/twitch" "github.com/Chatterino/api/internal/resolvers/twitter" @@ -106,6 +107,7 @@ func New(cfg config.APIConfig) *R { r.customResolvers = append(r.customResolvers, twitter.New(cfg)...) r.customResolvers = append(r.customResolvers, wikipedia.New(cfg)...) r.customResolvers = append(r.customResolvers, youtube.New(cfg)...) + r.customResolvers = append(r.customResolvers, seventv.New(cfg)...) return r } diff --git a/internal/resolvers/seventv/check.go b/internal/resolvers/seventv/check.go new file mode 100644 index 00000000..6153f07e --- /dev/null +++ b/internal/resolvers/seventv/check.go @@ -0,0 +1,15 @@ +package seventv + +import ( + "net/url" + + "github.com/Chatterino/api/pkg/resolver" +) + +func check(url *url.URL) bool { + if match, _ := resolver.MatchesHosts(url, domains); !match { + return false + } + + return emotePathRegex.MatchString(url.Path) +} diff --git a/internal/resolvers/seventv/load.go b/internal/resolvers/seventv/load.go new file mode 100644 index 00000000..cf1b6e13 --- /dev/null +++ b/internal/resolvers/seventv/load.go @@ -0,0 +1,133 @@ +package seventv + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Chatterino/api/pkg/cache" + "github.com/Chatterino/api/pkg/resolver" + "github.com/Chatterino/api/pkg/utils" +) + +func load(emoteHash string, r *http.Request) (interface{}, time.Duration, error) { + log.Println("[SevenTV] GET", emoteHash) + + queryMap := map[string]interface{}{ + "query": ` +query fetchEmote($id: String!) { + emote(id: $id) { + visibility + id + name + owner { + id + display_name + } + } +}`, + "variables": map[string]string{ + "id": emoteHash, + }, + } + + queryBytes, err := json.Marshal(queryMap) + if err != nil { + return &resolver.Response{ + Status: http.StatusInternalServerError, + Message: "SevenTV API request error" + resolver.CleanResponse(err.Error()), + }, cache.NoSpecialDur, nil + } + + // Execute SevenTV API request + resp, err := resolver.RequestPOST(seventvAPIURL, string(queryBytes)) + if err != nil { + return &resolver.Response{ + Status: http.StatusInternalServerError, + Message: "SevenTV API request error" + resolver.CleanResponse(err.Error()), + }, cache.NoSpecialDur, nil + } + defer resp.Body.Close() + + // Read response into a string + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return &resolver.Response{ + Status: http.StatusInternalServerError, + Message: "SevenTV API http body read error " + resolver.CleanResponse(err.Error()), + }, cache.NoSpecialDur, nil + } + + // Error out if the emote wasn't found or something else went wrong with the request + if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusMultipleChoices { + return emoteNotFoundResponse, cache.NoSpecialDur, nil + } + + var jsonResponse EmoteAPIResponse + if err := json.Unmarshal(body, &jsonResponse); err != nil { + return &resolver.Response{ + Status: http.StatusInternalServerError, + Message: "SevenTV API unmarshal error " + resolver.CleanResponse(err.Error()), + }, cache.NoSpecialDur, nil + } + + // API returns Data.Emote as null if the emote wasn't found + if jsonResponse.Data.Emote == nil { + return emoteNotFoundResponse, cache.NoSpecialDur, nil + } + + // Determine type of the emote based on visibility flags + visibility := jsonResponse.Data.Emote.Visibility + var emoteType []string + + if utils.HasBits(visibility, EmoteVisibilityGlobal) { + emoteType = append(emoteType, "Global") + } + + if utils.HasBits(visibility, EmoteVisibilityPrivate) { + emoteType = append(emoteType, "Private") + } + + // Default to Shared emote + if len(emoteType) == 0 { + emoteType = append(emoteType, "Shared") + } + + // Build tooltip data from the API response + data := TooltipData{ + Code: jsonResponse.Data.Emote.Name, + Type: strings.Join(emoteType, " "), + Uploader: jsonResponse.Data.Emote.Owner.DisplayName, + Unlisted: utils.HasBits(visibility, EmoteVisibilityHidden), + } + + // Build a tooltip using the tooltip template (see tooltipTemplate) with the data we massaged above + var tooltip bytes.Buffer + if err := seventvEmoteTemplate.Execute(&tooltip, data); err != nil { + return &resolver.Response{ + Status: http.StatusInternalServerError, + Message: "SevenTV emote template error " + resolver.CleanResponse(err.Error()), + }, cache.NoSpecialDur, nil + } + + // Success + successTooltip := &resolver.Response{ + Status: http.StatusOK, + Tooltip: url.PathEscape(tooltip.String()), + Thumbnail: utils.FormatThumbnailURL(baseURL, r, fmt.Sprintf(thumbnailFormat, emoteHash)), + Link: fmt.Sprintf("https://7tv.app/emotes/%s", emoteHash), + } + + // Hide thumbnail for unlisted or hidden emotes pajaS + if data.Unlisted { + successTooltip.Thumbnail = "" + } + + return successTooltip, cache.NoSpecialDur, nil +} diff --git a/internal/resolvers/seventv/load_test.go b/internal/resolvers/seventv/load_test.go new file mode 100644 index 00000000..31b730d0 --- /dev/null +++ b/internal/resolvers/seventv/load_test.go @@ -0,0 +1,136 @@ +package seventv + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/Chatterino/api/pkg/resolver" + "github.com/go-chi/chi/v5" + + qt "github.com/frankban/quicktest" +) + +var ( + emotes = map[string]EmoteAPIResponse{} +) + +func init() { + emotes["Pajawalk"] = EmoteAPIResponse{ + Data: EmoteAPIResponseData{ + Emote: &EmoteAPIEmote{ + ID: "604281c81ae70f000d47ffd9", + Name: "Pajawalk", + Visibility: EmoteVisibilityPrivate, + Owner: EmoteAPIUser{ + ID: "603d10e496832ffa787ca53c", + DisplayName: "durado_", + }, + }, + }, + } + + emotes["Hidden"] = EmoteAPIResponse{ + Data: EmoteAPIResponseData{ + Emote: &EmoteAPIEmote{ + ID: "604281c81ae70f000d47ffd9", + Name: "Hidden", + Visibility: EmoteVisibilityPrivate | EmoteVisibilityHidden, + Owner: EmoteAPIUser{ + ID: "603d10e496832ffa787ca53c", + DisplayName: "durado_", + }, + }, + }, + } +} + +func TestFoo(t *testing.T) { + c := qt.New(t) + + r := chi.NewRouter() + r.Post("/v2/gql", func(w http.ResponseWriter, r *http.Request) { + type gqlQuery struct { + Query string `json:"string"` + Variables map[string]string `json:"variables"` + } + + var q gqlQuery + + xd, _ := ioutil.ReadAll(r.Body) + err := json.Unmarshal(xd, &q) + if err != nil { + panic(err) + } + if response, ok := emotes[q.Variables["id"]]; ok { + b, _ := json.Marshal(&response) + + w.Header().Set("Content-Type", "application/json") + w.Write(b) + return + } + + // TODO: return 404 + }) + ts := httptest.NewServer(r) + defer ts.Close() + seventvAPIURL = ts.URL + "/v2/gql" + + type tTest struct { + emoteHash string + expectedTooltip string + } + + tests := []tTest{ + { + emoteHash: "Pajawalk", + expectedTooltip: `
+Pajawalk
+Private SevenTV Emote
+By: durado_ +
`, + }, + { + emoteHash: "Hidden", + expectedTooltip: `
+Hidden
+Private SevenTV Emote
+By: durado_ +
  • UNLISTED
  • +
    `, + }, + // TODO: Global emote + // TODO: Private emote + // TODO: Combined emote types + // TODO: Default emote type (shared) + // TODO: Thumbnails + // TODO: emote not found (404) + } + + request, _ := http.NewRequest(http.MethodPost, "https://7tv.app/test", nil) + + for _, test := range tests { + iret, _, err := load(test.emoteHash, request) + + c.Assert(err, qt.IsNil) + c.Assert(iret, qt.Not(qt.IsNil)) + + response := iret.(*resolver.Response) + + c.Assert(response, qt.Not(qt.IsNil)) + + c.Assert(response.Status, qt.Equals, 200) + + // TODO: check thumbnail + // c.Assert(response.Thumbnail, qt.Equals, fmt.Sprintf(thumbnailFormat, test.emoteHash)) + + // TODO: check error + cleanTooltip, unescapeErr := url.PathUnescape(response.Tooltip) + c.Assert(unescapeErr, qt.IsNil) + + c.Assert(cleanTooltip, qt.Equals, test.expectedTooltip) + } +} diff --git a/internal/resolvers/seventv/model.go b/internal/resolvers/seventv/model.go new file mode 100644 index 00000000..d5f78864 --- /dev/null +++ b/internal/resolvers/seventv/model.go @@ -0,0 +1,41 @@ +package seventv + +type EmoteAPIUser struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` +} + +type EmoteAPIEmote struct { + ID string `json:"id"` + Name string `json:"name"` + Visibility int32 `json:"visibility"` + Owner EmoteAPIUser `json:"owner"` +} + +type EmoteAPIResponseData struct { + Emote *EmoteAPIEmote `json:"emote,omitempty"` +} + +type EmoteAPIResponse struct { + Data EmoteAPIResponseData `json:"data"` +} + +type TooltipData struct { + Code string + Type string + Uploader string + + Unlisted bool +} + +const ( + EmoteVisibilityPrivate int32 = 1 << iota + EmoteVisibilityGlobal + EmoteVisibilityHidden + EmoteVisibilityOverrideBTTV + EmoteVisibilityOverrideFFZ + EmoteVisibilityOverrideTwitchGlobal + EmoteVisibilityOverrideTwitchSubscriber + + EmoteVisibilityAll int32 = (1 << iota) - 1 +) diff --git a/internal/resolvers/seventv/resolver.go b/internal/resolvers/seventv/resolver.go new file mode 100644 index 00000000..a2072e46 --- /dev/null +++ b/internal/resolvers/seventv/resolver.go @@ -0,0 +1,56 @@ +package seventv + +import ( + "errors" + "html/template" + "regexp" + "time" + + "github.com/Chatterino/api/pkg/cache" + "github.com/Chatterino/api/pkg/config" + "github.com/Chatterino/api/pkg/resolver" +) + +const ( + thumbnailFormat = "https://cdn.7tv.app/emote/%s/4x" + + tooltipTemplate = `
    +{{.Code}}
    +{{.Type}} SevenTV Emote
    +By: {{.Uploader}}` + + `{{ if .Unlisted }}` + ` +
  • UNLISTED
  • {{ end }} +
    ` +) + +var ( + seventvAPIURL = "https://api.7tv.app/v2/gql" + + errInvalidSevenTVEmotePath = errors.New("invalid SevenTV emote path") + + domains = map[string]struct{}{ + "7tv.app": {}, + } + + emotePathRegex = regexp.MustCompile(`/emotes/([a-f0-9]+)`) + + emoteCache = cache.New("seventv_emotes", load, 1*time.Hour) + + seventvEmoteTemplate = template.Must(template.New("seventvEmoteTooltip").Parse(tooltipTemplate)) + + baseURL string +) + +func New(cfg config.APIConfig) (resolvers []resolver.CustomURLManager) { + // Pass baseURL for thumbnail proxying + baseURL = cfg.BaseURL + + // Find links matching the SevenTV direct emote link (e.g. https://7tv.app/emotes/60b03e84b254a5e16b439128) + resolvers = append(resolvers, resolver.CustomURLManager{ + Check: check, + + Run: run, + }) + + return +} diff --git a/internal/resolvers/seventv/run.go b/internal/resolvers/seventv/run.go new file mode 100644 index 00000000..491ec16c --- /dev/null +++ b/internal/resolvers/seventv/run.go @@ -0,0 +1,19 @@ +package seventv + +import ( + "encoding/json" + "net/http" + "net/url" +) + +func run(url *url.URL, r *http.Request) ([]byte, error) { + matches := emotePathRegex.FindStringSubmatch(url.Path) + if len(matches) != 2 { + return nil, errInvalidSevenTVEmotePath + } + + emoteHash := matches[1] + + apiResponse := emoteCache.Get(emoteHash, r) + return json.Marshal(apiResponse) +} diff --git a/internal/resolvers/seventv/static_responses.go b/internal/resolvers/seventv/static_responses.go new file mode 100644 index 00000000..a000c6b7 --- /dev/null +++ b/internal/resolvers/seventv/static_responses.go @@ -0,0 +1,14 @@ +package seventv + +import ( + "net/http" + + "github.com/Chatterino/api/pkg/resolver" +) + +var ( + emoteNotFoundResponse = &resolver.Response{ + Status: http.StatusNotFound, + Message: "No SevenTV emote with this id found", + } +) diff --git a/pkg/resolver/request.go b/pkg/resolver/request.go index 43e38267..b719914f 100644 --- a/pkg/resolver/request.go +++ b/pkg/resolver/request.go @@ -2,6 +2,7 @@ package resolver import ( "net/http" + "strings" "time" ) @@ -43,6 +44,17 @@ func RequestGETWithHeaders(url string, extraHeaders map[string]string) (response return httpClient.Do(req) } +func RequestPOST(url, body string) (response *http.Response, err error) { + req, err := http.NewRequest("POST", url, strings.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + return httpClient.Do(req) +} + func HTTPClient() *http.Client { return httpClient } diff --git a/pkg/utils/bit.go b/pkg/utils/bit.go new file mode 100644 index 00000000..045bd9da --- /dev/null +++ b/pkg/utils/bit.go @@ -0,0 +1,6 @@ +package utils + +// HasBits checks if sum contains bit by performing a bitwise AND operation between values +func HasBits(sum int32, bit int32) bool { + return (sum & bit) == bit +} diff --git a/pkg/utils/bit_test.go b/pkg/utils/bit_test.go new file mode 100644 index 00000000..5c146737 --- /dev/null +++ b/pkg/utils/bit_test.go @@ -0,0 +1,42 @@ +package utils + +import "testing" + +func TestHasBits(t *testing.T) { + type tType struct { + input int32 + bit int32 + expectedOutput bool + } + + tests := []tType{ + { + input: 0b0100, + bit: 0b0100, + expectedOutput: true, + }, + { + input: 0b1100, + bit: 0b0100, + expectedOutput: true, + }, + { + input: 0b0000, + bit: 0b0100, + expectedOutput: false, + }, + { + input: 0b1000, + bit: 0b0100, + expectedOutput: false, + }, + } + + for _, test := range tests { + output := HasBits(test.input, test.bit) + if output != test.expectedOutput { + t.Fatalf("got output '%v', expected '%v'", output, test.expectedOutput) + } + } + +}