Skip to content

Commit 28f8a9b

Browse files
committed
fix(media): pin old IPFS CIDs to Pinata and route images through Next.js optimizer
Add a one-time backend CLI subcommand `pin-ipfs-cids` that queries all unique ipfs:// URIs across users, events, communities, and posts tables, then pins each CID to the current Pinata account via the pinByHash API (async operation). This restores access to files that were pinned to a previous Pinata account. On the frontend, remove the custom Pinata image-transform loader from Web3Image. IPFS URIs are now converted to HTTPS gateway URLs via web2URL before being passed to Next.js Image, so all media is served through the standard _next/image optimizer instead of bypassing it. The unused web3-img-loader.ts file is deleted. Closes #1031
1 parent 0558c75 commit 28f8a9b

4 files changed

Lines changed: 189 additions & 35 deletions

File tree

backend/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ func main() {
4747
newGenticketCmd(),
4848
newGenPdfTicketCmd(),
4949
newConvertEvtToComCmd(),
50+
newPinIPFSCIDsCmd(),
5051
)
5152

5253
cmd.Execute(context.Background(), os.Args[1:])

backend/pin_ipfs_cids.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"flag"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"os"
12+
"strings"
13+
"time"
14+
15+
"github.com/gnolang/gno/tm2/pkg/commands"
16+
"go.uber.org/zap"
17+
"gorm.io/driver/sqlite"
18+
"gorm.io/gorm"
19+
)
20+
21+
func newPinIPFSCIDsCmd() *commands.Command {
22+
return commands.NewCommand(
23+
commands.Metadata{
24+
Name: "pin-ipfs-cids",
25+
ShortUsage: "pin-ipfs-cids [flags]",
26+
ShortHelp: "pin all IPFS CIDs stored in the database to the current Pinata account",
27+
},
28+
&pinIPFSCIDsConf,
29+
func(ctx context.Context, args []string) error {
30+
return pinIPFSCIDs()
31+
},
32+
)
33+
}
34+
35+
var pinIPFSCIDsConf = pinIPFSCIDsConfig{}
36+
37+
type pinIPFSCIDsConfig struct {
38+
dbPath string
39+
pinataJWT string
40+
dryRun bool
41+
}
42+
43+
func (conf *pinIPFSCIDsConfig) RegisterFlags(flset *flag.FlagSet) {
44+
flset.StringVar(&conf.dbPath, "db", "dev.db", "path to the SQLite database")
45+
flset.StringVar(&conf.pinataJWT, "pinata-jwt", "", "Pinata API JWT token (or set PINATA_JWT env var)")
46+
flset.BoolVar(&conf.dryRun, "dry-run", false, "list CIDs without actually pinning them")
47+
}
48+
49+
func pinIPFSCIDs() error {
50+
if val := os.Getenv("ZENAO_DB"); val != "" && pinIPFSCIDsConf.dbPath == "dev.db" {
51+
pinIPFSCIDsConf.dbPath = val
52+
}
53+
if val := os.Getenv("PINATA_JWT"); val != "" {
54+
pinIPFSCIDsConf.pinataJWT = val
55+
}
56+
if !pinIPFSCIDsConf.dryRun && pinIPFSCIDsConf.pinataJWT == "" {
57+
return fmt.Errorf("-pinata-jwt or PINATA_JWT env var is required")
58+
}
59+
60+
logger, err := zap.NewDevelopment()
61+
if err != nil {
62+
return err
63+
}
64+
65+
db, err := gorm.Open(sqlite.Open(pinIPFSCIDsConf.dbPath), &gorm.Config{})
66+
if err != nil {
67+
return fmt.Errorf("open database: %w", err)
68+
}
69+
70+
cids, err := collectAllIPFSCIDs(db)
71+
if err != nil {
72+
return fmt.Errorf("collect CIDs: %w", err)
73+
}
74+
logger.Info("collected unique IPFS CIDs", zap.Int("count", len(cids)))
75+
76+
if pinIPFSCIDsConf.dryRun {
77+
for _, cid := range cids {
78+
fmt.Println(cid)
79+
}
80+
return nil
81+
}
82+
83+
client := &http.Client{Timeout: 30 * time.Second}
84+
pinned, failed := 0, 0
85+
for _, cid := range cids {
86+
logger.Info("pinning", zap.String("cid", cid))
87+
if err := callPinByHash(client, pinIPFSCIDsConf.pinataJWT, cid); err != nil {
88+
logger.Error("failed to pin", zap.String("cid", cid), zap.Error(err))
89+
failed++
90+
} else {
91+
pinned++
92+
}
93+
}
94+
95+
logger.Info("done", zap.Int("pinned", pinned), zap.Int("failed", failed))
96+
if failed > 0 {
97+
return fmt.Errorf("%d CID(s) failed to pin", failed)
98+
}
99+
return nil
100+
}
101+
102+
// allIPFSCIDsQuery collects every distinct ipfs:// URI across all tables that store
103+
// user-uploaded media, so we can pin them all to the current Pinata account.
104+
const allIPFSCIDsQuery = `
105+
SELECT DISTINCT uri FROM (
106+
SELECT avatar_uri AS uri FROM users WHERE avatar_uri LIKE 'ipfs://%'
107+
UNION
108+
SELECT image_uri FROM events WHERE image_uri LIKE 'ipfs://%'
109+
UNION
110+
SELECT avatar_uri FROM communities WHERE avatar_uri LIKE 'ipfs://%'
111+
UNION
112+
SELECT banner_uri FROM communities WHERE banner_uri LIKE 'ipfs://%'
113+
UNION
114+
SELECT preview_image_uri FROM posts WHERE preview_image_uri LIKE 'ipfs://%'
115+
UNION
116+
SELECT image_uri FROM posts WHERE image_uri LIKE 'ipfs://%'
117+
UNION
118+
SELECT audio_uri FROM posts WHERE audio_uri LIKE 'ipfs://%'
119+
UNION
120+
SELECT video_uri FROM posts WHERE video_uri LIKE 'ipfs://%'
121+
UNION
122+
SELECT thumbnail_image_uri FROM posts WHERE thumbnail_image_uri LIKE 'ipfs://%'
123+
)`
124+
125+
func collectAllIPFSCIDs(db *gorm.DB) ([]string, error) {
126+
rows, err := db.Raw(allIPFSCIDsQuery).Rows()
127+
if err != nil {
128+
return nil, err
129+
}
130+
defer rows.Close()
131+
132+
var cids []string
133+
for rows.Next() {
134+
var uri string
135+
if err := rows.Scan(&uri); err != nil {
136+
return nil, err
137+
}
138+
cids = append(cids, strings.TrimPrefix(uri, "ipfs://"))
139+
}
140+
return cids, rows.Err()
141+
}
142+
143+
type pinByHashBody struct {
144+
HashToPin string `json:"hashToPin"`
145+
PinataOptions pinataOpts `json:"pinataOptions"`
146+
}
147+
148+
type pinataOpts struct {
149+
CIDVersion int `json:"cidVersion"`
150+
}
151+
152+
// callPinByHash queues a CID to be pinned to the Pinata account associated with
153+
// jwt. The operation is asynchronous: Pinata fetches the content from the IPFS
154+
// network in the background after this call returns.
155+
func callPinByHash(client *http.Client, jwt, cid string) error {
156+
body, err := json.Marshal(pinByHashBody{
157+
HashToPin: cid,
158+
PinataOptions: pinataOpts{CIDVersion: 1},
159+
})
160+
if err != nil {
161+
return err
162+
}
163+
164+
req, err := http.NewRequest(http.MethodPost, "https://api.pinata.cloud/pinning/pinByHash", bytes.NewReader(body))
165+
if err != nil {
166+
return err
167+
}
168+
req.Header.Set("Content-Type", "application/json")
169+
req.Header.Set("Authorization", "Bearer "+jwt)
170+
171+
resp, err := client.Do(req)
172+
if err != nil {
173+
return err
174+
}
175+
defer resp.Body.Close()
176+
177+
if resp.StatusCode >= 400 {
178+
respBody, _ := io.ReadAll(resp.Body)
179+
return fmt.Errorf("pinata returned status %d: %s", resp.StatusCode, string(respBody))
180+
}
181+
return nil
182+
}

