├── .nvmrc ├── .commitlog.release ├── .dockerignore ├── www ├── .env.example ├── .gitignore ├── .editorconfig ├── public │ ├── favicon.png │ └── opengraph-image.jpg ├── hooks │ └── define-env.lua ├── readme.md ├── tailwind.config.js ├── Makefile ├── pages │ ├── index.md │ └── _layout.html └── assets │ └── main.css ├── .gitignore ├── templates ├── error.sh └── install.sh ├── deploy └── docker-compose │ ├── Caddyfile │ ├── docker-compose.goblin.yml │ ├── docker-compose.yml │ └── README.md ├── .env.example ├── scripts ├── build.sh ├── node.sh ├── deploy.sh └── prepare-ubuntu.sh ├── Dockerfile ├── storage ├── storage.go └── minio.go ├── .vscode └── launch.json ├── .github └── workflows │ ├── cleanup.yml │ ├── go.yml │ └── docker-pub.yml ├── LICENSE ├── docker-compose.yml ├── go.mod ├── nginx.conf ├── go.sum ├── resolver ├── resolver_test.go └── resolver.go ├── readme.md └── cmd └── goblin-api └── main.go /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.14.2 2 | -------------------------------------------------------------------------------- /.commitlog.release: -------------------------------------------------------------------------------- 1 | v0.5.5 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env* -------------------------------------------------------------------------------- /www/.env.example: -------------------------------------------------------------------------------- 1 | GOBLIN_ORIGIN_URL= -------------------------------------------------------------------------------- /www/.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /node_modules/ 3 | dist 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lab 2 | .env 3 | *.patch 4 | .DS_Store 5 | 6 | /static 7 | -------------------------------------------------------------------------------- /www/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | -------------------------------------------------------------------------------- /templates/error.sh: -------------------------------------------------------------------------------- 1 | echo 2 | printf " \033[38;5;125mError:\033[0;00m {{.}}\n" 3 | echo 4 | exit 1 -------------------------------------------------------------------------------- /www/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barelyhuman/goblin/HEAD/www/public/favicon.png -------------------------------------------------------------------------------- /www/public/opengraph-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barelyhuman/goblin/HEAD/www/public/opengraph-image.jpg -------------------------------------------------------------------------------- /deploy/docker-compose/Caddyfile: -------------------------------------------------------------------------------- 1 | localhost:80, http://goblin.run, https://goblin.run { 2 | reverse_proxy goblin:3000 3 | } -------------------------------------------------------------------------------- /deploy/docker-compose/docker-compose.goblin.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | goblin: 5 | image: ghcr.io/barelyhuman/goblin:nightly 6 | platform: "linux/amd64" 7 | networks: 8 | - caddy_network 9 | ports: 10 | - 3000:3000 11 | 12 | networks: 13 | caddy_network: 14 | external: true -------------------------------------------------------------------------------- /www/hooks/define-env.lua: -------------------------------------------------------------------------------- 1 | local json = require("json") 2 | local alvu = require("alvu") 3 | 4 | function Writer(filedata) 5 | local envData = {} 6 | 7 | envData["GOBLIN_ORIGIN_URL"] = alvu.get_env(".env","GOBLIN_ORIGIN_URL") 8 | 9 | return json.encode({ 10 | data = { 11 | env = envData 12 | }, 13 | }) 14 | end -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | STORAGE_ENABLED= 2 | STORAGE_CLIENT_ID= 3 | STORAGE_CLIENT_SECRET= 4 | STORAGE_ENDPOINT= 5 | STORAGE_BUCKET= 6 | STORAGE_BUCKET_PREFIX= 7 | ORIGIN_URL="" # eg: "http://localhost" depending on your nginx config changes 8 | GITHUB_TOKEN="" # required for github version resolutions 9 | 10 | # uncomment the below to enable cache clearing based on the passed duration 11 | # CLEAR_CACHE_TIME="10m" 12 | -------------------------------------------------------------------------------- /www/readme.md: -------------------------------------------------------------------------------- 1 | # goblin site 2 | 3 | > Golang binaries in a curl, built by goblins 4 | 5 | ## Contributing 6 | 7 | This site is built using [alvu](https://barelyhuman.github.io/alvu/00-readme). 8 | 9 | `make install` — Download dependencies 10 | 11 | `make build` - Build files with alvu and tailwindcss 12 | 13 | `make watch` - Watch files and build on change (served at [localhost:3000](http://localhost:3000)) 14 | -------------------------------------------------------------------------------- /deploy/docker-compose/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | caddy: 5 | image: caddy:2 6 | restart: unless-stopped 7 | ports: 8 | - '80:80' 9 | - '443:443' 10 | networks: 11 | - caddy_network 12 | volumes: 13 | - caddy:/data 14 | - ./Caddyfile:/etc/caddy/Caddyfile 15 | 16 | volumes: 17 | caddy: 18 | 19 | networks: 20 | caddy_network: 21 | external: true -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euxo pipefail 4 | 5 | # build the web project 6 | cd www 7 | # if using darwin arm64, uncomment the next line 8 | # make install 9 | make installLinux 10 | make build 11 | cd .. 12 | 13 | rm -rf ./static 14 | ln -sf ./www/dist ./static 15 | 16 | # build the go server 17 | go build -o ./goblin-api ./cmd/goblin-api 18 | pm2 stop goblin-api 19 | pm2 start goblin-api -- --env .env 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 2 | WORKDIR /app 3 | 4 | COPY go.* ./ 5 | RUN go mod download 6 | 7 | ENV GOBLIN_ORIGIN_URL="http://goblin.run" 8 | ENV ORIGIN_URL="http://goblin.run" 9 | 10 | COPY . ./ 11 | RUN cd www \ 12 | ;make installLinux \ 13 | ;make build \ 14 | ;cd .. \ 15 | ;rm -rf ./static \ 16 | ;ln -sf ./www/dist ./static 17 | 18 | RUN go build -o ./goblin-api ./cmd/goblin-api 19 | 20 | EXPOSE 3000 21 | 22 | CMD [ "./goblin-api" ] 23 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "bytes" 5 | "time" 6 | ) 7 | 8 | type MicroObject struct { 9 | LastModified time.Time 10 | Key string 11 | } 12 | 13 | type Storage interface { 14 | Connect() error 15 | HasObject(objectName string) bool 16 | Upload(objectName string, data bytes.Buffer) error 17 | GetSignedURL(objectName string) (string, error) 18 | ListObjects() []MicroObject 19 | RemoveObject(objectName string) (bool, error) 20 | } 21 | -------------------------------------------------------------------------------- /scripts/node.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | wget -q https://nodejs.org/dist/latest-v14.x/node-v14.21.1-linux-x64.tar.xz 5 | tar xf node-v14.21.1-linux-x64.tar.xz 6 | rm -f *.tar *.xz 7 | 8 | export PATH=$PATH:"$(pwd)/node-v14.21.1-linux-x64/bin" 9 | 10 | cd www 11 | 12 | npm install -g pnpm 13 | pnpm install 14 | pnpm build 15 | 16 | cd .. 17 | 18 | cp -r ./www/build ./static 19 | 20 | go build -o ./server cmd/goblin-api/main.go 21 | 22 | rm -rf node-v12.22.12-linux-x64 23 | 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Run Goblin Server", 9 | "type": "go", 10 | "request": "launch", 11 | "program": "${workspaceFolder}/cmd/goblin-api", 12 | "cwd":"${workspaceFolder}" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/cleanup.yml: -------------------------------------------------------------------------------- 1 | name: Cleaner 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 12,25 * *" 7 | 8 | jobs: 9 | cleanup: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | packages: write 14 | attestations: write 15 | id-token: write 16 | steps: 17 | - uses: actions/delete-package-versions@v5 18 | with: 19 | package-name: goblin 20 | package-type: container 21 | min-versions-to-keep: 10 22 | ignore-versions: '(v?\d+\.\d+\.\d+|latest|(nightly[-]\w+))' 23 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: ["dev"] 9 | pull_request: 10 | branches: ["dev"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | version: ["1.21", "1.22"] 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v3 23 | with: 24 | go-version: ${{ matrix.version }} 25 | 26 | - name: Setup modules 27 | run: go mod tidy 28 | 29 | - name: Test 30 | run: go test -v ./resolver 31 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker build . --platform=linux/amd64 -t goblin:latest 4 | docker save goblin:latest | gzip > goblin-latest.tar.gz 5 | 6 | PREPARE_COMMANDS=""" 7 | set -euxo pipefail 8 | mkdir -p ~/goblin 9 | """ 10 | 11 | ssh root@143.110.182.104 "/bin/bash -c '$PREPARE_COMMANDS'" 12 | 13 | rsync --progress goblin-latest.tar.gz root@143.110.182.104:~/goblin/ 14 | 15 | COMMANDS=""" 16 | set -euxo pipefail 17 | cd ~/goblin 18 | docker image load < goblin-latest.tar.gz 19 | docker stop \$(docker container ls --all --filter=ancestor="goblin:latest" --format "{{.ID}}") 20 | docker run -d -e 'ORIGIN_URL=http://count.barelyhuman.dev' -p '3000:3000' -v='./:/usr/bin/app' goblin:latest 21 | """ 22 | 23 | ssh root@143.110.182.104 "/bin/bash -c '$COMMANDS'" -------------------------------------------------------------------------------- /scripts/prepare-ubuntu.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euxo pipefail 4 | 5 | # install caddy 6 | sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https 7 | curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg 8 | curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list 9 | sudo apt update -y 10 | sudo apt install caddy -y 11 | 12 | 13 | # setup node and yarn 14 | wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash 15 | . ~/.nvm/nvm.sh 16 | nvm install 17 | nvm use 18 | npm i -g yarn 19 | npm i -g pm2 20 | 21 | # install golang and snap 22 | sudo apt install snapd 23 | sudo snap install go --channel=1.18/stable --classic 24 | 25 | 26 | -------------------------------------------------------------------------------- /www/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./pages/**/*.{html,md}"], 4 | theme: { 5 | extend: { 6 | screens: { 7 | xs: "420px", 8 | }, 9 | spacing: { 10 | "page-top": "var(--page-top)", 11 | "page-gutter": "var(--page-gutter)", 12 | "header-height": "var(--header-height)", 13 | }, 14 | maxWidth: { 15 | content: "var(--content-width)", 16 | }, 17 | minHeight: { 18 | content: "var(--content-height)", 19 | }, 20 | colors: { 21 | base: "rgb(var(--color-base) / )", 22 | surface: "rgb(var(--color-surface) / )", 23 | overlay: "rgb(var(--color-overlay) / )", 24 | subtle: "rgb(var(--color-subtle) / )", 25 | text: "rgb(var(--color-text) / )", 26 | primary: "rgb(var(--color-primary) / )", 27 | secondary: "rgb(var(--color-secondary) / )", 28 | }, 29 | borderColor: { 30 | DEFAULT: "rgb(var(--color-overlay))", 31 | }, 32 | ringColor: { 33 | DEFAULT: "rgb(var(--color-primary) / 0.2)", 34 | }, 35 | }, 36 | }, 37 | plugins: [], 38 | }; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-Present Reaper (https://reaper.im) 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 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # FIXME: 2 | # DISABLED STORAGE FOR THE INITIAL VERSION SINCE BUILDING IS LIMITED TO A FEW SECONDS 3 | 4 | services: 5 | api: 6 | build: "." 7 | container_name: goblin_api 8 | env_file: .env 9 | expose: 10 | - "3000:3000" 11 | environment: 12 | MINIO_URL: goblin_minio:9000 13 | restart: always 14 | ports: 15 | - "3000:3000" 16 | volumes: 17 | - "./:/usr/src/app" 18 | profiles: [app] 19 | 20 | minio: 21 | image: "minio/minio:RELEASE.2024-05-01T01-11-10Z" 22 | container_name: goblin_minio 23 | command: 'server --console-address ":9001" /home/files' 24 | healthcheck: 25 | test: ["CMD", "curl", "-I", "http://127.0.0.1:9000/minio/health/live"] 26 | interval: 30s 27 | timeout: 20s 28 | retries: 3 29 | env_file: 30 | - .env 31 | expose: 32 | - "9000:9000" 33 | - "9001:9001" 34 | volumes: 35 | - "goblin:/data" 36 | profiles: [storage] 37 | 38 | nginx: 39 | container_name: goblin_nginx 40 | depends_on: 41 | - api 42 | hostname: nginx 43 | image: "nginx:1.19.2-alpine" 44 | ports: 45 | - "80:80" 46 | - "9000:9000" 47 | volumes: 48 | - "./nginx.conf:/etc/nginx/nginx.conf:ro" 49 | profiles: [app] 50 | 51 | volumes: 52 | goblin: -------------------------------------------------------------------------------- /deploy/docker-compose/README.md: -------------------------------------------------------------------------------- 1 | # docker-compose-setup 2 | 3 | The example setup is for running an instance of goblin on your personal server 4 | using Docker + Docker Compose and Caddy. 5 | 6 | The Setup process involves a few simple steps. 7 | 8 | 1. Create a Caddy Network bridge 9 | 10 | ```sh 11 | ; docker network create -d bridge caddy_network 12 | ``` 13 | 14 | > [!NOTE]: Replace `caddy_network` with something else if you already have a 15 | > similar name for a different cluster and would like to avoid merging the 16 | > existing caddy services with this one. If merging them is not an issue, please 17 | > continue with the exiting name. 18 | 19 | 2. Modify the `Caddyfile` to make sure it has the right domains, the example has 20 | `goblin.run`. 21 | 3. Run the Caddy Service, you only need to run this once and just verify it's 22 | running by using `docker compose ps` 23 | 24 | ```sh 25 | docker compose -f ./docker-compose.yml up -d 26 | ``` 27 | 28 | 4. Now you can run the `goblin` service using the following 29 | 30 | ```sh 31 | docker compose -f ./docker-compose.goblin.yml up -d 32 | ``` 33 | 34 | You can verify the status of the running containers by running the following 35 | 36 | ```sh 37 | docker compose ps 38 | ``` 39 | 40 | You should have the services `caddy-1` and `goblin-1` running and will manage 41 | the domain mapping for you. 42 | -------------------------------------------------------------------------------- /www/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build install clean watchStyles watchPages watch 2 | 3 | build: 4 | ./bin/alvu 5 | ./bin/tailwindcss -i ./assets/main.css -o ./dist/style.css --minify 6 | 7 | install: 8 | mkdir -p ./bin 9 | # Downloading alvu 10 | # https://github.com/barelyhuman/alvu 11 | curl -sf https://goblin.run/github.com/barelyhuman/alvu | PREFIX=./bin sh 12 | chmod +x ./bin/alvu 13 | # Downloading Tailwind CSS CLI for macOS arm64 14 | # https://github.com/tailwindlabs/tailwindcss/releases/latest 15 | curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-macos-arm64 16 | chmod +x ./tailwindcss-macos-arm64 17 | mv tailwindcss-macos-arm64 ./bin/tailwindcss 18 | 19 | installLinux: 20 | mkdir -p ./bin 21 | # Downloading alvu 22 | # https://github.com/barelyhuman/alvu 23 | curl -sf https://goblin.run/github.com/barelyhuman/alvu | PREFIX=./bin sh 24 | chmod +x ./bin/alvu 25 | # Downloading Tailwind CSS CLI for linux amd64 26 | # https://github.com/tailwindlabs/tailwindcss/releases/latest 27 | curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-linux-x64 28 | chmod +x ./tailwindcss-linux-x64 29 | mv tailwindcss-linux-x64 ./bin/tailwindcss 30 | 31 | 32 | clean: 33 | rm ./bin/alvu ./bin/tailwindcss 34 | 35 | watchStyles: 36 | ./bin/tailwindcss -i ./assets/main.css -o ./dist/style.css --watch 37 | 38 | watchPages: 39 | alvu -serve 40 | 41 | watch: 42 | ${MAKE} -j4 watchPages watchStyles 43 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/barelyhuman/goblin 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/Masterminds/semver v1.5.0 9 | github.com/barelyhuman/go v0.2.2 10 | github.com/google/go-github/v53 v53.2.0 11 | github.com/joho/godotenv v1.5.1 12 | github.com/minio/minio-go/v7 v7.0.77 13 | github.com/tj/go-semver v1.0.0 14 | go.uber.org/ratelimit v0.3.1 15 | golang.org/x/oauth2 v0.27.0 16 | ) 17 | 18 | // Sec patches 19 | require golang.org/x/crypto v0.37.0 // indirect 20 | 21 | require ( 22 | github.com/ProtonMail/go-crypto v1.1.3 // indirect 23 | github.com/benbjohnson/clock v1.3.0 // indirect 24 | github.com/cloudflare/circl v1.3.7 // indirect 25 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 26 | github.com/dustin/go-humanize v1.0.1 // indirect 27 | github.com/go-ini/ini v1.67.0 // indirect 28 | github.com/goccy/go-json v0.10.3 // indirect 29 | github.com/google/go-cmp v0.6.0 // indirect 30 | github.com/google/go-querystring v1.1.0 // indirect 31 | github.com/google/uuid v1.6.0 // indirect 32 | github.com/klauspost/compress v1.17.11 // indirect 33 | github.com/klauspost/cpuid/v2 v2.2.8 // indirect 34 | github.com/minio/md5-simd v1.1.2 // indirect 35 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 36 | github.com/rs/xid v1.6.0 // indirect 37 | github.com/stretchr/testify v1.10.0 // indirect 38 | golang.org/x/net v0.36.0 // indirect 39 | golang.org/x/sys v0.32.0 // indirect 40 | golang.org/x/text v0.24.0 // indirect 41 | ) 42 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | 4 | error_log /var/log/nginx/error.log warn; 5 | pid /var/run/nginx.pid; 6 | 7 | events { 8 | worker_connections 4096; 9 | } 10 | 11 | http { 12 | include /etc/nginx/mime.types; 13 | default_type application/octet-stream; 14 | 15 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 16 | '$status $body_bytes_sent "$http_referer" ' 17 | '"$http_user_agent" "$http_x_forwarded_for"'; 18 | 19 | access_log /var/log/nginx/access.log main; 20 | sendfile on; 21 | keepalive_timeout 65; 22 | 23 | # Builds might need downloading deps and so needs the additional time 24 | proxy_read_timeout 250; 25 | proxy_connect_timeout 250; 26 | proxy_send_timeout 250; 27 | 28 | server { 29 | listen 80; 30 | server_name localhost; 31 | 32 | # To allow special characters in headers 33 | ignore_invalid_headers off; 34 | # Allow any size file to be uploaded. 35 | # Set to a value such as 1000m; to restrict file size to a specific value 36 | client_max_body_size 0; 37 | # To disable buffering 38 | proxy_buffering off; 39 | 40 | location / { 41 | proxy_pass http://goblin_api:3000; 42 | } 43 | } 44 | 45 | # FIXME: Disabled storage and caching for initial version 46 | # server { 47 | # listen 9000; 48 | # server_name localhost; 49 | 50 | # # To allow special characters in headers 51 | # ignore_invalid_headers off; 52 | # # Allow any size file to be uploaded. 53 | # # Set to a value such as 1000m; to restrict file size to a specific value 54 | # client_max_body_size 0; 55 | # # To disable buffering 56 | # proxy_buffering off; 57 | 58 | # location / { 59 | # proxy_pass http://goblin_minio:9000; 60 | # } 61 | # } 62 | } -------------------------------------------------------------------------------- /.github/workflows/docker-pub.yml: -------------------------------------------------------------------------------- 1 | name: Docker Pub 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | pull_request: 8 | branches: 9 | - 'dev' 10 | push: 11 | tags: 12 | - 'v*.*.*' 13 | 14 | env: 15 | REGISTRY: ghcr.io 16 | IMAGE_NAME: ${{ github.repository }} 17 | 18 | 19 | jobs: 20 | build-and-push-image: 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | packages: write 25 | attestations: write 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | 32 | - name: Log in to the Container registry 33 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 34 | with: 35 | registry: ${{ env.REGISTRY }} 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Extract metadata (tags, labels) for Docker 40 | id: meta 41 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 42 | with: 43 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 44 | tags: | 45 | type=ref,event=branch 46 | type=ref,event=pr 47 | type=semver,pattern={{version}} 48 | type=semver,pattern={{major}}.{{minor}} 49 | 50 | - name: Build and push Docker image 51 | id: push 52 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 53 | with: 54 | context: . 55 | push: true 56 | tags: ${{ steps.meta.outputs.tags }} 57 | labels: ${{ steps.meta.outputs.labels }} 58 | 59 | - name: Generate artifact attestation 60 | uses: actions/attest-build-provenance@v1 61 | with: 62 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} 63 | subject-digest: ${{ steps.push.outputs.digest }} 64 | push-to-registry: true -------------------------------------------------------------------------------- /www/pages/index.md: -------------------------------------------------------------------------------- 1 | ## Usage 2 | 3 | Install `package` with optional `@version` and `options`: 4 | 5 | ```command 6 | curl -sf {{.Data.env.GOBLIN_ORIGIN_URL}}/[@version] | [...options] sh 7 | ``` 8 | 9 | ## API 10 | 11 | `package` - Complete module path 12 | 13 | ```sh 14 | github.com/barelyhuman/commitlog 15 | gopkg.in/yaml.v2 16 | ``` 17 | 18 | `version` - Exact or partial version range, optionally prefixed with "v" 19 | 20 | ```sh 21 | # Install the latest version 22 | 23 | 24 | # Install v1.2.3 25 | @v1.2.3 26 | 27 | # Install v3.x.x 28 | @v3 29 | ``` 30 | 31 | ### Options 32 | 33 | > Control Goblin's behavior with environment variables 34 | 35 | `PREFIX` - Change installation location (default: `/usr/local/bin`) 36 | 37 | ```sh 38 | # Install to /tmp 39 | ... | PREFIX=/tmp sh 40 | ``` 41 | 42 | `OUT` - Rename the resulting binary (default: ``) 43 | 44 | ```sh 45 | # Export Windows executable 46 | ... | OUT=example.exe sh 47 | ``` 48 | 49 | `CMD_PATH` - Path to the binary package (default: "") 50 | 51 | ```sh 52 | # Export Windows executable 53 | ... | CMD_PATH="/cmd/example" sh 54 | ``` 55 | 56 | ## Examples 57 | 58 | Install the latest version: 59 | 60 | ```command 61 | curl -sf {{.Data.env.GOBLIN_ORIGIN_URL}}/github.com/rakyll/hey | sh 62 | ``` 63 | 64 | Specify package version: 65 | 66 | ```command 67 | curl -sf {{.Data.env.GOBLIN_ORIGIN_URL}}/github.com/barelyhuman/statico@v0.0.7 | sh 68 | ``` 69 | 70 | Or use commit hashes: 71 | 72 | ```command 73 | curl -sf {{.Data.env.GOBLIN_ORIGIN_URL}}/github.com/barelyhuman/commitlog@bba8d7a63d622e4f12dbea9722b647cd985be8ad | sh 74 | ``` 75 | 76 | Use alternative sources: 77 | 78 | ```command 79 | curl -sf {{.Data.env.GOBLIN_ORIGIN_URL}}/golang.org/x/tools/godoc | sh 80 | ``` 81 | 82 | Specify nested packages 83 | 84 | > Note: nested package expect the path to be a `package main` file with a `main` 85 | > call. If you use something like `spf13/cobra` then check the 2nd example. 86 | 87 | ```command 88 | curl -sf {{.Data.env.GOBLIN_ORIGIN_URL}}/vito/bass/cmd/bass | sh 89 | ``` 90 | 91 | ```command 92 | curl -sf {{.Data.env.GOBLIN_ORIGIN_URL}}/gnorm.org/gnorm | CMD_PATH="/cli" PREFIX=./bin sh 93 | ``` 94 | 95 | ## How does it work? 96 | 97 | Each request resolves the needed tags and versions from 98 | [proxy.golang.org](https://proxy.golang.org). If no module is found, you can try 99 | replacing the version with a commit hash on supported platforms, e.g. GitHub. 100 | 101 | The response of this request is a Golang binary compiled for the requested 102 | operating system, architecture, package version, and the binary's name—using Go 103 | 1.17.x via the official [Docker image](https://hub.docker.com/_/golang). 104 | 105 | **Example response** 106 | 107 | ```sh 108 | {{.Data.env.GOBLIN_ORIGIN_URL}}/binary/github.com/rakyll/hey?os=darwin&arch=amd64&version=v0.1.3&out=hey 109 | ``` 110 | 111 | _Note: compilation is limited to 200 seconds due to timeout restrictions._ 112 | -------------------------------------------------------------------------------- /www/assets/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | color-scheme: light dark; 7 | 8 | --page-top: 2rem; 9 | --page-gutter: 1.5rem; 10 | --header-height: 80px; 11 | --content-height: calc(100vh - var(--header-height)); 12 | --content-width: 44rem; 13 | 14 | --color-base: 250 250 250; 15 | --color-surface: 244 244 245; 16 | --color-overlay: 228 228 231; 17 | --color-subtle: 113 113 122; 18 | --color-text: 24 24 24; 19 | --color-primary: 253 126 20; 20 | --color-secondary: 250 176 5; 21 | } 22 | 23 | @media screen(sm) { 24 | :root { 25 | --page-top: 5rem; 26 | --page-gutter: 2rem; 27 | } 28 | } 29 | 30 | @media (prefers-color-scheme: dark) { 31 | :root { 32 | --color-base: 24 24 27; 33 | --color-surface: 39 39 42; 34 | --color-overlay: 63 63 58; 35 | --color-subtle: 161 161 170; 36 | --color-text: 228 228 231; 37 | } 38 | } 39 | 40 | html { 41 | @apply motion-safe:scroll-smooth; 42 | } 43 | 44 | body { 45 | @apply bg-base text-text antialiased; 46 | } 47 | 48 | [id] { 49 | @apply scroll-mt-3; 50 | } 51 | 52 | pre { 53 | @apply overflow-x-auto rounded-md border bg-surface p-3; 54 | } 55 | 56 | code { 57 | @apply rounded bg-surface px-1 py-0.5 text-sm; 58 | } 59 | 60 | pre > code { 61 | @apply p-0; 62 | } 63 | 64 | pre > code.language-command::before { 65 | @apply text-subtle content-['$_']; 66 | } 67 | 68 | article > *:not(:first-child):not(pre) { 69 | @apply mt-8; 70 | } 71 | article > pre { 72 | @apply mt-5; 73 | } 74 | article h2, 75 | article h2 > * { 76 | @apply border-b pb-3 text-xl font-bold; 77 | } 78 | article h3, 79 | article h3 > * { 80 | @apply text-lg font-semibold; 81 | } 82 | article h2:not(:first-child), 83 | article h3, 84 | article h4, 85 | article h5 { 86 | @apply mt-14; 87 | } 88 | .link, 89 | article a { 90 | @apply inline text-primary underline-offset-2; 91 | } 92 | .link::after, 93 | article a::after { 94 | @apply inline-block whitespace-pre; 95 | } 96 | .link[href^="http"]::after, 97 | article a[href^="http"]::after { 98 | @apply content-['_↗']; 99 | } 100 | .link:hover, 101 | article a:hover { 102 | @apply underline; 103 | } 104 | article blockquote { 105 | @apply border-l-[3px] pl-3 text-sm font-medium text-subtle; 106 | } 107 | article strong { 108 | @apply font-semibold; 109 | } 110 | article ul > li, 111 | article ol > li { 112 | @apply pb-1; 113 | } 114 | 115 | .animate.fade-in-y, 116 | .animate-kids.fade-in-y > * { 117 | opacity: 0; 118 | animation: fade-in-y var(--duration, 800ms) var(--direction, forwards); 119 | animation-delay: calc(var(--delay, 0) * 100ms); 120 | } 121 | 122 | @keyframes fade-in-y { 123 | from { 124 | opacity: 0; 125 | transform: translateY(var(--from, 20px)); 126 | } 127 | 128 | to { 129 | opacity: 1; 130 | transform: none; 131 | } 132 | } 133 | 134 | @media (prefers-reduced-motion: reduce) { 135 | *, 136 | ::before, 137 | ::after { 138 | animation-delay: -1ms !important; 139 | animation-duration: 1ms !important; 140 | animation-iteration-count: 1 !important; 141 | background-attachment: initial !important; 142 | scroll-behavior: auto !important; 143 | transition-duration: 0s !important; 144 | transition-delay: 0s !important; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /storage/minio.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "log" 8 | "net/url" 9 | "path/filepath" 10 | "strings" 11 | "time" 12 | 13 | "github.com/barelyhuman/go/env" 14 | "github.com/minio/minio-go/v7" 15 | "github.com/minio/minio-go/v7/pkg/credentials" 16 | ) 17 | 18 | type S3Storage struct { 19 | client *minio.Client 20 | bucket string 21 | bucketPrefix string 22 | ctx context.Context 23 | } 24 | 25 | func NewAWSStorage(bucket string) *S3Storage { 26 | clientId := env.Get("STORAGE_CLIENT_ID", "") 27 | clientSecret := env.Get("STORAGE_CLIENT_SECRET", "") 28 | endpoint := env.Get("STORAGE_ENDPOINT", "") 29 | bucketPrefix := env.Get("STORAGE_BUCKET_PREFIX", "") 30 | ssl := true 31 | ctx := context.Background() 32 | 33 | // Initiate a client using DigitalOcean Spaces. 34 | creds := credentials.NewStaticV4(clientId, clientSecret, "") 35 | opts := minio.Options{ 36 | Secure: ssl, 37 | Creds: creds, 38 | Region: "blr1", 39 | } 40 | client, err := minio.New(endpoint, &opts) 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | 45 | bucketExists, _ := client.BucketExists(ctx, bucket) 46 | 47 | fmt.Printf("bucketExists: %v\n", bucketExists) 48 | 49 | if !bucketExists { 50 | err := client.MakeBucket( 51 | ctx, 52 | bucket, 53 | minio.MakeBucketOptions{ 54 | Region: "blr1", 55 | }, 56 | ) 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | } 61 | 62 | // List all Spaces. 63 | spaces, err := client.ListBuckets(ctx) 64 | if err != nil { 65 | log.Fatal(err) 66 | } 67 | for _, space := range spaces { 68 | fmt.Println(space.Name) 69 | } 70 | 71 | return &S3Storage{ 72 | client: client, 73 | bucketPrefix: bucketPrefix, 74 | bucket: bucket, 75 | ctx: ctx, 76 | } 77 | } 78 | 79 | func (a *S3Storage) Connect() error { 80 | return nil 81 | } 82 | 83 | func (a *S3Storage) Upload(objectName string, data bytes.Buffer) error { 84 | dataBytes := bytes.NewReader(data.Bytes()) 85 | _, err := a.client.PutObject( 86 | a.ctx, 87 | a.bucket, 88 | filepath.Join(a.bucketPrefix, objectName), 89 | dataBytes, 90 | dataBytes.Size(), 91 | minio.PutObjectOptions{}, 92 | ) 93 | return err 94 | } 95 | 96 | func (a *S3Storage) HasObject(objectName string) bool { 97 | objectKey := objectName 98 | 99 | if !strings.HasPrefix(objectName, a.bucketPrefix) { 100 | objectKey = filepath.Join(a.bucketPrefix, objectName) 101 | } 102 | 103 | obj, err := a.client.StatObject(a.ctx, a.bucket, objectKey, minio.StatObjectOptions{}) 104 | if err != nil { 105 | return false 106 | } 107 | return len(obj.Key) > 0 108 | } 109 | 110 | func (a *S3Storage) GetSignedURL(objectName string) (string, error) { 111 | objectKey := objectName 112 | 113 | if !strings.HasPrefix(objectName, a.bucketPrefix) { 114 | objectKey = filepath.Join(a.bucketPrefix, objectName) 115 | } 116 | 117 | url, err := a.client.PresignedGetObject( 118 | a.ctx, 119 | a.bucket, objectKey, time.Minute*15, url.Values{}, 120 | ) 121 | return url.String(), err 122 | } 123 | 124 | func (a *S3Storage) ListObjects() []MicroObject { 125 | doneCh := make(chan struct{}) 126 | defer close(doneCh) 127 | recursive := true 128 | collection := []MicroObject{} 129 | 130 | for obj := range a.client.ListObjects(a.ctx, a.bucket, minio.ListObjectsOptions{ 131 | Recursive: recursive, 132 | WithMetadata: true, 133 | UseV1: true, 134 | Prefix: filepath.Join(a.bucketPrefix), 135 | }) { 136 | if obj.Err != nil { 137 | fmt.Printf("obj.Err: %v\n", obj.Err) 138 | continue 139 | } 140 | if filepath.Clean(obj.Key) == a.bucketPrefix { 141 | continue 142 | } 143 | 144 | collection = append(collection, 145 | MicroObject{ 146 | LastModified: obj.LastModified, 147 | Key: obj.Key, 148 | }, 149 | ) 150 | } 151 | return collection 152 | } 153 | 154 | func (a *S3Storage) RemoveObject(objectName string) (bool, error) { 155 | objectKey := objectName 156 | 157 | if !strings.HasPrefix(objectName, a.bucketPrefix) { 158 | objectKey = filepath.Join(a.bucketPrefix, objectName) 159 | } 160 | 161 | err := a.client.RemoveObject(a.ctx, a.bucket, objectKey, minio.RemoveObjectOptions{}) 162 | if err != nil { 163 | return false, err 164 | } 165 | return true, nil 166 | } 167 | -------------------------------------------------------------------------------- /templates/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # Some utilities from https://github.com/client9/shlib 6 | 7 | echoerr() { 8 | printf "$@\n" 1>&2 9 | } 10 | 11 | log_donation() { 12 | printf "\033[32;5;61m$@\033[0;00m\n" 13 | } 14 | 15 | log_info() { 16 | printf "\033[33;5;61m >>\033[0;00m $@\n" 17 | } 18 | 19 | log_crit() { 20 | echoerr 21 | echoerr " \033[38;5;125m$@\033[0;00m" 22 | echoerr 23 | } 24 | 25 | is_command() { 26 | command -v "$1" >/dev/null 27 | #type "$1" > /dev/null 2> /dev/null 28 | } 29 | 30 | http_download_curl() { 31 | local_file=$1 32 | source_url=$2 33 | header=$3 34 | if [ -z "$header" ]; then 35 | code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") 36 | else 37 | code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") 38 | fi 39 | if [ "$code" != "200" ]; then 40 | log_crit "Error downloading, got $code response from server" 41 | return 1 42 | fi 43 | return 0 44 | } 45 | 46 | http_download_wget() { 47 | local_file=$1 48 | source_url=$2 49 | header=$3 50 | if [ -z "$header" ]; then 51 | wget -q -O "$local_file" "$source_url" 52 | else 53 | wget -q --header "$header" -O "$local_file" "$source_url" 54 | fi 55 | } 56 | 57 | http_download() { 58 | if is_command curl; then 59 | http_download_curl "$@" 60 | return 61 | elif is_command wget; then 62 | http_download_wget "$@" 63 | return 64 | fi 65 | log_crit "http_download unable to find wget or curl" 66 | return 1 67 | } 68 | 69 | http_copy() { 70 | tmp=$(mktemp) 71 | http_download "${tmp}" "$1" "$2" || return 1 72 | body=$(cat "$tmp") 73 | rm -f "${tmp}" 74 | echo "$body" 75 | } 76 | 77 | uname_os() { 78 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 79 | 80 | # fixed up for https://github.com/client9/shlib/issues/3 81 | case "$os" in 82 | msys_nt*) os="windows" ;; 83 | mingw*) os="windows" ;; 84 | esac 85 | 86 | # other fixups here 87 | echo "$os" 88 | } 89 | 90 | uname_os_check() { 91 | os=$(uname_os) 92 | case "$os" in 93 | darwin) return 0 ;; 94 | dragonfly) return 0 ;; 95 | freebsd) return 0 ;; 96 | linux) return 0 ;; 97 | android) return 0 ;; 98 | nacl) return 0 ;; 99 | netbsd) return 0 ;; 100 | openbsd) return 0 ;; 101 | plan9) return 0 ;; 102 | solaris) return 0 ;; 103 | windows) return 0 ;; 104 | esac 105 | log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib" 106 | return 1 107 | } 108 | 109 | uname_arch() { 110 | arch=$(uname -m) 111 | case $arch in 112 | x86_64) arch="amd64" ;; 113 | x86) arch="386" ;; 114 | i686) arch="386" ;; 115 | i386) arch="386" ;; 116 | aarch64) arch="arm64" ;; 117 | armv5*) arch="armv5" ;; 118 | armv6*) arch="armv6" ;; 119 | armv7*) arch="armv7" ;; 120 | esac 121 | echo ${arch} 122 | } 123 | 124 | uname_arch_check() { 125 | arch=$(uname_arch) 126 | case "$arch" in 127 | 386) return 0 ;; 128 | amd64) return 0 ;; 129 | arm64) return 0 ;; 130 | armv5) return 0 ;; 131 | armv6) return 0 ;; 132 | armv7) return 0 ;; 133 | ppc64) return 0 ;; 134 | ppc64le) return 0 ;; 135 | mips) return 0 ;; 136 | mipsle) return 0 ;; 137 | mips64) return 0 ;; 138 | mips64le) return 0 ;; 139 | s390x) return 0 ;; 140 | amd64p32) return 0 ;; 141 | esac 142 | log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib" 143 | return 1 144 | } 145 | 146 | mktmpdir() { 147 | test -z "$TMPDIR" && TMPDIR="$(mktemp -d)" 148 | mkdir -p "${TMPDIR}" 149 | echo "${TMPDIR}" 150 | } 151 | 152 | start() { 153 | uname_os_check 154 | uname_arch_check 155 | 156 | # API endpoint such as "http://localhost:3000" 157 | api="{{.URL}}" 158 | 159 | # package such as "github.com/tj/triage/cmd/triage" 160 | pkg="{{.Package}}" 161 | 162 | # binary name such as "hello" 163 | bin="{{.Binary}}" 164 | 165 | # original_version such as "master" 166 | original_version="{{.OriginalVersion}}" 167 | 168 | # version such as "master" 169 | version="{{.Version}}" 170 | 171 | prefix=${PREFIX:-"/usr/local/bin"} 172 | out=${OUT:-"$bin"} 173 | tmp="$(mktmpdir)/$out" 174 | cmd=${CMD_PATH:-""} 175 | 176 | echo 177 | log_info "Downloading $pkg@$original_version" 178 | if [ "$original_version" != "$version" ]; then 179 | log_info "Resolved version $original_version to $version" 180 | fi 181 | log_info "Building binary for $os $arch ... Please wait" 182 | http_download $tmp "$api/binary/$pkg?os=$os&arch=$arch&version=$version&out=$out&cmd=$cmd" 183 | 184 | # check if the directory exists and also check if it requires write permissions 185 | if [ ! -d "$prefix" ]; then 186 | log_info "$prefix doesn't exist, attempting to create it" 187 | if [ -w "$prefix" ]; then 188 | mkdir -p "$prefix" 189 | else 190 | log_info "Permissions required for creating $prefix" 191 | sudo mkdir -p "$prefix" 192 | fi 193 | fi 194 | 195 | if [ -w "$prefix" ]; then 196 | log_info "Installing $out to $prefix" 197 | install "$tmp" "$prefix" 198 | else 199 | log_info "Permissions required for installation to $prefix — alternatively specify a new directory with:" 200 | log_info " $ curl -sf https://goblin.run/$pkg@$version | PREFIX=. sh" 201 | sudo install "$tmp" "$prefix" 202 | fi 203 | 204 | log_info "Installation complete" 205 | printf "\n" 206 | log_donation "Thank you for using goblin, if you like the ease of installation and would like to support the developer, please do so on http://github.com/sponsors/barelyhuman" 207 | echo 208 | } 209 | 210 | start 211 | 212 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= 2 | github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= 3 | github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= 4 | github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 5 | github.com/barelyhuman/go v0.2.2 h1:Lpk1XrlP40F3II8BibVzViZUOJ1GgDdzXUBb8ENwb0U= 6 | github.com/barelyhuman/go v0.2.2/go.mod h1:hox2iDYZAarjpS7jKQeYIi2F+qMA8KLMtCws++L2sSY= 7 | github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= 8 | github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 9 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 10 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 13 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 15 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 16 | github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= 17 | github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= 18 | github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 19 | github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 20 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 21 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 22 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 23 | github.com/google/go-github/v53 v53.2.0 h1:wvz3FyF53v4BK+AsnvCmeNhf8AkTaeh2SoYu/XUvTtI= 24 | github.com/google/go-github/v53 v53.2.0/go.mod h1:XhFRObz+m/l+UCm9b7KSIC3lT3NWSXGt7mOsAWEloao= 25 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 26 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 27 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 28 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 29 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 30 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 31 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 32 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 33 | github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 34 | github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= 35 | github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 36 | github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= 37 | github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= 38 | github.com/minio/minio-go/v7 v7.0.77 h1:GaGghJRg9nwDVlNbwYjSDJT1rqltQkBFDsypWX1v3Bw= 39 | github.com/minio/minio-go/v7 v7.0.77/go.mod h1:AVM3IUN6WwKzmwBxVdjzhH8xq+f57JSbbvzqvUzR6eg= 40 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 41 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 42 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 43 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= 44 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 45 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 46 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 47 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 48 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 49 | github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0PhZE7qpvbZl5ljd8r6U0bI= 50 | github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= 51 | github.com/tj/go-semver v1.0.0 h1:vpn6Jmn6Hi3QSmrP1PzYcqScop9IZiGCVOSn18wzu8w= 52 | github.com/tj/go-semver v1.0.0/go.mod h1:YZuwVc013rh7KDV0k6tPbWrFeEHBHcp8amfJL+nHzjM= 53 | go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= 54 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 55 | go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0= 56 | go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= 57 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 58 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 59 | golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= 60 | golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= 61 | golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 62 | golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 63 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 65 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 66 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 67 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 68 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 69 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 70 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 71 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 72 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 73 | -------------------------------------------------------------------------------- /resolver/resolver_test.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "testing" 5 | 6 | "regexp" 7 | 8 | "github.com/Masterminds/semver" 9 | ) 10 | 11 | func TestGetVersionListProxyURL(t *testing.T) { 12 | url := getVersionListProxyURL("github.com/barelyhuman/commitlog") 13 | if url != "https://proxy.golang.org/github.com/barelyhuman/commitlog/@v/list" { 14 | t.Fatalf("Invalid proxy version list url") 15 | } 16 | } 17 | 18 | func TestGetVersionLatestProxyURL(t *testing.T) { 19 | url := getVersionLatestProxyURL("github.com/barelyhuman/commitlog") 20 | if url != "https://proxy.golang.org/github.com/barelyhuman/commitlog/@latest" { 21 | t.Fatalf("Invalid proxy version latest url, result:%s", url) 22 | } 23 | } 24 | 25 | func TestNormalizeURL(t *testing.T) { 26 | url := normalizeUrl("https://proxy.golang.org/") 27 | if normalizeUrl("https://proxy.golang.org/") != "https://proxy.golang.org" { 28 | t.Fatalf("Failed to normalize proxy url,result:%s", url) 29 | } 30 | url = normalizeUrl("https://proxy.golang.org") 31 | if normalizeUrl("https://proxy.golang.org") != "https://proxy.golang.org" { 32 | t.Fatalf("Failed to normalize proxy url,result:%s", url) 33 | } 34 | } 35 | 36 | func TestParseVersion(t *testing.T) { 37 | r := Resolver{ 38 | Pkg: "barelyhuman/commitlog", 39 | } 40 | err := r.ParseVersion("0.0.6") 41 | if err != nil { 42 | t.Fatalf("Failed to parse version, err:%v", err) 43 | } 44 | if len(r.Value) == 0 { 45 | t.Fatalf("Failed to assign value") 46 | } 47 | 48 | if r.ConstraintCheck == nil { 49 | t.Fatalf("Failed to create a constraint checker") 50 | } 51 | } 52 | 53 | func TestParseVersionWithHash(t *testing.T) { 54 | r := Resolver{ 55 | Pkg: "barelyhuman/commitlog", 56 | } 57 | commitHash := "bba8d7a63d622e4f12dbea9722b647cd985be8ad" 58 | err := r.ParseVersion("bba8d7a63d622e4f12dbea9722b647cd985be8ad") 59 | if err != nil { 60 | t.Fatalf("Failed to parse version, err:%v", err) 61 | } 62 | 63 | if r.Value != commitHash { 64 | t.Fatalf("Failed to assign value, value:%v, hash:%v", r.Value, commitHash) 65 | } 66 | 67 | if r.ConstraintCheck != nil { 68 | t.Fatalf("Created a constraint check for invalid semver") 69 | } 70 | } 71 | 72 | func TestResolveClosestVersion(t *testing.T) { 73 | versionToResolve := "0.0.6" 74 | inputVersion := "0.0.6" 75 | r := Resolver{ 76 | Pkg: "github.com/barelyhuman/commitlog", 77 | } 78 | 79 | // parse with a version 80 | err := r.ParseVersion(inputVersion) 81 | if err != nil { 82 | t.Fatalf("Failed to parse version,err:%v", err) 83 | } 84 | version, err := r.ResolveClosestVersion() 85 | if err != nil { 86 | t.Fatalf("Failed to get closest version,err:%v", err) 87 | } 88 | if version != versionToResolve { 89 | t.Fatalf("Resolved invalid version,resolved version:%v,should resolve to:%v,input:%v", version, versionToResolve, inputVersion) 90 | } 91 | 92 | } 93 | 94 | func TestResolveLatestVersion(t *testing.T) { 95 | versionToResolve := "v1.0.0" 96 | inputVersion := "" 97 | r := Resolver{ 98 | Pkg: "github.com/barelyhuman/commitlog", 99 | } 100 | err := r.ParseVersion(inputVersion) 101 | if err != nil { 102 | t.Fatalf("Failed to parse version,err:%v", err) 103 | } 104 | versionInfo, err := r.ResolveLatestVersion() 105 | if err != nil { 106 | t.Fatalf("Failed to get closes version,err:%v", err) 107 | } 108 | if versionInfo.Version != versionToResolve { 109 | t.Fatalf("Resolved invalid version,resolved version:%v,should resolve to:%v,input:%v", versionInfo.Version, versionToResolve, inputVersion) 110 | } 111 | } 112 | 113 | func TestResolveVersionWithVersion(t *testing.T) { 114 | versionToResolve := "0.0.7-dev.5" 115 | inputVersion := "0.0.7-dev.5" 116 | r := Resolver{ 117 | Pkg: "github.com/barelyhuman/commitlog", 118 | } 119 | err := r.ParseVersion(inputVersion) 120 | if err != nil { 121 | t.Fatalf("Failed to parse version, err:%v", err) 122 | } 123 | 124 | version, err := r.ResolveVersion() 125 | if err != nil { 126 | t.Fatalf("Failed to resolve, err:%v", err) 127 | } 128 | if version != versionToResolve { 129 | t.Fatalf("Failed to resolve, resolved:%v,expected resolve:%v", version, versionToResolve) 130 | } 131 | } 132 | 133 | func TestFallbackResolveVersionForInvalidPkg(t *testing.T) { 134 | inputVersion := "0.0.7-dev.5" 135 | r := Resolver{ 136 | Pkg: "github.com/barelyhuman", 137 | } 138 | err := r.ParseVersion(inputVersion) 139 | if err != nil { 140 | t.Fatalf("Failed to parse version, err:%v", err) 141 | } 142 | 143 | _, err = r.GithubFallbackResolveVersion() 144 | if err == nil { 145 | t.Fail() 146 | } 147 | } 148 | 149 | func TestResolveVersionWithoutVersion(t *testing.T) { 150 | 151 | inputVersion := "" 152 | r := Resolver{ 153 | Pkg: "github.com/barelyhuman/alvu", 154 | } 155 | err := r.ParseVersion(inputVersion) 156 | if err != nil { 157 | t.Fatalf("Failed to parse version, err:%v", err) 158 | } 159 | 160 | version, err := r.ResolveVersion() 161 | if err != nil { 162 | t.Fatalf("Failed to resolve, err:%v", err) 163 | } 164 | 165 | _, err = semver.NewVersion(version) 166 | 167 | if err != nil { 168 | t.Fatalf("Failed as the version received wasn't a semver, err:%v", err) 169 | } 170 | } 171 | 172 | // Expects a commit hash instead of a version tag 173 | // since the package on github has a higher version 174 | // than what's available on the proxy 175 | func TestResolveVersionForConflictingPackages(t *testing.T) { 176 | 177 | inputVersion := "" 178 | r := Resolver{ 179 | Pkg: "github.com/barelyhuman/commitlog", 180 | } 181 | err := r.ParseVersion(inputVersion) 182 | if err != nil { 183 | t.Fatalf("Failed to parse version, err:%v", err) 184 | } 185 | 186 | version, err := r.ResolveVersion() 187 | if err != nil { 188 | t.Fatalf("Failed to resolve, err:%v", err) 189 | } 190 | 191 | pttrn := regexp.MustCompile("[a-zA-Z0-9]+") 192 | matchedVal := pttrn.Find([]byte(version)) 193 | 194 | if len(string(matchedVal)) <= 0 { 195 | t.Fatalf("Failed to resolve version as a hash for the conflicting package, err:%v", err) 196 | } 197 | 198 | } 199 | 200 | func TestResolveVersionWithHash(t *testing.T) { 201 | versionToResolve := "7e0664aba1db8e44d11f9d457bd5bb583a8000ba" 202 | inputVersion := "7e0664aba1db8e44d11f9d457bd5bb583a8000ba" 203 | r := Resolver{ 204 | Pkg: "github.com/barelyhuman/commitlog", 205 | } 206 | err := r.ParseVersion(inputVersion) 207 | if err != nil { 208 | t.Fatalf("Failed to parse version, err:%v", err) 209 | } 210 | 211 | version, err := r.ResolveVersion() 212 | if err != nil { 213 | t.Fatalf("Failed to resolve, err:%v", err) 214 | } 215 | if version != versionToResolve { 216 | t.Fatalf("Failed to resolve, resolved:%v,expected resolve:%v", version, versionToResolve) 217 | } 218 | } 219 | 220 | func TestFailGettingLatest(t *testing.T) { 221 | pkgName := "barelyhuman/commitlog" 222 | version := "0.0.6" 223 | 224 | r := Resolver{ 225 | Pkg: pkgName, 226 | } 227 | r.ParseVersion(version) 228 | 229 | versionInfo, err := r.ResolveLatestVersion() 230 | if err == nil { 231 | t.Fatalf("Resolved for invalid package, pkg:%s , version:%s", pkgName, versionInfo.Version) 232 | } 233 | } 234 | 235 | func TestFailGettingClosest(t *testing.T) { 236 | pkgName := "barelyhuman/commitlog" 237 | version := "0.0.6" 238 | 239 | r := Resolver{ 240 | Pkg: pkgName, 241 | } 242 | r.ParseVersion(version) 243 | 244 | versionInfo, err := r.ResolveClosestVersion() 245 | if err == nil { 246 | t.Fatalf("Resolved for invalid package, pkg:%s , version:%s", pkgName, versionInfo) 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # goblin 2 | 3 | > [gobinaries](https://gobinaries.com/) alternative 4 | 5 | Simply put it's a lot of code that's been picked up from the original 6 | [gobinaries](https://github.com/tj/gobinaries) and the majority of the reason is 7 | that most of the research for the work has been already done there. 8 | 9 | The reason for another repo is that the development on gobinaries has been slow 10 | for the past few months / years at this point and go has moved up 2 version and 11 | people are still waiting for gobinaries to update itself. 12 | 13 | **All credits to [tj](https://github.com/tj) for the idea and the initial 14 | implementation.** 15 | 16 | - [goblin](#goblin) 17 | - [Why not fork?](#why-not-fork) 18 | - [Highlights](#highlights) 19 | - [Roadmap](#roadmap) 20 | - [Authors](#authors) 21 | - [Usage](#usage) 22 | - [Exposed API](#exposed-api) 23 | - [Deploy your own](#deploy-your-own) 24 | - [Existing Image](#existing-image) 25 | - [Using Docker](#using-docker) 26 | - [Using Traditional Servers](#using-traditional-servers) 27 | - [Configuration](#configuration) 28 | - [License](#license) 29 | 30 | ## Why not fork? 31 | 32 | To keep it short, it's fun to build the arch from scratch, helps you learn. Also 33 | the mentality of both the authors differ. 34 | 35 | (was easier to start from scratch then remove each blocking thing from the 36 | original one) 37 | 38 | ## Highlights 39 | 40 | - Easy to use - Users don't need go to install your CLI 41 | - Works with most common package ( Raise an [issue](/issues) if you find it not 42 | working with something) 43 | - Persistence ( can be made into a temporary cache to minimize storage costs) 44 | - Self Hostable 45 | 46 | ## Roadmap 47 | 48 | - [x] Cache a previously built version binary 49 | - Builds are cache for 6 hours due reduce storage costs, since it's run by a 50 | solo developer 51 | - [ ] Add support for download binaries from existing Github Release artifacts 52 | 53 | ## Authors 54 | 55 | [Reaper](https://github.com/barelyhuman), [Mvllow](https://github.com/mvllow) 56 | 57 | ## Usage 58 | 59 | You can read about it on [https://goblin.run](https://goblin.run) 60 | 61 | ## Exposed API 62 | 63 | from v0.4.0 64 | The server exposes a the following public routes usable for information 65 | 66 | **`GET /version///`** 67 | 68 | - A `GET /version` request on a package path for example `github.com/barelyhuman/commitlog/v3` would give you the latest version resolved for it by comparing it on the goproxy and github's repo tags. This is the same algo used internally by Goblin and is available to you. 69 | 70 | ## Deploy your own 71 | 72 | Since the entire reason for doing this was that delay on the original 73 | implementation added a lot more handling and addition of scripts to my website 74 | deployments than I liked. 75 | 76 | I wouldn't want that to happen again, so I really recommend people to spin up 77 | their own instances if they can afford to do so. If not, you can always use the 78 | hosted version from me at [goblin.run](https://goblin.run) 79 | 80 | **Note:the original code for gobinaries is equally simple to use and deploy but 81 | you'll have to make a few tweaks to the original code to make it work in a 82 | simpler fashion** 83 | 84 | ### Existing Image 85 | 86 | The repository builds and publishes a `latest` and a semver tagged version on each release. You can use that if you do not wish to tweak or change anything in the original source code and build structure 87 | 88 | ```sh 89 | $ docker run -p "3000:3000" ghcr.io/barelyhuman/goblin:latest 90 | # change the domain to whatever you are using for it 91 | $ docker run -e "GOBLIN_ORIGIN_URL=example.com" -p "3000:3000" ghcr.io/barelyhuman/goblin:latest 92 | ``` 93 | 94 | #### Using Docker 95 | 96 | 1. Setup docker or any other platform that would allow you to build and run 97 | docker images, if using services like Digital Ocean or AWS, you can use their 98 | container and docker image specific environments 99 | 2. Build the image 100 | 101 | ```sh 102 | cd goblin 103 | docker build -t goblin:latest . 104 | ``` 105 | 106 | 3. And finally push the image to either of the environments as mentioned in 107 | point 1. If doing it on a personal compute instance, you can just install 108 | docker, do step 3 and then run the below command. 109 | 110 | ```sh 111 | docker run -p "3000:3000" goblin:latest 112 | ``` 113 | 114 | #### Using Traditional Servers 115 | 116 | Let's face it, docker can be heavy and sometimes it's easier to run these apps 117 | separately as a simple service. 118 | 119 | Much like most go lang projects, goblin can be built into a single binary and 120 | run on any open port. 121 | 122 | The repo comes with helper scripts to setup an ubuntu server with the required 123 | stuff 124 | 125 | 1. Caddy for server 126 | 2. Go and Node for language support 127 | 3. PM2 as a process manager and start the process in the background 128 | 129 | You can run it like so 130 | 131 | ```sh 132 | ./scripts/prepare-ubuntu.sh 133 | ``` 134 | 135 | If you already have all the above setup separately, you can modify the build 136 | script and run that instead. 137 | 138 | ```sh 139 | ./scripts/build.sh 140 | ``` 141 | 142 | You'll have to create 2 `.env` files, one inside `www` and one at the root 143 | `.env` 144 | 145 | ```sh 146 | # .env 147 | 148 | # token from github that allows authentication for resolving versions from go modules as github repositories 149 | GITHUB_TOKEN= 150 | # the url that you want the server to use for creating scripts 151 | ORIGIN_URL= 152 | ``` 153 | 154 | ```sh 155 | # www/.env 156 | 157 | # the same url as ORIGIN_URL but added again because the static build needs it in the repo 158 | GOBLIN_ORIGIN_URL= 159 | ``` 160 | 161 | running the `build.sh` should handle building with the needed env files and 162 | restarting the server for you. 163 | 164 | ## Configuration 165 | 166 | The server can be configured easily using environment variables 167 | 168 | | KEY | default | description | options | 169 | | --------------------- | -------------------------- | ------------------------------------------------------------------------------------ | --------------- | 170 | | STORAGE_ENABLED | `false` | Enable persistence | `true` | 171 | | STORAGE_CLIENT_ID | | CLIENT_ID of a S3 compatible storage | | 172 | | STORAGE_CLIENT_SECRET | | CLIENT_SECRET of a S3 compatible storage | | 173 | | STORAGE_ENDPOINT | | Endpoint value of an S3 compatible storage | | 174 | | STORAGE_BUCKET | | Bucket name of the S3 compatible storage | | 175 | | STORAGE_BUCKET_PREFIX | | folder/namespace to store the files inside the bucket | | 176 | | PORT | `3000` | Default port for running the application | | 177 | | ORIGIN_URL | `http://localhost:${PORT}` | Default URL of the application | | 178 | | GITHUB_TOKEN | | Github authentication token for accessing github repositories and resolving versions | | 179 | | CLEAR_CACHE_TIME | | Duration used to clear and set expiry for stored binaries | `1m`,`30s`, etc | 180 | 181 | ## License 182 | 183 | [MIT](/LICENSE) 184 | -------------------------------------------------------------------------------- /resolver/resolver.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "os" 12 | "regexp" 13 | "sort" 14 | "strings" 15 | "time" 16 | 17 | "github.com/google/go-github/v53/github" 18 | goSem "github.com/tj/go-semver" 19 | "golang.org/x/oauth2" 20 | 21 | "github.com/Masterminds/semver" 22 | ) 23 | 24 | const DEFAULT_PROXY_URL = "https://proxy.golang.org" 25 | 26 | const hashRegex = "^[0-9a-f]{7,40}$" 27 | 28 | var gh = &GitHub{} 29 | 30 | type Resolver struct { 31 | Pkg string 32 | Value string 33 | Hash bool 34 | ConstraintCheck *semver.Constraints 35 | } 36 | 37 | type Plumbing struct { 38 | Hash string 39 | Version string 40 | } 41 | 42 | type PlumbingWithRange struct { 43 | Version goSem.Version 44 | Hash string 45 | } 46 | 47 | type GitHub struct { 48 | // Client is the GitHub client. 49 | Client *github.Client 50 | } 51 | 52 | type VersionInfo struct { 53 | Version string // version string 54 | Time time.Time // commit time 55 | } 56 | 57 | func init() { 58 | ctx := context.Background() 59 | 60 | authToken := os.Getenv("GITHUB_TOKEN") 61 | 62 | if len(authToken) > 0 { 63 | ghClient := oauth2.StaticTokenSource( 64 | &oauth2.Token{ 65 | AccessToken: os.Getenv("GITHUB_TOKEN"), 66 | }, 67 | ) 68 | gh.Client = github.NewClient(oauth2.NewClient(ctx, ghClient)) 69 | } else { 70 | gh.Client = github.NewClient(nil) 71 | } 72 | } 73 | 74 | // Resolve the version for the given package by 75 | // checking with the proxy for either the specified version 76 | // or getting the latest version on the proxy 77 | func (v *Resolver) ResolveVersion() (string, error) { 78 | if len(v.Value) == 0 { 79 | proxyVersion, proxyErr := v.ResolveLatestVersion() 80 | 81 | var fallbackErr error 82 | var fallbackVersion PlumbingWithRange 83 | 84 | if v.isGithubPKG() { 85 | fallbackVersion, fallbackErr = v.GithubFallbackResolveVersion() 86 | } 87 | 88 | if fallbackErr != nil { 89 | log.Println("Failed to resolve from Github Tags") 90 | } 91 | 92 | if proxyErr != nil && fallbackErr != nil { 93 | log.Println(proxyErr, fallbackErr) 94 | return "", fmt.Errorf(`failed to get any version from both proxy and fallback`) 95 | } 96 | 97 | if len(proxyVersion.Version) == 0 { 98 | return fallbackVersion.Hash, fallbackErr 99 | } 100 | 101 | // In case the value from the fallback (github's tag version ) 102 | // is greater than the version from proxy (proxy.golang) then 103 | // pick the version from the fallback 104 | if IsSemver(fallbackVersion.Version.String()) && IsSemver(proxyVersion.Version) && 105 | semver.MustParse(fallbackVersion.Version.String()).GreaterThan(semver.MustParse(proxyVersion.Version)) { 106 | return fallbackVersion.Hash, nil 107 | } 108 | 109 | return proxyVersion.Version, nil 110 | } 111 | 112 | if v.Hash { 113 | return v.Value, nil 114 | } 115 | 116 | versionString, err := v.ResolveClosestVersion() 117 | if err != nil { 118 | return "", err 119 | } 120 | 121 | if len(versionString) == 0 && v.isGithubPKG() { 122 | fallbackVersion, err := v.GithubFallbackResolveVersion() 123 | return fallbackVersion.Hash, err 124 | } 125 | 126 | return versionString, nil 127 | } 128 | 129 | func (v *Resolver) isGithubPKG() bool { 130 | parts := strings.Split(v.Pkg, "/") 131 | return parts[0] == "github.com" 132 | } 133 | 134 | func (v *Resolver) GithubFallbackResolveVersion() (PlumbingWithRange, error) { 135 | parts := strings.Split(v.Pkg, "/") 136 | 137 | // TODO: handle the latest branch to also be considering `main` and `dev` 138 | version := "master" 139 | if len(v.Value) == 0 { 140 | version = v.Value 141 | } 142 | 143 | if len(parts) != 3 { 144 | return PlumbingWithRange{}, fmt.Errorf("error, invalid resolution reference for github: %v, expected resolution reference to be like so: github.com//", v.Pkg) 145 | } 146 | 147 | resolvedV, err := gh.resolve(parts[1], parts[2], version) 148 | if err != nil { 149 | return PlumbingWithRange{}, err 150 | } 151 | 152 | return resolvedV, nil 153 | } 154 | 155 | // resolve the latest version from the proxy 156 | func (v *Resolver) ResolveLatestVersion() (VersionInfo, error) { 157 | var versionInfo VersionInfo 158 | 159 | resp, err := http.Get(getVersionLatestProxyURL(v.Pkg)) 160 | if err != nil { 161 | return versionInfo, err 162 | } 163 | defer resp.Body.Close() 164 | 165 | respBytes, err := ioutil.ReadAll(resp.Body) 166 | if err != nil { 167 | return versionInfo, err 168 | } 169 | 170 | if err := json.Unmarshal(respBytes, &versionInfo); err != nil { 171 | return versionInfo, err 172 | } 173 | 174 | return versionInfo, nil 175 | } 176 | 177 | // Parse the given string to be either a semver version string 178 | // or a commit hash 179 | func (v *Resolver) ParseVersion(version string) error { 180 | v.Value = version 181 | 182 | // just send back if no version is provided 183 | if len(version) == 0 { 184 | return nil 185 | } 186 | 187 | // return the string back if it's a valid hash string 188 | if !IsSemver(version) && !isValidSemverConstraint(version) { 189 | matched, err := regexp.MatchString(hashRegex, version) 190 | if matched { 191 | v.Hash = true 192 | return nil 193 | } 194 | // if not a hash or a semver, just return an error 195 | if err != nil { 196 | return err 197 | } 198 | } 199 | 200 | if IsSemver(version) { 201 | check, err := semver.NewConstraint("= " + version) 202 | if err != nil { 203 | return err 204 | } 205 | 206 | v.ConstraintCheck = check 207 | } 208 | 209 | if isValidSemverConstraint(version) { 210 | check, err := semver.NewConstraint(version) 211 | if err != nil { 212 | return err 213 | } 214 | 215 | v.ConstraintCheck = check 216 | } 217 | 218 | return nil 219 | } 220 | 221 | // Resolve the closes version to the given semver from the proxy 222 | func (v *Resolver) ResolveClosestVersion() (string, error) { 223 | var versionTags []string 224 | 225 | resp, err := http.Get(getVersionListProxyURL(v.Pkg)) 226 | if err != nil { 227 | return "", err 228 | } 229 | defer resp.Body.Close() 230 | 231 | data, err := ioutil.ReadAll(resp.Body) 232 | if err != nil { 233 | return "", err 234 | } 235 | 236 | versionTags = strings.Split(string(data), "\n") 237 | matchedVersion := "" 238 | 239 | var sortedVersionTags []*semver.Version 240 | 241 | for _, versionTag := range versionTags { 242 | if len(versionTag) == 0 { 243 | continue 244 | } 245 | 246 | ver, err := semver.NewVersion(versionTag) 247 | if err != nil { 248 | return "", err 249 | } 250 | sortedVersionTags = append(sortedVersionTags, ver) 251 | } 252 | sort.Sort(semver.Collection(sortedVersionTags)) 253 | 254 | for _, versionTag := range sortedVersionTags { 255 | if !v.ConstraintCheck.Check( 256 | versionTag, 257 | ) { 258 | continue 259 | } 260 | matchedVersion = versionTag.String() 261 | break 262 | } 263 | 264 | if len(matchedVersion) == 0 { 265 | return "", nil 266 | } 267 | 268 | return matchedVersion, nil 269 | } 270 | 271 | // check if the given string is valid semver string and if yest 272 | // create a constraint checker out of it 273 | func IsSemver(version string) bool { 274 | _, err := semver.NewVersion(version) 275 | return err == nil 276 | } 277 | 278 | func isValidSemverConstraint(version string) bool { 279 | versionRegex := `v?([0-9|x|X|\*]+)(\.[0-9|x|X|\*]+)?(\.[0-9|x|X|\*]+)?` + 280 | `(-([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` + 281 | `(\+([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` 282 | constraintOperations := `=||!=|>|<|>=|=>|<=|=<|~|~>|\^` 283 | validConstraintRegex := regexp.MustCompile(fmt.Sprintf( 284 | `^(\s*(%s)\s*(%s)\s*\,?)+$`, 285 | constraintOperations, 286 | versionRegex)) 287 | 288 | return validConstraintRegex.MatchString(version) 289 | } 290 | 291 | // normalize the proxy url to 292 | // - not have traling slashes 293 | func normalizeUrl(url string) string { 294 | if strings.HasSuffix(url, "/") { 295 | ind := strings.LastIndex(url, "/") 296 | if ind == -1 { 297 | return url 298 | } 299 | return strings.Join([]string{url[:ind], "", url[ind+1:]}, "") 300 | } 301 | return url 302 | } 303 | 304 | // get the proxy url for the latest version 305 | func getVersionLatestProxyURL(pkg string) string { 306 | urlPrefix := normalizeUrl(DEFAULT_PROXY_URL) 307 | return urlPrefix + "/" + pkg + "/@latest" 308 | } 309 | 310 | // get the proxy url for the entire version list 311 | func getVersionListProxyURL(pkg string) string { 312 | urlPrefix := normalizeUrl(DEFAULT_PROXY_URL) 313 | return urlPrefix + "/" + pkg + "/@v/list" 314 | } 315 | 316 | func (g *GitHub) versions(owner, repo string) (versions []Plumbing, err error) { 317 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) 318 | defer cancel() 319 | 320 | page := 1 321 | 322 | for { 323 | options := &github.ListOptions{ 324 | Page: page, 325 | PerPage: 100, 326 | } 327 | 328 | tags, _, err := g.Client.Repositories.ListTags(ctx, owner, repo, options) 329 | if err != nil { 330 | return nil, fmt.Errorf("listing tags: %w", err) 331 | } 332 | 333 | if len(tags) == 0 { 334 | break 335 | } 336 | 337 | // FIXME: parse the version right here 338 | // instead of parsing it during a range check 339 | for _, t := range tags { 340 | versions = append(versions, Plumbing{ 341 | Version: t.GetName(), 342 | Hash: t.GetCommit().GetSHA(), 343 | }) 344 | } 345 | 346 | page++ 347 | } 348 | 349 | if len(versions) == 0 { 350 | return nil, errors.New("no versions defined") 351 | } 352 | 353 | return 354 | } 355 | 356 | // Resolve implementation. 357 | func (g *GitHub) resolve(owner, repo, version string) (PlumbingWithRange, error) { 358 | // fetch tags 359 | tags, err := g.versions(owner, repo) 360 | if err != nil { 361 | return PlumbingWithRange{}, err 362 | } 363 | 364 | // convert to semver, ignoring malformed 365 | var versions []PlumbingWithRange 366 | for _, t := range tags { 367 | if v, err := goSem.Parse(t.Version); err == nil { 368 | versions = append(versions, PlumbingWithRange{ 369 | Version: v, 370 | Hash: t.Hash, 371 | }) 372 | } 373 | } 374 | 375 | // no versions, it has tags but they're not semver 376 | if len(versions) == 0 { 377 | return PlumbingWithRange{}, errors.New("no versions matched") 378 | } 379 | 380 | // master special-case 381 | if version == "master" { 382 | return versions[0], nil 383 | } 384 | 385 | // match requested semver range 386 | vr, err := goSem.ParseRange(version) 387 | if err != nil { 388 | return PlumbingWithRange{}, fmt.Errorf("parsing version range: %w", err) 389 | } 390 | 391 | for _, v := range versions { 392 | if vr.Match(v.Version) { 393 | return v, nil 394 | } 395 | } 396 | 397 | return PlumbingWithRange{}, errors.New("no versions matched") 398 | } 399 | -------------------------------------------------------------------------------- /cmd/goblin-api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "html/template" 10 | "io" 11 | "log" 12 | "net/http" 13 | "os" 14 | "os/exec" 15 | "path/filepath" 16 | "strings" 17 | "time" 18 | 19 | "github.com/barelyhuman/go/env" 20 | "github.com/barelyhuman/goblin/build" 21 | "github.com/barelyhuman/goblin/resolver" 22 | "github.com/barelyhuman/goblin/storage" 23 | "github.com/joho/godotenv" 24 | "go.uber.org/ratelimit" 25 | ) 26 | 27 | var shTemplates *template.Template 28 | var serverURL string 29 | var storageClient storage.Storage 30 | var rateLimiter ratelimit.Limiter 31 | 32 | type ErrorJSON struct { 33 | Success bool `json:"success"` 34 | Message string `json:"message"` 35 | } 36 | 37 | func (ej ErrorJSON) toJSONString() (string, error) { 38 | marshaled, err := json.Marshal(ej) 39 | if err != nil { 40 | return "", err 41 | } 42 | return string(marshaled), nil 43 | } 44 | 45 | type VersionJSON struct { 46 | Success bool `json:"success"` 47 | Package string `json:"package"` 48 | Binary string `json:"binary"` 49 | OriginalVersion string `json:"originalVersion"` 50 | Version string `json:"version"` 51 | } 52 | 53 | func (ej VersionJSON) toJSONString() (string, error) { 54 | marshaled, err := json.Marshal(ej) 55 | if err != nil { 56 | return "", err 57 | } 58 | return string(marshaled), nil 59 | } 60 | 61 | func HandleRequest(rw http.ResponseWriter, req *http.Request) { 62 | path := req.URL.Path 63 | 64 | if path == "/" { 65 | path = "./static/index.html" 66 | http.ServeFile(rw, req, path) 67 | return 68 | } 69 | 70 | file := filepath.Join("static", path) 71 | info, err := os.Stat(file) 72 | if err == nil && info.Mode().IsRegular() { 73 | http.ServeFile(rw, req, file) 74 | return 75 | } 76 | 77 | if strings.HasPrefix(path, "/version") { 78 | log.Println("Resolving version") 79 | rateLimiter.Take() 80 | resolveVersionJSON(rw, req) 81 | return 82 | } 83 | 84 | if strings.HasPrefix(path, "/binary") { 85 | log.Print("handle binary") 86 | fetchBinary(rw, req) 87 | return 88 | } 89 | 90 | fetchInstallScript(rw, req) 91 | } 92 | 93 | func BlankReq(rw http.ResponseWriter, req *http.Request) { 94 | rw.Header().Set("Content-Type", "text/plain") 95 | rw.Header().Set("Link", "rel=\"shortcut icon\" href=\"#\"") 96 | } 97 | 98 | func StartServer(port string) { 99 | http.Handle("/favicon.ico", http.HandlerFunc(BlankReq)) 100 | http.Handle("/", http.HandlerFunc(HandleRequest)) 101 | 102 | fmt.Println(">> Listening on " + port) 103 | err := http.ListenAndServe(":"+port, nil) 104 | if err != nil { 105 | log.Fatal(err) 106 | } 107 | } 108 | 109 | // TODO: cleanup code 110 | // TODO: move everything into their own interface/structs 111 | func main() { 112 | 113 | envFile := flag.String("env", ".env", "path to read the env config from") 114 | portFlag := env.Get("PORT", "3000") 115 | 116 | flag.Parse() 117 | 118 | // starting off with 250 since there's only one operation that's being rate limited 119 | rateLimiter = ratelimit.New(250) 120 | 121 | if _, err := os.Stat(*envFile); !errors.Is(err, os.ErrNotExist) { 122 | err := godotenv.Load() 123 | if err != nil { 124 | log.Fatal("Error loading .env file", err) 125 | } 126 | } 127 | 128 | shTemplates = template.Must(template.ParseGlob("templates/*")) 129 | serverURL = env.Get("ORIGIN_URL", "http://localhost:"+portFlag) 130 | 131 | if isStorageEnabled() { 132 | storageClient = storage.NewAWSStorage(env.Get("STORAGE_BUCKET", "goblin-cache")) 133 | err := storageClient.Connect() 134 | if err != nil { 135 | log.Fatal(err) 136 | } 137 | } 138 | 139 | clearTempCaches() 140 | clearStorageBackgroundJob() 141 | StartServer(portFlag) 142 | } 143 | 144 | func clearStorageBackgroundJob() { 145 | if !isStorageEnabled() { 146 | log.Printf("Clearer Disabled since storage is disabled") 147 | return 148 | } 149 | 150 | cacheHoldEnv := env.Get("CLEAR_CACHE_TIME", "") 151 | if len(cacheHoldEnv) == 0 { 152 | return 153 | } 154 | 155 | cacheHoldDuration, err := time.ParseDuration(cacheHoldEnv) 156 | if err != nil { 157 | log.Println(err) 158 | return 159 | } 160 | log.Printf("Clearer Initialized for: %v\n", cacheHoldDuration) 161 | 162 | cleaner := func(storageClient storage.Storage) { 163 | log.Println("Cleaning Cached Storage Object") 164 | objects := storageClient.ListObjects() 165 | now := time.Now() 166 | for _, obj := range objects { 167 | objExpiry := obj.LastModified.Add(cacheHoldDuration) 168 | if now.Equal(objExpiry) || now.After(objExpiry) { 169 | storageClient.RemoveObject(obj.Key) 170 | } 171 | } 172 | } 173 | 174 | tickerDur, _ := time.ParseDuration("2m") 175 | ticker := time.NewTicker(tickerDur) 176 | quit := make(chan struct{}) 177 | 178 | go func() { 179 | for { 180 | select { 181 | case <-ticker.C: 182 | cleaner(storageClient) 183 | case <-quit: 184 | ticker.Stop() 185 | return 186 | } 187 | } 188 | }() 189 | } 190 | 191 | func isStorageEnabled() bool { 192 | useStorageEnv := env.Get("STORAGE_ENABLED", "false") 193 | useStorage := false 194 | if useStorageEnv == "true" { 195 | useStorage = true 196 | } 197 | return useStorage 198 | } 199 | 200 | func normalizePackage(pkg string) string { 201 | // strip leading protocol 202 | pkg = strings.Replace(pkg, "https://", "", 1) 203 | return pkg 204 | } 205 | 206 | func parsePackage(path string) (pkg, mod, version, bin string) { 207 | p := strings.Split(path, "@") 208 | version = "" 209 | 210 | // pkg 211 | pkg = normalizePackage(p[0]) 212 | 213 | // mod 214 | modp := strings.Split(pkg, "/") 215 | if len(modp) >= 3 { 216 | mod = strings.Join(modp[:3], "/") 217 | } else { 218 | mod = pkg 219 | } 220 | 221 | // version after @ 222 | if len(p) > 1 { 223 | version = p[1] 224 | } 225 | 226 | // binary name from pkg 227 | p = strings.Split(pkg, "/") 228 | bin = p[len(p)-1] 229 | return 230 | } 231 | 232 | // immutable sets immutability header fields. 233 | func immutable(w http.ResponseWriter) { 234 | w.Header().Set("Content-Type", "application/octet-stream") 235 | w.Header().Set("Cache-Control", "max-age=31536000, immutable") 236 | } 237 | 238 | func render(w http.ResponseWriter, name string, data interface{}) { 239 | w.Header().Set("Content-Type", "application/x-sh") 240 | w.Header().Set("Cache-Control", "no-store") 241 | shTemplates.ExecuteTemplate(w, name, data) 242 | } 243 | 244 | func fetchInstallScript(rw http.ResponseWriter, req *http.Request) { 245 | pkg := strings.TrimPrefix(req.URL.Path, "/") 246 | pkg, _, version, name := parsePackage(pkg) 247 | 248 | v := &resolver.Resolver{ 249 | Pkg: pkg, 250 | } 251 | 252 | v.ParseVersion(version) 253 | resolvedVersion, err := v.ResolveVersion() 254 | if err != nil || len(resolvedVersion) == 0 { 255 | render(rw, "error.sh", ("Failed to resolve version:" + version)) 256 | return 257 | } 258 | 259 | // == mark default to latest version when nothing is provided == 260 | // this has be separated and put here since `latest` might actually 261 | // be a tag provided to the package 262 | // and could be then used, so using the branch name 263 | // makes no sense when working with go proxy instead of 264 | // github for example 265 | if len(version) == 0 { 266 | version = "latest" 267 | } 268 | 269 | render(rw, "install.sh", struct { 270 | URL string 271 | Package string 272 | Binary string 273 | OriginalVersion string 274 | Version string 275 | }{ 276 | URL: serverURL, 277 | Package: pkg, 278 | Binary: name, 279 | OriginalVersion: version, 280 | Version: resolvedVersion, 281 | }) 282 | } 283 | 284 | func resolveVersionJSON(rw http.ResponseWriter, req *http.Request) { 285 | // only reply in JSON 286 | rw.Header().Set("Content-Type", "application/json") 287 | 288 | pkg := strings.TrimPrefix(req.URL.Path, "/version") 289 | pkg, _, version, name := parsePackage(pkg) 290 | v := &resolver.Resolver{ 291 | Pkg: pkg, 292 | } 293 | v.ParseVersion(version) 294 | resolvedVersion, err := v.ResolveVersion() 295 | if err != nil || len(resolvedVersion) == 0 { 296 | errorJson, _ := ErrorJSON{Success: false, Message: "Failed to resolve version:" + version}.toJSONString() 297 | rw.Write([]byte(errorJson)) 298 | return 299 | } 300 | 301 | responseJson, _ := VersionJSON{ 302 | Success: true, 303 | Package: pkg, 304 | Binary: name, 305 | OriginalVersion: version, 306 | Version: resolvedVersion, 307 | }.toJSONString() 308 | 309 | rw.Write([]byte(responseJson)) 310 | return 311 | 312 | } 313 | 314 | func fetchBinary(rw http.ResponseWriter, req *http.Request) { 315 | pkg := strings.TrimPrefix(req.URL.Path, "/binary/") 316 | 317 | pkg, mod, _, name := parsePackage(pkg) 318 | 319 | goos := req.URL.Query().Get("os") 320 | if goos == "" { 321 | rw.WriteHeader(http.StatusBadRequest) 322 | fmt.Fprint(rw, "`os` is a required parameter") 323 | return 324 | } 325 | 326 | arch := req.URL.Query().Get("arch") 327 | if arch == "" { 328 | rw.WriteHeader(http.StatusBadRequest) 329 | fmt.Fprint(rw, "`arch` is a required parameter") 330 | return 331 | } 332 | 333 | version := req.URL.Query().Get("version") 334 | if version == "" { 335 | rw.WriteHeader(http.StatusBadRequest) 336 | fmt.Fprint(rw, "`version` is a required parameter") 337 | return 338 | } 339 | 340 | binName := req.URL.Query().Get("out") 341 | if binName == "" { 342 | binName = name 343 | } 344 | 345 | cmdPath := req.URL.Query().Get("cmd") 346 | 347 | bin := &build.Binary{ 348 | Path: pkg, 349 | Version: version, 350 | OS: goos, 351 | CmdPath: cmdPath, 352 | Arch: arch, 353 | Name: binName, 354 | Module: mod, 355 | } 356 | 357 | immutable(rw) 358 | 359 | artifactName := constructArtifactName(bin) 360 | 361 | if isStorageEnabled() && storageClient.HasObject(artifactName) { 362 | url, _ := storageClient.GetSignedURL(artifactName) 363 | log.Println("From cache") 364 | http.Redirect(rw, req, url, http.StatusSeeOther) 365 | return 366 | } 367 | 368 | var buf bytes.Buffer 369 | err := bin.WriteBuild(io.MultiWriter(rw, &buf)) 370 | 371 | if err != nil { 372 | rw.WriteHeader(http.StatusInternalServerError) 373 | fmt.Fprint(rw, err.Error()) 374 | return 375 | } 376 | 377 | if isStorageEnabled() { 378 | err = storageClient.Upload( 379 | artifactName, 380 | buf, 381 | ) 382 | 383 | if err != nil { 384 | log.Println("Failed to upload", err) 385 | } 386 | } 387 | 388 | err = bin.Cleanup() 389 | if err != nil { 390 | log.Println("cleaning binary build", err) 391 | } 392 | } 393 | 394 | func constructArtifactName(bin *build.Binary) string { 395 | var artifactName strings.Builder 396 | artifactName.Write([]byte(bin.Name)) 397 | artifactName.Write([]byte("-")) 398 | artifactName.Write([]byte(bin.Version)) 399 | artifactName.Write([]byte("-")) 400 | artifactName.Write([]byte(bin.OS)) 401 | artifactName.Write([]byte("-")) 402 | artifactName.Write([]byte(bin.Arch)) 403 | return artifactName.String() 404 | } 405 | 406 | func CleanupGoCache() { 407 | log.Println("Cleaning up go caches") 408 | cmd := exec.Command("go", "clean", "-cache") 409 | err := cmd.Run() 410 | if err != nil { 411 | log.Printf("Failed to run go clean -cache with error: %v", err) 412 | } 413 | 414 | cmd = exec.Command("go", "clean", "-modcache") 415 | err = cmd.Run() 416 | if err != nil { 417 | log.Printf("Failed to run go clean -modcache with error: %v", err) 418 | } 419 | } 420 | 421 | func CleanupFailedBuildCaches() { 422 | log.Println("Cleaning up fail build directories") 423 | base := os.TempDir() 424 | entries, err := os.ReadDir(base) 425 | if err != nil { 426 | log.Println("failed to read temp directory") 427 | return 428 | } 429 | for _, ent := range entries { 430 | if ent.IsDir() { 431 | if strings.HasPrefix(ent.Name(), "goblin") { 432 | err := os.RemoveAll(ent.Name()) 433 | if err != nil { 434 | log.Printf("failed to remove dir: %v", err) 435 | } 436 | } 437 | } 438 | } 439 | } 440 | 441 | func clearTempCaches() { 442 | tickerDur, _ := time.ParseDuration("6h") 443 | ticker := time.NewTicker(tickerDur) 444 | 445 | go func() { 446 | for range ticker.C { 447 | CleanupFailedBuildCaches() 448 | CleanupGoCache() 449 | } 450 | }() 451 | } 452 | -------------------------------------------------------------------------------- /www/pages/_layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Goblin 11 | 12 | 16 | 17 | 76 | 77 | 78 | 92 | Skip to main content 93 | 94 | 176 | 177 |
178 |
182 |

183 | Golang binaries in a curl, built by 184 | goblins 188 |

189 | 190 |

191 | Install Go binaries—without Go. 192 |

193 | 194 |
195 |
curl -sf http://goblin.run/github.com/rakyll/hey | sh
196 |
197 | 245 |
246 |
247 | 248 |
249 | Get Started 254 |
255 |
256 | 257 |
258 |

261 | If you get value from using Goblin, please consider 262 | donating. This helps cover server costs and supports my open source work. 265 |

266 | 267 | {{ .Content }} 268 |
269 |
270 | 271 | 286 | 287 | 288 | --------------------------------------------------------------------------------