├── .github ├── assets │ ├── media_only.jpg │ ├── no_caption.jpg │ ├── orig_embed.jpg │ ├── Step1_image.jpg │ ├── Step2_image.jpg │ ├── Step4_image_a.jpg │ └── Step4_image_b.jpg └── workflows │ └── build.yml ├── handlers ├── scraper │ ├── dictionary.bin │ ├── cache.go │ └── data.go ├── oembed.go ├── images.go ├── videos.go ├── embed.go └── grid.go ├── scripts ├── build.sh ├── docker-compose.yml └── k8s │ ├── instafix-deployment.yaml │ └── instafix-ingress.yaml ├── utils ├── strconv.go ├── crawlerdetect.go ├── strings.go └── jsonesc.go ├── views ├── model │ └── model.go ├── oembed.manual.go ├── embed.jade ├── home.jade ├── home.jade.go ├── jade.go └── embed.jade.go ├── .gitignore ├── go.mod ├── Caddyfile ├── LICENSE ├── Dockerfile ├── README.md ├── main.go └── go.sum /.github/assets/media_only.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wikidepia/InstaFix/HEAD/.github/assets/media_only.jpg -------------------------------------------------------------------------------- /.github/assets/no_caption.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wikidepia/InstaFix/HEAD/.github/assets/no_caption.jpg -------------------------------------------------------------------------------- /.github/assets/orig_embed.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wikidepia/InstaFix/HEAD/.github/assets/orig_embed.jpg -------------------------------------------------------------------------------- /.github/assets/Step1_image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wikidepia/InstaFix/HEAD/.github/assets/Step1_image.jpg -------------------------------------------------------------------------------- /.github/assets/Step2_image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wikidepia/InstaFix/HEAD/.github/assets/Step2_image.jpg -------------------------------------------------------------------------------- /.github/assets/Step4_image_a.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wikidepia/InstaFix/HEAD/.github/assets/Step4_image_a.jpg -------------------------------------------------------------------------------- /.github/assets/Step4_image_b.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wikidepia/InstaFix/HEAD/.github/assets/Step4_image_b.jpg -------------------------------------------------------------------------------- /handlers/scraper/dictionary.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wikidepia/InstaFix/HEAD/handlers/scraper/dictionary.bin -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Optional: If not using GHCR, let it build locally 4 | #docker build -t instafix ../src/ 5 | 6 | # Refresh existing containers 7 | docker-compose down 8 | docker-compose up -d -------------------------------------------------------------------------------- /utils/strconv.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "unsafe" 4 | 5 | func S2B(s string) []byte { 6 | return unsafe.Slice(unsafe.StringData(s), len(s)) 7 | } 8 | 9 | func B2S(b []byte) string { 10 | return *(*string)(unsafe.Pointer(&b)) 11 | } 12 | -------------------------------------------------------------------------------- /views/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type ViewsData struct { 4 | Card string 5 | Title string `default:"InstaFix"` 6 | ImageURL string `default:""` 7 | VideoURL string `default:""` 8 | URL string 9 | Description string 10 | OEmbedURL string 11 | Width int `default:"400"` 12 | Height int `default:"400"` 13 | } 14 | 15 | type OEmbedData struct { 16 | Text string 17 | URL string 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Allowlisting gitignore template for GO projects prevents us 2 | # from adding various unwanted local files, such as generated 3 | # files, developer configurations or IDE-specific files etc. 4 | # 5 | # Recommended: Go.AllowList.gitignore 6 | 7 | # Ignore everything 8 | * 9 | 10 | # But not these files... 11 | !/.gitignore 12 | 13 | !*.go 14 | !go.sum 15 | !go.mod 16 | 17 | !README.md 18 | !LICENSE 19 | 20 | # !Makefile 21 | 22 | # ...even if they are in subdirectories 23 | !*/ 24 | -------------------------------------------------------------------------------- /scripts/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | instafix: 5 | image: ghcr.io/wikidepia/instafix:main 6 | restart: unless-stopped 7 | expose: 8 | - "3000:3000" # Use HOST:3000 for direct access if Traefik is not setup 9 | labels: 10 | - traefik.enable=true 11 | - traefik.http.routers.instafix.entryPoints=web-secure 12 | - traefik.http.routers.instafix.rule=Host(`ddinstagram.com`) 13 | - traefik.http.routers.instafix.tls=true 14 | - traefik.http.services.instafix.loadbalancer.server.port=3000 -------------------------------------------------------------------------------- /scripts/k8s/instafix-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: instafix 5 | spec: 6 | replicas: 10 7 | strategy: 8 | type: Recreate 9 | selector: 10 | matchLabels: 11 | app: instafix 12 | 13 | template: 14 | metadata: 15 | labels: 16 | app: instafix 17 | spec: 18 | containers: 19 | - image: ghcr.io/wikidepia/instafix:main 20 | name: instafix 21 | ports: 22 | - containerPort: 3000 23 | protocol: TCP 24 | restartPolicy: Always 25 | -------------------------------------------------------------------------------- /handlers/oembed.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "instafix/utils" 5 | "instafix/views" 6 | "instafix/views/model" 7 | "net/http" 8 | ) 9 | 10 | func OEmbed(w http.ResponseWriter, r *http.Request) { 11 | urlQuery := r.URL.Query() 12 | if urlQuery == nil { 13 | return 14 | } 15 | headingText := urlQuery.Get("text") 16 | headingURL := urlQuery.Get("url") 17 | if headingText == "" || headingURL == "" { 18 | return 19 | } 20 | w.Header().Set("Content-Type", "application/json") 21 | 22 | // Totally safe 100% valid template 👍 23 | OEmbedData := &model.OEmbedData{ 24 | Text: utils.EscapeJSONString(headingText), 25 | URL: headingURL, 26 | } 27 | 28 | views.OEmbed(OEmbedData, w) 29 | } 30 | -------------------------------------------------------------------------------- /views/oembed.manual.go: -------------------------------------------------------------------------------- 1 | // Code generated by "Myself"; PLEASE EDIT. 2 | 3 | package views 4 | 5 | import ( 6 | "instafix/views/model" 7 | "io" 8 | ) 9 | 10 | const ( 11 | oembed__0 = `{"author_name": ` 12 | oembed__1 = `,"author_url": "` 13 | oembed__2 = `","provider_name": "InstaFix","provider_url": "https://github.com/Wikidepia/InstaFix","title": "Instagram","type": "link","version": "1.0"}` 14 | ) 15 | 16 | func OEmbed(o *model.OEmbedData, wr io.Writer) { 17 | buffer := &WriterAsBuffer{wr} 18 | 19 | buffer.WriteString(oembed__0) 20 | WriteAll(o.Text, false, buffer) // Escape in handlers 21 | buffer.WriteString(oembed__1) 22 | WriteAll(o.URL, false, buffer) // Escape in handlers 23 | buffer.WriteString(oembed__2) 24 | 25 | } 26 | -------------------------------------------------------------------------------- /handlers/images.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | scraper "instafix/handlers/scraper" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/go-chi/chi/v5" 9 | ) 10 | 11 | func Images(w http.ResponseWriter, r *http.Request) { 12 | postID := chi.URLParam(r, "postID") 13 | mediaNum, err := strconv.Atoi(chi.URLParam(r, "mediaNum")) 14 | if err != nil { 15 | http.Error(w, err.Error(), http.StatusInternalServerError) 16 | return 17 | } 18 | 19 | item, err := scraper.GetData(postID) 20 | if err != nil { 21 | http.Error(w, err.Error(), http.StatusInternalServerError) 22 | return 23 | } 24 | 25 | // Redirect to image URL 26 | if mediaNum > len(item.Medias) { 27 | return 28 | } 29 | imageURL := item.Medias[max(1, mediaNum)-1].URL 30 | http.Redirect(w, r, imageURL, http.StatusFound) 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module instafix 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/PuerkitoBio/goquery v1.10.0 7 | github.com/RyanCarrier/dijkstra/v2 v2.0.2 8 | github.com/cespare/xxhash/v2 v2.3.0 9 | github.com/elastic/go-freelru v0.16.0 10 | github.com/go-chi/chi/v5 v5.1.0 11 | github.com/kelindar/binary v1.0.19 12 | github.com/klauspost/compress v1.17.11 13 | github.com/tdewolff/parse/v2 v2.7.19 14 | github.com/tidwall/gjson v1.18.0 15 | go.etcd.io/bbolt v1.3.11 16 | golang.org/x/image v0.22.0 17 | golang.org/x/net v0.31.0 18 | golang.org/x/sync v0.9.0 19 | ) 20 | 21 | require ( 22 | github.com/andybalholm/cascadia v1.3.2 // indirect 23 | github.com/stretchr/testify v1.9.0 // indirect 24 | github.com/tidwall/match v1.1.1 // indirect 25 | github.com/tidwall/pretty v1.2.1 // indirect 26 | golang.org/x/sys v0.27.0 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /utils/crawlerdetect.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | var knownBots = []string{ 8 | "bot", 9 | "facebook", 10 | "embed", 11 | "got", 12 | "firefox/92", 13 | "firefox/38", 14 | "curl", 15 | "wget", 16 | "go-http", 17 | "yahoo", 18 | "generator", 19 | "whatsapp", 20 | "preview", 21 | "link", 22 | "proxy", 23 | "vkshare", 24 | "images", 25 | "analyzer", 26 | "index", 27 | "crawl", 28 | "spider", 29 | "python", 30 | "cfnetwork", 31 | "node", 32 | "mastodon", 33 | "http.rb", 34 | "discord", 35 | "ruby", 36 | "bun/", 37 | "fiddler", 38 | "revoltchat", 39 | } 40 | 41 | func IsBot(userAgent string) bool { 42 | userAgent = strings.ToLower(userAgent) 43 | for _, bot := range knownBots { 44 | if strings.Contains(userAgent, bot) { 45 | return true 46 | } 47 | } 48 | return false 49 | } 50 | -------------------------------------------------------------------------------- /scripts/k8s/instafix-ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: traefik.containo.us/v1alpha1 2 | kind: IngressRoute 3 | metadata: 4 | name: instafix 5 | spec: 6 | entryPoints: 7 | - websecure 8 | routes: 9 | - match: Host(`ddinstagram.com`) 10 | kind: Rule 11 | services: 12 | - name: instafix 13 | port: 3000 14 | tls: 15 | secretName: ddinstagram-com 16 | 17 | --- 18 | apiVersion: cert-manager.io/v1 19 | kind: Certificate 20 | metadata: 21 | name: ddinstagram-com 22 | spec: 23 | secretName: ddinstagram-com 24 | commonName: ddinstagram.com 25 | dnsNames: 26 | - ddinstagram.com 27 | issuerRef: 28 | name: letsencrypt-prod 29 | kind: ClusterIssuer 30 | 31 | --- 32 | apiVersion: v1 33 | kind: Service 34 | metadata: 35 | name: instafix 36 | labels: 37 | app: instafix 38 | spec: 39 | selector: 40 | app: instafix 41 | ports: 42 | - port: 3000 -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | # The Caddyfile is an easy way to configure your Caddy web server. 2 | # 3 | # Unless the file starts with a global options block, the first 4 | # uncommented line is always the address of your site. 5 | # 6 | # To use your own domain name (with automatic HTTPS), first make 7 | # sure your domain's A/AAAA DNS records are properly pointed to 8 | # this machine's public IP, then replace ":80" below with your 9 | # domain name. 10 | 11 | domain.com, www.domain.com { 12 | # Another common task is to set up a reverse proxy: 13 | reverse_proxy localhost:3000 14 | } 15 | 16 | d.domain.com { 17 | reverse_proxy localhost:3000 18 | rewrite * ?direct=true 19 | } 20 | 21 | g.domain.com { 22 | reverse_proxy localhost:3000 23 | rewrite * ?gallery=true 24 | } 25 | 26 | # Refer to the Caddy docs for more information: 27 | # https://caddyserver.com/docs/caddyfile 28 | -------------------------------------------------------------------------------- /handlers/videos.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | scraper "instafix/handlers/scraper" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/go-chi/chi/v5" 10 | ) 11 | 12 | var VideoProxyAddr string 13 | 14 | func Videos(w http.ResponseWriter, r *http.Request) { 15 | postID := chi.URLParam(r, "postID") 16 | mediaNum, err := strconv.Atoi(chi.URLParam(r, "mediaNum")) 17 | if err != nil { 18 | http.Error(w, err.Error(), http.StatusInternalServerError) 19 | return 20 | } 21 | 22 | item, err := scraper.GetData(postID) 23 | if err != nil { 24 | http.Error(w, err.Error(), http.StatusInternalServerError) 25 | return 26 | } 27 | 28 | // Redirect to image URL 29 | if mediaNum > len(item.Medias) { 30 | return 31 | } 32 | videoURL := item.Medias[max(1, mediaNum)-1].URL 33 | 34 | // Redirect to proxy if not TelegramBot in User-Agent 35 | if strings.Contains(r.Header.Get("User-Agent"), "TelegramBot") { 36 | http.Redirect(w, r, videoURL, http.StatusFound) 37 | return 38 | } 39 | http.Redirect(w, r, VideoProxyAddr+videoURL, http.StatusFound) 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /handlers/scraper/cache.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/cespare/xxhash/v2" 7 | "github.com/elastic/go-freelru" 8 | bolt "go.etcd.io/bbolt" 9 | ) 10 | 11 | var DB *bolt.DB 12 | var LRU *freelru.SyncedLRU[string, bool] 13 | 14 | func hashStringXXHASH(s string) uint32 { 15 | return uint32(xxhash.Sum64String(s)) 16 | } 17 | 18 | func InitDB() { 19 | db, err := bolt.Open("cache.db", 0600, nil) 20 | if err != nil { 21 | panic(err) 22 | } 23 | 24 | // Create buckets 25 | err = db.Update(func(tx *bolt.Tx) error { 26 | tx.CreateBucketIfNotExists([]byte("data")) 27 | tx.CreateBucketIfNotExists([]byte("ttl")) 28 | return nil 29 | }) 30 | if err != nil { 31 | panic(err) 32 | } 33 | 34 | DB = db 35 | } 36 | 37 | func InitLRU(maxEntries int) { 38 | // Initialize LRU for grid caching 39 | lru, err := freelru.NewSynced[string, bool](uint32(maxEntries), hashStringXXHASH) 40 | if err != nil { 41 | panic(err) 42 | } 43 | 44 | lru.SetOnEvict(func(key string, value bool) { 45 | os.Remove(key) 46 | }) 47 | 48 | // Fill LRU with existing files 49 | dir, err := os.ReadDir("static") 50 | if err != nil { 51 | panic(err) 52 | } 53 | for _, d := range dir { 54 | if !d.IsDir() { 55 | lru.Add("static/"+d.Name(), true) 56 | } 57 | } 58 | 59 | LRU = lru 60 | } 61 | -------------------------------------------------------------------------------- /utils/strings.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "unicode/utf8" 4 | 5 | // Substr returns a substring of a given string, starting at the specified index 6 | // and with a specified length. 7 | // It handles UTF-8 encoded strings. 8 | // Taken from https://github.com/goravel/framework/ 9 | func Substr(str string, start int, length ...int) string { 10 | // Convert the string to a rune slice for proper handling of UTF-8 encoding. 11 | runes := []rune(str) 12 | strLen := utf8.RuneCountInString(str) 13 | end := strLen 14 | // Check if the start index is out of bounds. 15 | if start >= strLen { 16 | return "" 17 | } 18 | 19 | // If the start index is negative, count backwards from the end of the string. 20 | if start < 0 { 21 | start = strLen + start 22 | if start < 0 { 23 | start = 0 24 | } 25 | } 26 | 27 | if len(length) > 0 { 28 | if length[0] >= 0 { 29 | end = start + length[0] 30 | } else { 31 | end = strLen + length[0] 32 | } 33 | } 34 | 35 | // If the length is 0, return the substring from start to the end of the string. 36 | if len(length) == 0 { 37 | return string(runes[start:]) 38 | } 39 | 40 | // Handle the case where lenArg is negative and less than start 41 | if end < start { 42 | return "" 43 | } 44 | 45 | if end > strLen { 46 | end = strLen 47 | } 48 | 49 | // Return the substring. 50 | return string(runes[start:end]) 51 | } 52 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM --platform=$BUILDPLATFORM golang:1.23 as app-builder 4 | 5 | # Set destination for COPY 6 | WORKDIR /app 7 | 8 | # Download Go modules 9 | COPY go.mod go.sum ./ 10 | RUN go mod download 11 | 12 | # Copy the source code. Note the slash at the end, as explained in 13 | # https://docs.docker.com/engine/reference/builder/#copy 14 | COPY *.go ./ 15 | COPY handlers/ ./handlers/ 16 | COPY handlers/scraper/ ./handlers/scraper/ 17 | # NO-OP in case handlers/scraper/ was already copied previously 18 | RUN true 19 | COPY utils/ ./utils/ 20 | COPY views/ ./views/ 21 | 22 | # This is the architecture you’re building for, which is passed in by the builder. 23 | # Placing it here allows the previous steps to be cached across architectures. 24 | ARG TARGETARCH 25 | 26 | # Build 27 | RUN GOOS=linux GOARCH=$TARGETARCH go build -tags netgo,osusergo -ldflags '-extldflags "-static"' 28 | 29 | # Run in scratch container 30 | FROM scratch 31 | # the test program: 32 | COPY --from=app-builder /app/instafix /instafix 33 | # the tls certificates: 34 | # NB: this pulls directly from the upstream image, which already has ca-certificates: 35 | COPY --from=alpine:latest /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 36 | 37 | # Optional: 38 | # To bind to a TCP port, runtime parameters must be supplied to the docker command. 39 | # But we can document in the Dockerfile what ports 40 | # the application is going to listen on by default. 41 | # https://docs.docker.com/engine/reference/builder/#expose 42 | EXPOSE 3000 43 | 44 | # Run the app 45 | ENTRYPOINT ["/instafix"] 46 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Wikidepia/InstaFix 2 | on: 3 | push: 4 | workflow_dispatch: 5 | env: 6 | REGISTRY: ghcr.io 7 | IMAGE_NAME: ${{ github.repository }} 8 | jobs: 9 | build-and-push-image: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | packages: write 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 17 | 18 | - name: Setup QEMU 19 | uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3.2.0 20 | with: 21 | platforms: arm, arm64, amd64 22 | 23 | - name: Setup BuildX 24 | uses: docker/setup-buildx-action@aa33708b10e362ff993539393ff100fa93ed6a27 # v3.5.0 25 | 26 | - name: Log in to the Container registry 27 | uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 28 | with: 29 | registry: ${{ env.REGISTRY }} 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Extract metadata (tags, labels) for Docker 34 | id: meta 35 | uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 36 | with: 37 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 38 | 39 | - name: Build and push Docker image 40 | uses: docker/build-push-action@5176d81f87c23d6fc96624dfdbcd9f3830bbe445 # v6.5.0 41 | with: 42 | context: . 43 | platforms: linux/arm, linux/arm64, linux/amd64 44 | push: true 45 | tags: ${{ steps.meta.outputs.tags }} 46 | labels: ${{ steps.meta.outputs.labels }} 47 | -------------------------------------------------------------------------------- /views/embed.jade: -------------------------------------------------------------------------------- 1 | :go:func Embed(v *model.ViewsData) 2 | 3 | :go:import "instafix/views/model" 4 | 5 | mixin for(golang) 6 | #cmd Precompile jade templates to #{golang} code. 7 | 8 | doctype html 9 | html(lang='en') 10 | head 11 | meta(charset="utf-8") 12 | meta(name='theme-color', content='#CE0071') 13 | 14 | if (v.Card != "") 15 | meta(name='twitter:card', content=v.Card) 16 | if (v.Title != "") 17 | meta(name='twitter:title', content=v.Title) 18 | if (v.ImageURL != "") 19 | meta(name='twitter:image', content=v.ImageURL) 20 | if (v.VideoURL != "") 21 | meta(name='twitter:player:width', content=v.Width) 22 | meta(name='twitter:player:height', content=v.Height) 23 | meta(name='twitter:player:stream', content=v.VideoURL) 24 | meta(name='twitter:player:stream:content_type', content='video/mp4') 25 | 26 | if (v.VideoURL != "" || v.ImageURL != "") 27 | meta(property='og:site_name', content='InstaFix') 28 | 29 | meta(property='og:url', content=v.URL) 30 | meta(property='og:description', content=v.Description) 31 | 32 | if (v.ImageURL != "") 33 | meta(property='og:image', content=v.ImageURL) 34 | 35 | if (v.VideoURL != "") 36 | meta(property='og:video', content=v.VideoURL) 37 | meta(property='og:video:secure_url', content=v.VideoURL) 38 | meta(property='og:video:type', content='video/mp4') 39 | meta(property='og:video:width', content=v.Width) 40 | meta(property='og:video:height', content=v.Height) 41 | 42 | if (v.OEmbedURL != "") 43 | link(rel='alternate', href!=v.OEmbedURL, type='application/json+oembed', title=v.Title) 44 | 45 | meta(http-equiv='refresh', content=`0; url = ${v.URL}`) 46 | 47 | body 48 | | Redirecting you to the post in a moment. 49 | a(href=v.URL) Or click here. 50 | -------------------------------------------------------------------------------- /views/home.jade: -------------------------------------------------------------------------------- 1 | :go:func Home() 2 | 3 | mixin for(golang) 4 | #cmd Precompile jade templates to #{golang} code. 5 | 6 | doctype html 7 | html(lang="en") 8 | 9 | head 10 | meta(charset="utf-8") 11 | meta(name="viewport", content="width=device-width, initial-scale=1") 12 | meta(property="og:title", content="InstaFix") 13 | meta(property="og:site_name", content="InstaFix") 14 | meta(property="og:description", content="Fix Instagram embeds in Discord (and Telegram!)") 15 | title InstaFix 16 | link(rel="icon", href="data:image/svg+xml,🛠") 17 | link(rel="stylesheet", href="https://cdn.jsdelivr.net/npm/@picocss/pico@1.5.13/css/pico.min.css") 18 | 19 | body 20 | main.container(style="max-width: 35rem") 21 | hgroup 22 | h1 InstaFix 23 | h2 Fix Instagram embeds in Discord (and Telegram!) 24 | p InstaFix serves fixed Instagram image and video embeds. Heavily inspired by fxtwitter.com. 25 | 26 | section 27 | header 28 | h3(style="margin-bottom: 4px") How to Use 29 | ul 30 | li Add dd before instagram.com to fix embeds, or 31 | li To get direct media embed, add `d.dd` before `instagram.com`. 32 | video(src="https://user-images.githubusercontent.com/72781956/168544556-31009b0e-62e8-4d4c-909b-434ad146e118.mp4", controls="controls", muted="muted", style="width: 100%; max-height: 100%") 33 | | Your browser does not support the video tag. 34 | hr 35 | small: a(href="https://github.com/Wikidepia/InstaFix", target="_blank") Source code available in GitHub! 36 | br 37 | small • Instagram is a trademark of Instagram, Inc. This app is not affiliated with Instagram, Inc. 38 | -------------------------------------------------------------------------------- /views/home.jade.go: -------------------------------------------------------------------------------- 1 | // Code generated by "jade.go"; DO NOT EDIT. 2 | 3 | package views 4 | 5 | import ( 6 | "io" 7 | ) 8 | 9 | const ( 10 | home__0 = `InstaFix