components/widgets/images/web3-image.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,21 @@
22

33
import React from "react";
44
import Image, { ImageProps } from "next/image";
5-
import withWeb3ImgLoader from "@/lib/web3-img-loader";
5+
import { web2URL } from "@/lib/uris";
66
import { cn } from "@/lib/tailwind";
77

88
export const Web3Image = React.forwardRef<
99
HTMLImageElement,
10-
Omit<ImageProps, "loader"> & {
11-
imgFit?: "scale-down" | "contain" | "cover" | "crop" | "pad";
12-
}
13-
>(({ alt, src, className, imgFit, ...props }, ref) => {
14-
const isWeb3 = typeof src === "string" && src.startsWith("ipfs://");
10+
Omit<ImageProps, "loader">
11+
>(({ alt, src, className, ...props }, ref) => {
12+
const resolvedSrc =
13+
typeof src === "string" && src.startsWith("ipfs://") ? web2URL(src) : src;
1514

1615
return (
1716
<Image
1817
ref={ref}
19-
src={src}
18+
src={resolvedSrc}
2019
alt={alt}
21-
loader={isWeb3 ? withWeb3ImgLoader({ imgFit }) : undefined}
2220
className={cn("bg-primary/10", className)}
2321
{...props}
2422
/>

lib/web3-img-loader.ts

Lines changed: 0 additions & 27 deletions
This file was deleted.

0 commit comments

Comments
 (0)