diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9a93045 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +/public +/bin diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..a3dc7ec --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,53 @@ +name: Create and publish a Docker image + +on: + push: + branches: + - '**' + pull_request: + branches: + - "**" + + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64a5640 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/pina +/public diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7cb490a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.21.3-alpine3.18 + +COPY . /app +WORKDIR /app + +RUN mkdir /app/bin +RUN mkdir /app/public + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/🍍 + +EXPOSE 8000 + +CMD /app/bin/🍍 -port 8000 -public /app/public diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6b856d1 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.PHONY: docker-build docker-run docker-kill docker-stop docker-logs docker-deploy-contracts docker-all + +IMAGE_NAME=pina +CONTAINER_NAME=pina + +docker-all: docker-kill docker-build docker-run + +docker-build: + docker build . -t $(IMAGE_NAME) --no-cache --progress=plain + +docker-run: + docker run --name $(CONTAINER_NAME) --rm -d -p 127.0.0.1:8000:8000/tcp $(IMAGE_NAME) + +docker-kill: + -docker kill $(CONTAINER_NAME) + +docker-stop: + docker stop $(CONTAINER_NAME) + +docker-logs: + docker logs -f $(CONTAINER_NAME) + diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc550ee --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Piña 🍍 + +Piña is a clone of the [Pinata](https://www.pinata.cloud/) API, +**exclusively designed for local development environments** to streamline your development process and minimize +the need for direct usage of Pinata's services during development. + +## API + +``` +get /ipfs/{CID} +post /pinning/pinJSONToIPFS +post /pinning/pinFileToIPFS +``` + +### Test and Build + +``` +git clone git@github.com:gitcoinco/pina.git +go test +go build +``` + +### Run + +`go build && ./pina -port 8000 -public ./public` + +### Run in docker + +``` +make docker-build +make docker-run +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0006d16 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module github.com/gitcoinco/pina + +go 1.19 + +require ( + github.com/gravityblast/miniassert v0.0.0-20140522125902-bee63581261a + github.com/julienschmidt/httprouter v1.3.0 +) + +require ( + github.com/ipfs/go-cid v0.4.1 // indirect + github.com/klauspost/cpuid/v2 v2.0.4 // indirect + github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1 // indirect + github.com/minio/sha256-simd v1.0.0 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/multiformats/go-base32 v0.0.3 // indirect + github.com/multiformats/go-base36 v0.1.0 // indirect + github.com/multiformats/go-multibase v0.0.3 // indirect + github.com/multiformats/go-multicodec v0.9.0 // indirect + github.com/multiformats/go-multihash v0.0.15 // indirect + github.com/multiformats/go-varint v0.0.6 // indirect + github.com/rs/cors v1.10.1 // indirect + golang.org/x/crypto v0.1.0 // indirect + golang.org/x/sys v0.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d6f0672 --- /dev/null +++ b/go.sum @@ -0,0 +1,41 @@ +github.com/gravityblast/miniassert v0.0.0-20140522125902-bee63581261a h1:3xcty4gUzOHD4pqUZQDbS/Xb34susIRg9nmjKR3XM94= +github.com/gravityblast/miniassert v0.0.0-20140522125902-bee63581261a/go.mod h1:tVdF3zmaPraSn1jEeaVFIw4GYDX6fezNB3P9Gk4UpPA= +github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= +github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/klauspost/cpuid/v2 v2.0.4 h1:g0I61F2K2DjRHz1cnxlkNSBIaePVoJIjjnHui8QHbiw= +github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1 h1:lYpkrQH5ajf0OXOcUbGjvZxxijuBwbbmlSxLiuofa+g= +github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mr-tron/base58 v1.1.0/go.mod h1:xcD2VGqlgYjBdcBLw+TuYLr8afG+Hj8g2eTVqeSzSU8= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/multiformats/go-base32 v0.0.3 h1:tw5+NhuwaOjJCC5Pp82QuXbrmLzWg7uxlMFp8Nq/kkI= +github.com/multiformats/go-base32 v0.0.3/go.mod h1:pLiuGC8y0QR3Ue4Zug5UzK9LjgbkL8NSQj0zQ5Nz/AA= +github.com/multiformats/go-base36 v0.1.0 h1:JR6TyF7JjGd3m6FbLU2cOxhC0Li8z8dLNGQ89tUg4F4= +github.com/multiformats/go-base36 v0.1.0/go.mod h1:kFGE83c6s80PklsHO9sRn2NCoffoRdUUOENyW/Vv6sM= +github.com/multiformats/go-multibase v0.0.3 h1:l/B6bJDQjvQ5G52jw4QGSYeOTZoAwIO77RblWplfIqk= +github.com/multiformats/go-multibase v0.0.3/go.mod h1:5+1R4eQrT3PkYZ24C3W2Ue2tPwIdYQD509ZjSb5y9Oc= +github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= +github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= +github.com/multiformats/go-multihash v0.0.15 h1:hWOPdrNqDjwHDx82vsYGSDZNyktOJJ2dzZJzFkOV1jM= +github.com/multiformats/go-multihash v0.0.15/go.mod h1:D6aZrWNLFTV/ynMpKsNtB40mJzmCl4jb1alC0OvHiHg= +github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY= +github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= +github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= +github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/handlers_test.go b/handlers_test.go new file mode 100644 index 0000000..fb0402c --- /dev/null +++ b/handlers_test.go @@ -0,0 +1,158 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "log" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "os" + "path/filepath" + "strings" + "testing" + + assert "github.com/gravityblast/miniassert" + "github.com/julienschmidt/httprouter" +) + +func newTestRouter(t *testing.T) (*httprouter.Router, string) { + publicPath := t.TempDir() + router, err := newRouter(publicPath) + if err != nil { + t.Error(err) + t.FailNow() + } + + return router, filepath.Join(publicPath, "ipfs") +} + +func TestIndexHandler(t *testing.T) { + router, _ := newTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + router.ServeHTTP(w, req) + + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) + assert.Equal(t, "Hello World", string(body)) +} + +func TestStaticFiles(t *testing.T) { + router, ipfsPath := newTestRouter(t) + filePath := filepath.Join(ipfsPath, "test.txt") + err := os.WriteFile(filePath, []byte("Static file example"), 0755) + if err != nil { + t.Fatal(err) + } + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/ipfs/test.txt", nil) + router.ServeHTTP(w, req) + + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) + assert.Equal(t, "Static file example", string(body)) +} + +func TestPinJSONHandler(t *testing.T) { + router, ipfsPath := newTestRouter(t) + + w := httptest.NewRecorder() + reqBody := bytes.NewBufferString(`{"pinataContent": {"foo": {"bar": "baz"}}}`) + req := httptest.NewRequest(http.MethodPost, "/pinning/pinJSONToIPFS", reqBody) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", AUTH_TOKEN)) + router.ServeHTTP(w, req) + + resp := w.Result() + + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) + + var responseBody PinJSONResponseBody + err := json.NewDecoder(resp.Body).Decode(&responseBody) + if err != nil { + log.Fatal(err) + } + + cid := "bafkreihktyturq4bzrikdjylvvjbrgh5rfigzlydmjoyri3ip6fjbcqddu" + assert.Equal(t, cid, responseBody.IpfsHash) + assert.Equal(t, 10, responseBody.PinSize) + assert.NotEqual(t, "", responseBody.Timestamp) + + fileName := filepath.Join(ipfsPath, cid) + f, err := os.Open(fileName) + if err != nil { + log.Fatal(err) + } + + content, err := ioutil.ReadAll(f) + if err != nil { + log.Fatal(err) + } + + assert.Equal(t, `{"foo":{"bar":"baz"}}`, strings.TrimSpace(string(content))) +} + +func TestPinFileHandler(t *testing.T) { + router, _ := newTestRouter(t) + + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + // writer.WriteField("file", "Uploaded content") + + // Create the file part with an invalid file extension + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "file", "uploaded.txt")) + h.Set("Content-Type", "text/plain") + part, _ := writer.CreatePart(h) + part.Write([]byte("Uploaded content")) + writer.Close() + // Prepare and send the HTTP request + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/pinning/pinFileToIPFS", body) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", AUTH_TOKEN)) + req.Header.Add("Content-Type", writer.FormDataContentType()) + router.ServeHTTP(w, req) + + resp := w.Result() + + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) + + var responseBody PinJSONResponseBody + err := json.NewDecoder(resp.Body).Decode(&responseBody) + if err != nil { + log.Fatal(err) + } + + cid := "bafkreifqrpvngn6k2q6qahrm2oawbtrsoucxi7xtxehydodbzgnky6eiem" + assert.Equal(t, cid, responseBody.IpfsHash) + assert.Equal(t, 10, responseBody.PinSize) + assert.NotEqual(t, "", responseBody.Timestamp) + + fileName := filepath.Join(ipfsPath, cid) + f, err := os.Open(fileName) + if err != nil { + log.Fatal(err) + } + + content, err := ioutil.ReadAll(f) + if err != nil { + log.Fatal(err) + } + + assert.Equal(t, "Uploaded content", strings.TrimSpace(string(content))) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..8adcf36 --- /dev/null +++ b/main.go @@ -0,0 +1,221 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + cid "github.com/ipfs/go-cid" + "github.com/julienschmidt/httprouter" + mc "github.com/multiformats/go-multicodec" + mh "github.com/multiformats/go-multihash" + "github.com/rs/cors" +) + +const AUTH_TOKEN = "development-token" + +var ( + publicPath string + ipfsPath string + port int + + logger = log.New(os.Stderr, "", log.Ltime) +) + +type PinJSONRequestBody struct { + PinataContent interface{} `json:"pinataContent"` +} + +type PinJSONResponseBody struct { + IpfsHash string `json:"IpfsHash"` + PinSize int `json:"PinSize"` + Timestamp string `json:"Timestamp"` +} + +type WrappedResponseWriter struct { + http.ResponseWriter + lastStatusCode int +} + +func (w *WrappedResponseWriter) WriteHeader(statusCode int) { + w.lastStatusCode = statusCode + w.ResponseWriter.WriteHeader(statusCode) +} + +type LogHandler struct { + handler http.Handler +} + +func (l *LogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ww := &WrappedResponseWriter{w, 200} + l.handler.ServeHTTP(ww, r) + logger.Printf("[%s] %s (%d)", r.Method, r.URL.Path, ww.lastStatusCode) +} + +func init() { + flag.IntVar(&port, "port", 0, "http server port") + flag.StringVar(&publicPath, "public", "", "public path") +} + +func handleError(w http.ResponseWriter, err error) { + w.WriteHeader(http.StatusInternalServerError) + logger.Printf("error: %+v", err) + fmt.Fprint(w, "server error") +} + +func indexHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + fmt.Fprint(w, "Hello World") +} + +func bytesToCID(b []byte) (string, error) { + pref := cid.Prefix{ + Version: 1, + Codec: uint64(mc.Raw), + MhType: mh.SHA2_256, + MhLength: -1, // default length + } + + hash, err := pref.Sum(b) + if err != nil { + return "", err + } + + return hash.String(), nil +} + +func handleUpload(w http.ResponseWriter, r *http.Request, content []byte) error { + // generate CID + ipfsHash, err := bytesToCID(content) + if err != nil { + return err + } + + // create file using CID as file name + filePath := filepath.Join(ipfsPath, ipfsHash) + logger.Printf("writing to file %s", filePath) + f, err := os.Create(filePath) + if err != nil { + return err + } + defer f.Close() + + // write uploadded file content to file + _, err = f.Write(content) + if err != nil { + return err + } + + // encode response body + err = json.NewEncoder(w).Encode(&PinJSONResponseBody{ + IpfsHash: ipfsHash, + PinSize: 10, + Timestamp: time.Now().UTC().Format(time.RFC3339), + }) + + return err +} + +func pinJSONHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + var body PinJSONRequestBody + + // parse request body + err := json.NewDecoder(r.Body).Decode(&body) + if err != nil { + handleError(w, err) + return + } + + // encode JSON file content + content := bytes.NewBuffer([]byte{}) + err = json.NewEncoder(content).Encode(body.PinataContent) + if err != nil { + handleError(w, err) + return + } + + err = handleUpload(w, r, content.Bytes()) + if err != nil { + handleError(w, err) + } +} + +func pinFileHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + r.ParseMultipartForm(10 << 20) // max 10MB + + // parse uploaded file + file, _, err := r.FormFile("file") + if err != nil { + handleError(w, err) + return + } + defer file.Close() + + // read file content + content, err := ioutil.ReadAll(file) + if err != nil { + handleError(w, err) + return + } + + err = handleUpload(w, r, content) + if err != nil { + handleError(w, err) + } +} + +func authWrapper(next httprouter.Handle) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + authorization := r.Header.Get("Authorization") + token := strings.TrimSpace(strings.Replace(authorization, "Bearer", "", 1)) + if token != AUTH_TOKEN { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, fmt.Sprintf("access denied, you are not using the development auth token: %s", AUTH_TOKEN)) + return + } + next(w, r, params) + } +} + +func newRouter(publicPath string) (*httprouter.Router, error) { + ipfsPath = filepath.Join(publicPath, "ipfs") + err := os.MkdirAll(ipfsPath, 0755) + if err != nil { + return nil, err + } + + router := httprouter.New() + router.GET("/", indexHandler) + router.POST("/pinning/pinJSONToIPFS", authWrapper(pinJSONHandler)) + router.POST("/pinning/pinFileToIPFS", authWrapper(pinFileHandler)) + router.NotFound = http.FileServer(http.Dir(publicPath)) + + return router, nil +} + +func main() { + flag.Parse() + if port == 0 || publicPath == "" { + fmt.Println("port and public path flags are mandatory") + flag.Usage() + os.Exit(1) + } + + router, err := newRouter(publicPath) + if err != nil { + log.Fatal(err) + } + binding := fmt.Sprintf(":%d", port) + logger.Printf("listening: %s\n", binding) + logger.Printf("public path: %s\n", publicPath) + + handler := cors.New(cors.Options{Logger: logger, AllowedHeaders: []string{"*"}}).Handler(router) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), &LogHandler{handler})) +}