InstaFix

Fix Instagram embeds in Discord (and Telegram!)

InstaFix serves fixed Instagram image and video embeds. Heavily inspired by fxtwitter.com.

How to Use

  • Add dd before instagram.com to fix embeds, or
  • ` 11 | home__1 = `

Source code available in GitHub!
• Instagram is a trademark of Instagram, Inc. This app is not affiliated with Instagram, Inc.
` 12 | ) 13 | 14 | func Home(wr io.Writer) { 15 | buffer := &WriterAsBuffer{wr} 16 | 17 | buffer.WriteString(home__0) 18 | 19 | buffer.WriteString(`To get direct media embed, add ` + "`" + `d.dd` + "`" + ` before ` + "`" + `instagram.com` + "`" + `.`) 20 | buffer.WriteString(home__1) 21 | 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # InstaFix 2 | 3 | > Instagram is a trademark of Instagram, Inc. This app is not affiliated with Instagram, Inc. 4 | 5 | InstaFix serves fixed Instagram image and video embeds. Heavily inspired by [fxtwitter.com](https://fxtwitter.com). 6 | 7 | ## How to use 8 | 9 | Add `dd` before `instagram.com` to show Instagram embeds, or 10 | 11 | 12 | 13 | ### Embed Media Only 14 | 15 | Add `d.dd` before `instagram.com` to show only the media. 16 | 17 | 18 | 19 | ### Gallery View 20 | 21 | Add `g.dd` before `instagram.com` to show only the author and the media, without any caption. 22 | 23 | 24 | 25 | ## Deploy InstaFix yourself (locally) 26 | 27 | 1. Clone the repository. 28 | 2. Install [Go](https://golang.org/doc/install). 29 | 3. Run `go build`. 30 | 4. Run `./instafix`. 31 | 32 | ## Deploy InstaFix yourself (cloud) 33 | 34 | 1. Pull the latest container image from GHCR and run it. 35 | `docker pull ghcr.io/wikidepia/instafix:main` 36 | 2. Run the pulled image with Docker (bound on port 3000): 37 | `docker run -d --restart=always -p 3000:3000 ghcr.io/wikidepia/instafix:main` 38 | 3. Optional: Use the Docker Compose file in [./scripts/docker-compose.yml](./scripts/docker-compose.yml). 39 | 4. Optional: Use a [Kubernetes Deployment file](./scripts/k8s/instafix-deployment.yaml) and a [Kubernetes Ingress configuration file](./scripts/k8s/instafix-ingress.yaml) to deploy to a Kubernetes cluster (with 10 replicas) by issuing `kubectl apply -f .` over the `./scripts/k8s/` folder. [TODO: CockroachDB is not shared between replicas at application level, extract Cockroach into its own Service and allow replicas to communicate to it]. 40 | 41 | ## Using iOS shortcut (contributed by @JohnMcAnearney) 42 | You can use the iOS shortcut found here: [Embed in Discord](https://www.icloud.com/shortcuts/3412a4c344fd4c6f99924e525dd3c0a2), in order to quickly embed content using InstaFix. The shortcut works by grabbing the url of the Instagram content you're trying to share, automatically appends 'dd' to where it needs to be, copies this to your device's clipboard and opens Discord. 43 | 44 | Note: Once you've downloaded the shortcut, you will need to: 45 | 1. Update the value of YOUR_SERVER_URL_HERE by opening the shortcut in the Shortcuts App. The value for this can be a direct message URL or a server URL. 46 | 2. The shortcut will already be available in your share sheet. To test, go to Instagram -> share -> Tap on "Embed in Discord". 47 | 3. This will open Discord and now you simply paste the text into the custom chat you've setup! 48 | 4. Now edit your sharesheet to make it even quicker to use! Simply open your share sheet by sharing something from Instagram, click "Edit Actions..." and click the "+" button on "Embed in Discord" 49 | 50 |

51 | 52 | 53 | 54 | 55 |

56 | 57 | ## Report a bug 58 | 59 | You could open an [issue](https://github.com/Wikidepia/InstaFix/issues). 60 | -------------------------------------------------------------------------------- /views/jade.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "io" 5 | "strconv" 6 | "strings" 7 | "unsafe" 8 | ) 9 | 10 | func S2B(s string) []byte { 11 | return unsafe.Slice(unsafe.StringData(s), len(s)) 12 | } 13 | 14 | // https://cs.opensource.google/go/go/+/refs/tags/go1.23.0:src/html/escape.go;l=166 15 | var htmlEscaper = strings.NewReplacer( 16 | `&`, "&", 17 | `'`, "'", // "'" is shorter than "'" and apos was not in HTML until HTML5. 18 | `<`, "<", 19 | `>`, ">", 20 | `"`, """, // """ is shorter than """. 21 | ) 22 | 23 | func WriteEscString(st string, buffer *WriterAsBuffer) { 24 | htmlEscaper.WriteString(buffer, st) 25 | } 26 | 27 | type WriterAsBuffer struct { 28 | io.Writer 29 | } 30 | 31 | func (w *WriterAsBuffer) WriteString(s string) (n int, err error) { 32 | n, err = w.Write(S2B(s)) 33 | return 34 | } 35 | 36 | func (w *WriterAsBuffer) WriteByte(b byte) (err error) { 37 | _, err = w.Write([]byte{b}) 38 | return 39 | } 40 | 41 | type stringer interface { 42 | String() string 43 | } 44 | 45 | func WriteAll(a interface{}, escape bool, buffer *WriterAsBuffer) { 46 | switch v := a.(type) { 47 | case string: 48 | if escape { 49 | WriteEscString(v, buffer) 50 | } else { 51 | buffer.WriteString(v) 52 | } 53 | case int: 54 | WriteInt(int64(v), buffer) 55 | case int8: 56 | WriteInt(int64(v), buffer) 57 | case int16: 58 | WriteInt(int64(v), buffer) 59 | case int32: 60 | WriteInt(int64(v), buffer) 61 | case int64: 62 | WriteInt(v, buffer) 63 | case uint: 64 | WriteUint(uint64(v), buffer) 65 | case uint8: 66 | WriteUint(uint64(v), buffer) 67 | case uint16: 68 | WriteUint(uint64(v), buffer) 69 | case uint32: 70 | WriteUint(uint64(v), buffer) 71 | case uint64: 72 | WriteUint(v, buffer) 73 | case float32: 74 | buffer.WriteString(strconv.FormatFloat(float64(v), 'f', -1, 64)) 75 | case float64: 76 | buffer.WriteString(strconv.FormatFloat(v, 'f', -1, 64)) 77 | case bool: 78 | WriteBool(v, buffer) 79 | case stringer: 80 | if escape { 81 | WriteEscString(v.String(), buffer) 82 | } else { 83 | buffer.WriteString(v.String()) 84 | } 85 | default: 86 | buffer.WriteString("\n<<< unprinted type, fmt.Stringer implementation needed >>>\n") 87 | } 88 | } 89 | 90 | func ternary(condition bool, iftrue, iffalse interface{}) interface{} { 91 | if condition { 92 | return iftrue 93 | } else { 94 | return iffalse 95 | } 96 | } 97 | 98 | // Used part of go source: 99 | // https://github.com/golang/go/blob/master/src/strconv/itoa.go 100 | func WriteUint(u uint64, buffer *WriterAsBuffer) { 101 | var a [64 + 1]byte 102 | i := len(a) 103 | 104 | if ^uintptr(0)>>32 == 0 { 105 | for u > uint64(^uintptr(0)) { 106 | q := u / 1e9 107 | us := uintptr(u - q*1e9) 108 | for j := 9; j > 0; j-- { 109 | i-- 110 | qs := us / 10 111 | a[i] = byte(us - qs*10 + '0') 112 | us = qs 113 | } 114 | u = q 115 | } 116 | } 117 | 118 | us := uintptr(u) 119 | for us >= 10 { 120 | i-- 121 | q := us / 10 122 | a[i] = byte(us - q*10 + '0') 123 | us = q 124 | } 125 | 126 | i-- 127 | a[i] = byte(us + '0') 128 | buffer.Write(a[i:]) 129 | } 130 | func WriteInt(i int64, buffer *WriterAsBuffer) { 131 | if i < 0 { 132 | buffer.WriteByte('-') 133 | i = -i 134 | } 135 | WriteUint(uint64(i), buffer) 136 | } 137 | func WriteBool(b bool, buffer *WriterAsBuffer) { 138 | if b { 139 | buffer.WriteString("true") 140 | return 141 | } 142 | buffer.WriteString("false") 143 | } 144 | -------------------------------------------------------------------------------- /views/embed.jade.go: -------------------------------------------------------------------------------- 1 | // Code generated by "jade.go"; DO NOT EDIT. 2 | 3 | package views 4 | 5 | import ( 6 | "instafix/views/model" 7 | "io" 8 | ) 9 | 10 | const ( 11 | embed__0 = `` 12 | embed__1 = `` 15 | embed__4 = `Redirecting you to the post in a moment. Or click here.` 18 | embed__7 = `` 25 | embed__17 = `` 26 | embed__18 = ` 0 { 24 | remainder := mediaID % 64 25 | mediaID /= 64 26 | shortCode = string(alphabet[remainder]) + shortCode 27 | } 28 | 29 | return shortCode 30 | } 31 | 32 | func getSharePostID(postID string) (string, error) { 33 | req, err := http.NewRequest("HEAD", "https://www.instagram.com/share/reel/"+postID+"/", nil) 34 | if err != nil { 35 | return postID, err 36 | } 37 | resp, err := http.DefaultTransport.RoundTrip(req) 38 | if err != nil { 39 | return postID, err 40 | } 41 | defer resp.Body.Close() 42 | redirURL, err := url.Parse(resp.Header.Get("Location")) 43 | if err != nil { 44 | return postID, err 45 | } 46 | postID = path.Base(redirURL.Path) 47 | if postID == "login" { 48 | return postID, errors.New("not logged in") 49 | } 50 | return postID, nil 51 | } 52 | 53 | func Embed(w http.ResponseWriter, r *http.Request) { 54 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 55 | viewsData := &model.ViewsData{} 56 | 57 | var err error 58 | postID := chi.URLParam(r, "postID") 59 | mediaNumParams := chi.URLParam(r, "mediaNum") 60 | urlQuery := r.URL.Query() 61 | if urlQuery == nil { 62 | return 63 | } 64 | if mediaNumParams == "" { 65 | imgIndex := urlQuery.Get("img_index") 66 | if imgIndex != "" { 67 | mediaNumParams = imgIndex 68 | } else { 69 | mediaNumParams = "0" 70 | } 71 | } 72 | mediaNum, err := strconv.Atoi(mediaNumParams) 73 | if err != nil { 74 | viewsData.Description = "Invalid img_index parameter" 75 | views.Embed(viewsData, w) 76 | return 77 | } 78 | 79 | isDirect, _ := strconv.ParseBool(urlQuery.Get("direct")) 80 | isGallery, _ := strconv.ParseBool(urlQuery.Get("gallery")) 81 | 82 | // Get direct/gallery from header too, nginx query params is pain in the ass 83 | embedType := r.Header.Get("X-Embed-Type") 84 | if embedType == "direct" { 85 | isDirect = true 86 | } else if embedType == "gallery" { 87 | isGallery = true 88 | } 89 | 90 | // Stories use mediaID (int) instead of postID 91 | if strings.Contains(r.URL.Path, "/stories/") { 92 | mediaID, err := strconv.Atoi(postID) 93 | if err != nil { 94 | viewsData.Description = "Invalid postID" 95 | views.Embed(viewsData, w) 96 | return 97 | } 98 | postID = mediaidToCode(mediaID) 99 | } else if strings.Contains(r.URL.Path, "/share/") { 100 | postID, err = getSharePostID(postID) 101 | if err != nil && len(scraper.RemoteScraperAddr) == 0 { 102 | slog.Error("Failed to get new postID from share URL", "postID", postID, "err", err) 103 | viewsData.Description = "Failed to get new postID from share URL" 104 | views.Embed(viewsData, w) 105 | return 106 | } 107 | } 108 | 109 | // If User-Agent is not bot, redirect to Instagram 110 | viewsData.Title = "InstaFix" 111 | viewsData.URL = "https://instagram.com" + strings.Replace(r.URL.RequestURI(), "/"+mediaNumParams, "", 1) 112 | if !utils.IsBot(r.Header.Get("User-Agent")) { 113 | http.Redirect(w, r, viewsData.URL, http.StatusFound) 114 | return 115 | } 116 | 117 | item, err := scraper.GetData(postID) 118 | if err != nil || len(item.Medias) == 0 { 119 | http.Redirect(w, r, viewsData.URL, http.StatusFound) 120 | return 121 | } 122 | 123 | if mediaNum > len(item.Medias) { 124 | viewsData.Description = "Media number out of range" 125 | views.Embed(viewsData, w) 126 | return 127 | } else if len(item.Username) == 0 { 128 | viewsData.Description = "Post not found" 129 | views.Embed(viewsData, w) 130 | return 131 | } 132 | 133 | var sb strings.Builder 134 | sb.Grow(32) // 32 bytes should be enough for most cases 135 | 136 | viewsData.Title = "@" + item.Username 137 | // Gallery do not have any caption 138 | if !isGallery { 139 | viewsData.Description = item.Caption 140 | if len(viewsData.Description) > 255 { 141 | viewsData.Description = utils.Substr(viewsData.Description, 0, 250) + "..." 142 | } 143 | } 144 | 145 | typename := item.Medias[max(1, mediaNum)-1].TypeName 146 | isImage := strings.Contains(typename, "Image") || strings.Contains(typename, "StoryVideo") 147 | switch { 148 | case mediaNum == 0 && isImage && len(item.Medias) > 1: 149 | viewsData.Card = "summary_large_image" 150 | sb.WriteString("/grid/") 151 | sb.WriteString(postID) 152 | viewsData.ImageURL = sb.String() 153 | case isImage: 154 | viewsData.Card = "summary_large_image" 155 | sb.WriteString("/images/") 156 | sb.WriteString(postID) 157 | sb.WriteString("/") 158 | sb.WriteString(strconv.Itoa(max(1, mediaNum))) 159 | viewsData.ImageURL = sb.String() 160 | default: 161 | viewsData.Card = "player" 162 | sb.WriteString("/videos/") 163 | sb.WriteString(postID) 164 | sb.WriteString("/") 165 | sb.WriteString(strconv.Itoa(max(1, mediaNum))) 166 | viewsData.VideoURL = sb.String() 167 | 168 | scheme := "http" 169 | if r.TLS != nil { 170 | scheme = "https" 171 | } 172 | viewsData.OEmbedURL = scheme + "://" + r.Host + "/oembed?text=" + url.QueryEscape(viewsData.Description) + "&url=" + viewsData.URL 173 | } 174 | if isDirect { 175 | http.Redirect(w, r, sb.String(), http.StatusFound) 176 | return 177 | } 178 | 179 | views.Embed(viewsData, w) 180 | } 181 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4= 2 | github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4= 3 | github.com/RyanCarrier/dijkstra/v2 v2.0.2 h1:DIOg/a7XDR+KmlDkNSX9ggDY6sNLrG+EBGvZUjfgi+A= 4 | github.com/RyanCarrier/dijkstra/v2 v2.0.2/go.mod h1:XwpYN7nC1LPwL3HkaavzB+VGaHRndSsZy/whsFy1AEI= 5 | github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= 6 | github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= 7 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 8 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/elastic/go-freelru v0.16.0 h1:gG2HJ1WXN2tNl5/p40JS/l59HjvjRhjyAa+oFTRArYs= 12 | github.com/elastic/go-freelru v0.16.0/go.mod h1:bSdWT4M0lW79K8QbX6XY2heQYSCqD7THoYf82pT/H3I= 13 | github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 14 | github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 15 | github.com/kelindar/binary v1.0.19 h1:DNyQCtKjkLhBh9pnP49OWREddLB0Mho+1U/AOt/Qzxw= 16 | github.com/kelindar/binary v1.0.19/go.mod h1:/twdz8gRLNMffx0U4UOgqm1LywPs6nd9YK2TX52MDh8= 17 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 18 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 22 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 23 | github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg= 24 | github.com/tdewolff/parse/v2 v2.7.19/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= 25 | github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52 h1:gAQliwn+zJrkjAHVcBEYW/RFvd2St4yYimisvozAYlA= 26 | github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= 27 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 28 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 29 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 30 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 31 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 32 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 33 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 34 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 35 | go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= 36 | go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= 37 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 38 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 39 | golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= 40 | golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= 41 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 42 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 43 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 44 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 45 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 46 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 47 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 48 | golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= 49 | golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= 50 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 51 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 52 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 53 | golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= 54 | golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 55 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 56 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 57 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 61 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= 63 | golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 64 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 65 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 66 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 67 | golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 68 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 69 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 70 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 71 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 72 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 73 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 74 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 75 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 76 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 77 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 78 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 79 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 80 | -------------------------------------------------------------------------------- /utils/jsonesc.go: -------------------------------------------------------------------------------- 1 | // This file originally comes from valyala/fastjson 2 | // Copyright (c) 2018 Aliaksandr Valialkin 3 | 4 | package utils 5 | 6 | import ( 7 | "strconv" 8 | "strings" 9 | "unicode/utf16" 10 | "unicode/utf8" 11 | ) 12 | 13 | // Copied from encoding/json 14 | const hex = "0123456789abcdef" 15 | 16 | var safeSet = [utf8.RuneSelf]bool{ 17 | ' ': true, 18 | '!': true, 19 | '"': false, 20 | '#': true, 21 | '$': true, 22 | '%': true, 23 | '&': true, 24 | '\'': true, 25 | '(': true, 26 | ')': true, 27 | '*': true, 28 | '+': true, 29 | ',': true, 30 | '-': true, 31 | '.': true, 32 | '/': true, 33 | '0': true, 34 | '1': true, 35 | '2': true, 36 | '3': true, 37 | '4': true, 38 | '5': true, 39 | '6': true, 40 | '7': true, 41 | '8': true, 42 | '9': true, 43 | ':': true, 44 | ';': true, 45 | '<': true, 46 | '=': true, 47 | '>': true, 48 | '?': true, 49 | '@': true, 50 | 'A': true, 51 | 'B': true, 52 | 'C': true, 53 | 'D': true, 54 | 'E': true, 55 | 'F': true, 56 | 'G': true, 57 | 'H': true, 58 | 'I': true, 59 | 'J': true, 60 | 'K': true, 61 | 'L': true, 62 | 'M': true, 63 | 'N': true, 64 | 'O': true, 65 | 'P': true, 66 | 'Q': true, 67 | 'R': true, 68 | 'S': true, 69 | 'T': true, 70 | 'U': true, 71 | 'V': true, 72 | 'W': true, 73 | 'X': true, 74 | 'Y': true, 75 | 'Z': true, 76 | '[': true, 77 | '\\': false, 78 | ']': true, 79 | '^': true, 80 | '_': true, 81 | '`': true, 82 | 'a': true, 83 | 'b': true, 84 | 'c': true, 85 | 'd': true, 86 | 'e': true, 87 | 'f': true, 88 | 'g': true, 89 | 'h': true, 90 | 'i': true, 91 | 'j': true, 92 | 'k': true, 93 | 'l': true, 94 | 'm': true, 95 | 'n': true, 96 | 'o': true, 97 | 'p': true, 98 | 'q': true, 99 | 'r': true, 100 | 's': true, 101 | 't': true, 102 | 'u': true, 103 | 'v': true, 104 | 'w': true, 105 | 'x': true, 106 | 'y': true, 107 | 'z': true, 108 | '{': true, 109 | '|': true, 110 | '}': true, 111 | '~': true, 112 | '\u007f': true, 113 | } 114 | 115 | func UnescapeJSONString(s string) string { 116 | n := strings.IndexByte(s, '\\') 117 | if n < 0 { 118 | // Fast path - nothing to unescape. 119 | return s 120 | } 121 | 122 | // Slow path - unescape string. 123 | b := S2B(s) // It is safe to do, since s points to a byte slice in Parser.b. 124 | b = b[:n] 125 | s = s[n+1:] 126 | for len(s) > 0 { 127 | ch := s[0] 128 | s = s[1:] 129 | switch ch { 130 | case '"': 131 | b = append(b, '"') 132 | case '\\': 133 | b = append(b, '\\') 134 | case '/': 135 | b = append(b, '/') 136 | case 'b': 137 | b = append(b, '\b') 138 | case 'f': 139 | b = append(b, '\f') 140 | case 'n': 141 | b = append(b, '\n') 142 | case 'r': 143 | b = append(b, '\r') 144 | case 't': 145 | b = append(b, '\t') 146 | case 'u': 147 | if len(s) < 4 { 148 | // Too short escape sequence. Just store it unchanged. 149 | b = append(b, "\\u"...) 150 | break 151 | } 152 | xs := s[:4] 153 | x, err := strconv.ParseUint(xs, 16, 16) 154 | if err != nil { 155 | // Invalid escape sequence. Just store it unchanged. 156 | b = append(b, "\\u"...) 157 | break 158 | } 159 | s = s[4:] 160 | if !utf16.IsSurrogate(rune(x)) { 161 | b = append(b, string(rune(x))...) 162 | break 163 | } 164 | 165 | // Surrogate. 166 | // See https://en.wikipedia.org/wiki/Universal_Character_Set_characters#Surrogates 167 | if len(s) < 6 || s[0] != '\\' || s[1] != 'u' { 168 | b = append(b, "\\u"...) 169 | b = append(b, xs...) 170 | break 171 | } 172 | x1, err := strconv.ParseUint(s[2:6], 16, 16) 173 | if err != nil { 174 | b = append(b, "\\u"...) 175 | b = append(b, xs...) 176 | break 177 | } 178 | r := utf16.DecodeRune(rune(x), rune(x1)) 179 | b = append(b, string(r)...) 180 | s = s[6:] 181 | default: 182 | // Unknown escape sequence. Just store it unchanged. 183 | b = append(b, '\\', ch) 184 | } 185 | n = strings.IndexByte(s, '\\') 186 | if n < 0 { 187 | b = append(b, s...) 188 | break 189 | } 190 | b = append(b, s[:n]...) 191 | s = s[n+1:] 192 | } 193 | return B2S(b) 194 | } 195 | 196 | func EscapeJSONString(src string) string { 197 | sb := strings.Builder{} 198 | sb.Grow(len(src)) 199 | sb.WriteByte('"') 200 | start := 0 201 | for i := 0; i < len(src); { 202 | if b := src[i]; b < utf8.RuneSelf { 203 | if safeSet[b] { 204 | i++ 205 | continue 206 | } 207 | sb.WriteString(src[start:i]) 208 | switch b { 209 | case '\\', '"': 210 | sb.WriteByte('\\') 211 | sb.WriteByte(b) 212 | case '\b': 213 | sb.WriteByte('\\') 214 | sb.WriteByte('b') 215 | case '\f': 216 | sb.WriteByte('\\') 217 | sb.WriteByte('f') 218 | case '\n': 219 | sb.WriteByte('\\') 220 | sb.WriteByte('n') 221 | case '\r': 222 | sb.WriteByte('\\') 223 | sb.WriteByte('r') 224 | case '\t': 225 | sb.WriteByte('\\') 226 | sb.WriteByte('t') 227 | default: 228 | // This encodes bytes < 0x20 except for \b, \f, \n, \r and \t. 229 | // If escapeHTML is set, it also escapes <, >, and & 230 | // because they can lead to security holes when 231 | // user-controlled strings are rendered into JSON 232 | // and served to some browsers. 233 | sb.WriteByte('\\') 234 | sb.WriteByte('u') 235 | sb.WriteByte('0') 236 | sb.WriteByte('0') 237 | sb.WriteByte(hex[b>>4]) 238 | sb.WriteByte(hex[b&0xF]) 239 | } 240 | i++ 241 | start = i 242 | continue 243 | } 244 | // TODO(https://go.dev/issue/56948): Use generic utf8 functionality. 245 | // For now, cast only a small portion of byte slices to a string 246 | // so that it can be stack allocated. This slows down []byte slightly 247 | // due to the extra copy, but keeps string performance roughly the same. 248 | n := len(src) - i 249 | if n > utf8.UTFMax { 250 | n = utf8.UTFMax 251 | } 252 | c, size := utf8.DecodeRuneInString(string(src[i : i+n])) 253 | if c == utf8.RuneError && size == 1 { 254 | sb.WriteString(src[start:i]) 255 | sb.WriteString(`\ufffd`) 256 | i += size 257 | start = i 258 | continue 259 | } 260 | // U+2028 is LINE SEPARATOR. 261 | // U+2029 is PARAGRAPH SEPARATOR. 262 | // They are both technically valid characters in JSON strings, 263 | // but don't work in JSONP, which has to be evaluated as JavaScript, 264 | // and can lead to security holes there. It is valid JSON to 265 | // escape them, so we do so unconditionally. 266 | // See https://en.wikipedia.org/wiki/JSON#Safety. 267 | if c == '\u2028' || c == '\u2029' { 268 | sb.WriteString(src[start:i]) 269 | sb.WriteString(`\u202`) 270 | sb.WriteByte(hex[c&0xF]) 271 | i += size 272 | start = i 273 | continue 274 | } 275 | i += size 276 | } 277 | sb.WriteString(src[start:]) 278 | sb.WriteByte('"') 279 | return sb.String() 280 | } 281 | -------------------------------------------------------------------------------- /handlers/grid.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "errors" 5 | "image" 6 | "image/jpeg" 7 | scraper "instafix/handlers/scraper" 8 | "io" 9 | "log/slog" 10 | "math" 11 | "net" 12 | "net/http" 13 | "os" 14 | "path/filepath" 15 | "strings" 16 | "sync" 17 | "time" 18 | 19 | "github.com/RyanCarrier/dijkstra/v2" 20 | "github.com/go-chi/chi/v5" 21 | "golang.org/x/image/draw" 22 | "golang.org/x/sync/singleflight" 23 | ) 24 | 25 | var timeout = 60 * time.Second 26 | var transport = &http.Transport{ 27 | Proxy: nil, // Skip any proxy 28 | DialContext: (&net.Dialer{ 29 | Timeout: 30 * time.Second, 30 | KeepAlive: 30 * time.Second, 31 | }).DialContext, 32 | ForceAttemptHTTP2: true, 33 | MaxIdleConns: 100, 34 | IdleConnTimeout: 90 * time.Second, 35 | TLSHandshakeTimeout: 10 * time.Second, 36 | ExpectContinueTimeout: 1 * time.Second, 37 | } 38 | var sflightGrid singleflight.Group 39 | 40 | // getHeight returns the height of the rows, imagesWH [w,h] 41 | func getHeight(imagesWH [][]float64, canvasWidth int) float64 { 42 | var height float64 43 | for _, image := range imagesWH { 44 | height += image[0] / image[1] 45 | } 46 | return float64(canvasWidth) / height 47 | } 48 | 49 | // costFn returns the cost of the row graph thingy 50 | func costFn(imagesWH [][]float64, i, j, canvasWidth, maxRowHeight int) float64 { 51 | slices := imagesWH[i:j] 52 | rowHeight := getHeight(slices, canvasWidth) 53 | return math.Pow(float64(maxRowHeight)-rowHeight, 2) 54 | } 55 | 56 | func createGraph(imagesWH [][]float64, start, canvasWidth int) map[int]uint64 { 57 | results := make(map[int]uint64, len(imagesWH)) 58 | results[start] = 0 59 | for i := start + 1; i < len(imagesWH); i++ { 60 | // Max 3 images for every row 61 | if i-start > 3 { 62 | break 63 | } 64 | results[i] = uint64(costFn(imagesWH, start, i, canvasWidth, 1000)) 65 | } 66 | return results 67 | } 68 | 69 | func avg(n []float64) float64 { 70 | var sum float64 71 | for _, v := range n { 72 | sum += v 73 | } 74 | return sum / float64(len(n)) 75 | } 76 | 77 | // GenerateGrid generates a grid of images 78 | // based on https://blog.vjeux.com/2014/image/google-plus-layout-find-best-breaks.html 79 | func GenerateGrid(images []image.Image) (image.Image, error) { 80 | var imagesWH [][]float64 81 | images = append(images, image.Rect(0, 0, 0, 0)) // Needed as for some reason the last image is not added 82 | for _, image := range images { 83 | imagesWH = append(imagesWH, []float64{float64(image.Bounds().Dx()), float64(image.Bounds().Dy())}) 84 | } 85 | 86 | // Calculate canvas width by taking the average of width of all images 87 | // There should be a better way to do this 88 | var allWidth []float64 89 | for _, image := range imagesWH { 90 | allWidth = append(allWidth, image[0]) 91 | } 92 | canvasWidth := int(avg(allWidth) * 1.5) 93 | 94 | graph := dijkstra.NewGraph() 95 | for i := range images { 96 | graph.AddVertexAndArcs(i, createGraph(imagesWH, i, canvasWidth)) 97 | } 98 | 99 | // Get the shortest path from 0 to len(images)-1 100 | best, err := graph.Shortest(0, len(images)-1) 101 | if err != nil { 102 | return nil, err 103 | } 104 | path := best.Path 105 | 106 | canvasHeight := 0 107 | var heightRows []int 108 | // Calculate height of each row and canvas height 109 | for i := 1; i < len(path); i++ { 110 | if len(imagesWH) < path[i-1] { 111 | return nil, errors.New("imagesWH is not long enough") 112 | } 113 | rowWH := imagesWH[path[i-1]:path[i]] 114 | 115 | rowHeight := int(getHeight(rowWH, canvasWidth)) 116 | heightRows = append(heightRows, rowHeight) 117 | canvasHeight += rowHeight 118 | } 119 | 120 | canvas := image.NewRGBA(image.Rect(0, 0, canvasWidth, canvasHeight)) 121 | 122 | oldRowHeight := 0 123 | for i := 1; i < len(path); i++ { 124 | inRow := images[path[i-1]:path[i]] 125 | oldImWidth := 0 126 | if len(heightRows) < i { 127 | return nil, errors.New("heightRows is not long enough") 128 | } 129 | heightRow := heightRows[i-1] 130 | for _, imageOne := range inRow { 131 | newWidth := float64(heightRow) * float64(imageOne.Bounds().Dx()) / float64(imageOne.Bounds().Dy()) 132 | draw.ApproxBiLinear.Scale(canvas, image.Rect(oldImWidth, oldRowHeight, oldImWidth+int(newWidth), oldRowHeight+int(heightRow)), imageOne, imageOne.Bounds(), draw.Src, nil) 133 | oldImWidth += int(newWidth) 134 | } 135 | oldRowHeight += heightRow 136 | } 137 | return canvas, nil 138 | } 139 | 140 | func Grid(w http.ResponseWriter, r *http.Request) { 141 | postID := chi.URLParam(r, "postID") 142 | gridFname := filepath.Join("static", postID+".jpeg") 143 | 144 | // If already exists, return from cache 145 | if _, ok := scraper.LRU.Get(gridFname); ok { 146 | f, err := os.Open(gridFname) 147 | if err != nil && !errors.Is(err, os.ErrNotExist) { 148 | http.Error(w, err.Error(), http.StatusInternalServerError) 149 | return 150 | } else if err == nil { 151 | defer f.Close() 152 | w.Header().Set("Content-Type", "image/jpeg") 153 | io.Copy(w, f) 154 | return 155 | } 156 | } 157 | 158 | item, err := scraper.GetData(postID) 159 | if err != nil { 160 | http.Error(w, err.Error(), http.StatusInternalServerError) 161 | return 162 | } 163 | 164 | // Filter media only include image 165 | var mediaURLs []string 166 | for _, media := range item.Medias { 167 | if !strings.Contains(media.TypeName, "Image") { 168 | continue 169 | } 170 | mediaURLs = append(mediaURLs, media.URL) 171 | } 172 | 173 | if len(item.Medias) == 1 || len(mediaURLs) == 1 { 174 | http.Redirect(w, r, "/images/"+postID+"/1", http.StatusFound) 175 | return 176 | } 177 | 178 | _, err, _ = sflightGrid.Do(postID, func() (interface{}, error) { 179 | var wg sync.WaitGroup 180 | images := make([]image.Image, len(mediaURLs)) 181 | for i, mediaURL := range mediaURLs { 182 | wg.Add(1) 183 | 184 | go func(i int, url string) { 185 | defer wg.Done() 186 | client := http.Client{Transport: transport, Timeout: timeout} 187 | req, err := http.NewRequest(http.MethodGet, url, http.NoBody) 188 | if err != nil { 189 | return 190 | } 191 | 192 | // Make request client.Get 193 | res, err := client.Do(req) 194 | if err != nil { 195 | slog.Error("Failed to get image", "postID", postID, "err", err) 196 | return 197 | } 198 | defer res.Body.Close() 199 | 200 | images[i], err = jpeg.Decode(res.Body) 201 | if err != nil { 202 | slog.Error("Failed to decode image", "postID", postID, "err", err) 203 | return 204 | } 205 | }(i, mediaURL) 206 | } 207 | wg.Wait() 208 | 209 | // Create grid Images 210 | grid, err := GenerateGrid(images) 211 | if err != nil { 212 | return false, err 213 | } 214 | 215 | // Write grid to static folder 216 | f, err := os.Create(gridFname) 217 | if err != nil { 218 | return false, err 219 | } 220 | defer f.Close() 221 | 222 | if err := jpeg.Encode(f, grid, &jpeg.Options{Quality: 80}); err != nil { 223 | return false, err 224 | } 225 | scraper.LRU.Add(gridFname, true) 226 | return true, nil 227 | }) 228 | 229 | if err != nil { 230 | http.Error(w, err.Error(), http.StatusInternalServerError) 231 | return 232 | } 233 | 234 | f, err := os.Open(gridFname) 235 | if err != nil { 236 | http.Error(w, err.Error(), http.StatusInternalServerError) 237 | return 238 | } 239 | defer f.Close() 240 | w.Header().Set("Content-Type", "image/jpeg") 241 | io.Copy(w, f) 242 | } 243 | -------------------------------------------------------------------------------- /handlers/scraper/data.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "errors" 7 | "instafix/utils" 8 | "io" 9 | "log/slog" 10 | "net/http" 11 | "net/url" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/PuerkitoBio/goquery" 17 | "github.com/kelindar/binary" 18 | "github.com/klauspost/compress/gzhttp" 19 | "github.com/klauspost/compress/zstd" 20 | "github.com/tdewolff/parse/v2" 21 | "github.com/tdewolff/parse/v2/js" 22 | "github.com/tidwall/gjson" 23 | bolt "go.etcd.io/bbolt" 24 | "golang.org/x/net/html" 25 | "golang.org/x/sync/singleflight" 26 | ) 27 | 28 | var ( 29 | RemoteScraperAddr string 30 | ErrNotFound = errors.New("post not found") 31 | timeout = 5 * time.Second 32 | transport http.RoundTripper 33 | transportNoProxy *http.Transport 34 | sflightScraper singleflight.Group 35 | remoteZSTDReader *zstd.Decoder 36 | ) 37 | 38 | //go:embed dictionary.bin 39 | var zstdDict []byte 40 | 41 | type Media struct { 42 | TypeName string 43 | URL string 44 | } 45 | 46 | type InstaData struct { 47 | PostID string 48 | Username string 49 | Caption string 50 | Medias []Media 51 | } 52 | 53 | func init() { 54 | var err error 55 | transport = gzhttp.Transport(http.DefaultTransport, gzhttp.TransportAlwaysDecompress(true)) 56 | transportNoProxy = http.DefaultTransport.(*http.Transport).Clone() 57 | transportNoProxy.Proxy = nil // Skip any proxy 58 | 59 | remoteZSTDReader, err = zstd.NewReader(nil, zstd.WithDecoderLowmem(true), zstd.WithDecoderDicts(zstdDict)) 60 | if err != nil { 61 | panic(err) 62 | } 63 | } 64 | 65 | func GetData(postID string) (*InstaData, error) { 66 | if len(postID) == 0 || (postID[0] != 'C' && postID[0] != 'D' && postID[0] != 'B') { 67 | return nil, errors.New("postID is not a valid Instagram post ID") 68 | } 69 | 70 | i := &InstaData{PostID: postID} 71 | err := DB.View(func(tx *bolt.Tx) error { 72 | b := tx.Bucket([]byte("data")) 73 | if b == nil { 74 | return nil 75 | } 76 | v := b.Get([]byte(postID)) 77 | if v == nil { 78 | return nil 79 | } 80 | err := binary.Unmarshal(v, i) 81 | if err != nil { 82 | return err 83 | } 84 | slog.Debug("Data parsed from cache", "postID", postID) 85 | return nil 86 | }) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | // Successfully parsed from cache 92 | if len(i.Medias) != 0 { 93 | return i, nil 94 | } 95 | 96 | ret, err, _ := sflightScraper.Do(postID, func() (interface{}, error) { 97 | item := new(InstaData) 98 | item.PostID = postID 99 | if err := item.ScrapeData(); err != nil { 100 | slog.Error("Failed to scrape data from Instagram", "postID", item.PostID, "err", err) 101 | return nil, err 102 | } 103 | 104 | // Replace all media urls cdn to scontent.cdninstagram.com 105 | for n, media := range item.Medias { 106 | u, err := url.Parse(media.URL) 107 | if err != nil { 108 | slog.Error("Failed to parse media URL", "postID", item.PostID, "err", err) 109 | return false, err 110 | } 111 | u.Host = "scontent.cdninstagram.com" 112 | item.Medias[n].URL = u.String() 113 | } 114 | 115 | bb, err := binary.Marshal(item) 116 | if err != nil { 117 | slog.Error("Failed to marshal data", "postID", item.PostID, "err", err) 118 | return false, err 119 | } 120 | 121 | err = DB.Batch(func(tx *bolt.Tx) error { 122 | dataBucket := tx.Bucket([]byte("data")) 123 | if dataBucket == nil { 124 | return nil 125 | } 126 | dataBucket.Put(utils.S2B(item.PostID), bb) 127 | 128 | ttlBucket := tx.Bucket([]byte("ttl")) 129 | if ttlBucket == nil { 130 | return nil 131 | } 132 | expTime := strconv.FormatInt(time.Now().Add(24*time.Hour).UnixNano(), 10) 133 | ttlBucket.Put(utils.S2B(expTime), utils.S2B(item.PostID)) 134 | return nil 135 | }) 136 | if err != nil { 137 | slog.Error("Failed to save data to cache", "postID", item.PostID, "err", err) 138 | return false, err 139 | } 140 | return item, nil 141 | }) 142 | if err != nil { 143 | return nil, err 144 | } 145 | return ret.(*InstaData), nil 146 | } 147 | 148 | func (i *InstaData) ScrapeData() error { 149 | // Scrape from remote scraper if available 150 | if len(RemoteScraperAddr) > 0 { 151 | remoteClient := http.Client{Transport: transportNoProxy, Timeout: timeout} 152 | req, err := http.NewRequest("GET", RemoteScraperAddr+"/scrape/"+i.PostID, nil) 153 | if err != nil { 154 | return err 155 | } 156 | req.Header.Set("Accept-Encoding", "zstd.dict") 157 | res, err := remoteClient.Do(req) 158 | if err == nil && res != nil { 159 | defer res.Body.Close() 160 | remoteData, err := io.ReadAll(res.Body) 161 | if err == nil && res.StatusCode == 200 { 162 | remoteDecomp, err := remoteZSTDReader.DecodeAll(remoteData, nil) 163 | if err != nil { 164 | return err 165 | } 166 | if err := binary.Unmarshal(remoteDecomp, i); err == nil { 167 | if len(i.Username) > 0 { 168 | slog.Info("Data parsed from remote scraper", "postID", i.PostID) 169 | return nil 170 | } 171 | } 172 | } 173 | slog.Error("Failed to scrape data from remote scraper", "postID", i.PostID, "status", res.StatusCode, "err", err) 174 | } 175 | if err != nil { 176 | slog.Error("Failed when trying to scrape data from remote scraper", "postID", i.PostID, "err", err) 177 | } 178 | } 179 | 180 | client := http.Client{Transport: transport, Timeout: timeout} 181 | req, err := http.NewRequest("GET", "https://www.instagram.com/p/"+i.PostID+"/embed/captioned/", nil) 182 | if err != nil { 183 | return err 184 | } 185 | 186 | var body []byte 187 | for retries := 0; retries < 3; retries++ { 188 | err := func() error { 189 | res, err := client.Do(req) 190 | if err != nil { 191 | return err 192 | } 193 | defer res.Body.Close() 194 | if res.StatusCode != 200 { 195 | return errors.New("status code is not 200") 196 | } 197 | 198 | body, err = io.ReadAll(res.Body) 199 | if err != nil { 200 | return err 201 | } 202 | return nil 203 | }() 204 | if err == nil { 205 | break 206 | } 207 | } 208 | 209 | var embedData gjson.Result 210 | var timeSliceData gjson.Result 211 | if len(body) > 0 { 212 | var scriptText []byte 213 | 214 | // TimeSliceImpl (very fragile) 215 | for _, line := range bytes.Split(body, []byte("\n")) { 216 | if bytes.Contains(line, []byte("shortcode_media")) { 217 | scriptText = line 218 | break 219 | } 220 | } 221 | 222 | if len(scriptText) > 0 { 223 | // Remove