Build a Telegram Mini App Using Pinata’s Files API - No Database!
Written by: Paige Jones
Republished from here: https://bit.ly/4f6FtTQ
Telegram Mini Apps (TMAs) are fully-featured applications that run directly within the Telegram platform, combining the capabilities of bots with the flexibility of custom interfaces. In this guide, we'll walk you through creating a complete, full-stack TMA that includes user authentication and file storage using Pinata's File API and key-value pairs to eliminate the need for a traditional database. This tutorial will be comprised of two parts, building a backend Golang server and creating a React frontend interface. By the end of this demo, you will have a fully authenticated mini app that allows users to upload and share images within a chat.
Prerequisites
Before you start: Create a Telegram Mini App with BotFather
The first step is to create a new Telegram bot and mini app using the BotFather bot.
Part 1: Golang Server
In this section, we’ll create a simple Go server that validates user initData from the Telegram Mini App client and handles file upload and retrieval functionality using Pinata’s Files API. When a user launches the mini app within a chat, Telegram provides initData to the mini app. This initData contains details about the user and the chat session. The mini app client can send this data to our Go server, where we authenticate the user using the Go Telegram SDK init-data-golang.
Objectives
By the end of this section, you will:
Step 1: Set Up Your Go Project
1. Create a New Project Directory
Begin by creating a directory for your project and navigating into it:
mkdir telegram-go-server
cd telegram-go-server
This directory will house all your server-related files and folders.
2. Initialize a New Go Module
Initialize your Go module to manage dependencies and ensure proper versioning:
go mod init github.com/<YOUR_GITHUB_USERNAME>/miniapp-server
Replace <YOUR_GITHUB_USERNAME> with your GitHub username or organization name.
This command creates a go.mod file, which tracks your project's dependencies.
Step 2: Install Required Packages
Install the necessary Go packages for building the server:
go get github.com/joho/godotenv
go get github.com/gin-contrib/cors
go get github.com/gin-gonic/gin
go get github.com/telegram-mini-apps/init-data-golang
Why These Packages?
Step 3: Set Up Environment Variables
Environment variables store sensitive data securely, preventing hardcoding secrets into your codebase. Create a .env file in your project directory:
touch .env
Add the following environment variables to the .env file:
TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN # You received this from BotFather
PINATA_JWT=YOUR_JWT_TOKEN # Available in the Pinata Dashboard
PINATA_GATEWAY=YOUR_GATEWAY # Available in the Pinata Dashboard
WEB_APP_URL=* # Replace with deployed frontend URL later
Replace YOUR_BOT_TOKEN, YOUR_JWT_TOKEN, and YOUR_GATEWAY with your actual tokens from Telegram BotFather and Pinata.
Step 4: Create the Main File
Create the main entry point for your server:
touch main.go
Open main.go in your favorite text editor and add the following code:
package main
import (
"log"
"os"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
func main() {
// Load environment variables from .env file
err := godotenv.Load()
if err != nil {
log.Println("Error loading .env file")
}
// Ensure the Telegram bot token is set
telegramBotToken := os.Getenv("TELEGRAM_BOT_TOKEN")
if telegramBotToken == "" {
log.Fatal("TELEGRAM_BOT_TOKEN is not set")
}
// Configure CORS settings
corsConfig := cors.Config{
AllowOrigins: []string{os.Getenv("WEB_APP_URL")},
AllowMethods: []string{"GET", "POST", "OPTIONS"},
AllowHeaders: []string{"Content-Type", "Authorization", "X-Requested-With", "Accept", "Origin"},
AllowCredentials: true,
}
// Create a Gin router with default middleware
r := gin.Default()
r.Use(cors.New(corsConfig))
//TODO: Add routes here
// Start the server
port := ":8080"
log.Printf("Server is running on port %s\\n", port)
if err := r.Run(port); err != nil {
log.Fatalf("Failed to run server: %v", err)
}
}
Step 5: Run the Server
With your main file configured, you can now run your server to ensure everything is set up correctly:
go run main.go
You should see the following output in your terminal:
Server is running on port :8080
Congratulations! ???Your server is now operational and ready to handle incoming requests.
Step 6: Organize Your Project
Create the following folders to structure your codebase effectively:
mkdir routes dto services
Within each directory, add the necessary files to adhere to the following structure:
dto
├── auth.go
├── files.go
routes
├── auth
│ ├── controller.go
│ └── handler.go
├── files
│ ├── controller.go
│ └── handler.go
└── routes.go
services
├── auth.go
└── files.go
main.go
Directory Structure Explained:
Step 7: Implement Authentication Logic
Authentication is a critical component of your Mini App, ensuring that only authorized users can access and manipulate data. We'll validate the initData received from Telegram and manage user sessions.
1. Define the Auth DTO
Create a Data Transfer Object (DTO) to handle authentication requests and responses. This DTO structures the data exchanged between the frontend client and the backend server. Add the following to dto/auth.go:
package dto
import tgData "github.com/telegram-mini-apps/init-data-golang"
type AuthRequest struct {
InitData string `json:"initData"`
IsMocked bool `json:"isMocked"`
}
type AuthOutput struct {
User tgData.User `json:"user"`
ChatID string `json:"chat_id"`
Message string `json:"message"`
}
2. Build the Auth Service
The auth service encapsulates the business logic required to authenticate users based on the initData provided by Telegram. It handles validation, parsing, and the optional mocking of data for testing.
For testing purposes, we’ll include an isMocked flag that allows us to return dummy Telegram data. The server will respond with the parsed user information and chatID, which can then be consumed by the frontend client.
Add the following to services/auth.go:
package services
import (
"errors"
"fmt"
"log"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/dto"
tgData "github.com/telegram-mini-apps/init-data-golang"
)
type AuthService struct{}
func NewAuthService() *AuthService {
return &AuthService{}
}
func (s *AuthService) Authenticate(c *gin.Context, initData *string, isMocked bool) (dto.AuthOutput, error) {
if initData == nil && !isMocked {
return dto.AuthOutput{}, errors.New("initData is required")
}
// Get the Telegram Bot Token from environment variables
telegramBotToken := os.Getenv("TELEGRAM_BOT_TOKEN")
if telegramBotToken == "" {
return dto.AuthOutput{}, errors.New("telegram bot token is not set")
}
// Handle mocked data for testing
if isMocked {
mockUserData := tgData.User{
ID: 123456789,
FirstName: "Test",
LastName: "User",
Username: "testuser",
PhotoURL: "<https://www.gravatar.com/avatar>",
}
response := dto.AuthOutput{
User: mockUserData,
ChatID: "123456789",
Message: "Using mocked data",
}
return response, nil
}
// Define expiration time for initData (e.g., 24 hours)
expiration := 24 * time.Hour
if initData != nil {
// Validate the initData with the Telegram Bot Token and expiration time
err := tgData.Validate(*initData, telegramBotToken, expiration)
if err != nil {
log.Println("Error validating initData:", err)
return dto.AuthOutput{}, errors.New("invalid initData")
}
// Parse the initData to get user data
initDataParsed, err := tgData.Parse(*initData)
if err != nil {
log.Println("Error parsing initData:", err)
return dto.AuthOutput{}, errors.New("failed to parse initData")
}
// Respond with the parsed initData
response := dto.AuthOutput{
User: initDataParsed.User,
ChatID: fmt.Sprint(initDataParsed.ChatInstance),
Message: "Using parsed data",
}
return response, nil
}
return dto.AuthOutput{}, errors.New("invalid initData")
}
Note: Ensure that your dto file imports reflect your module path in your services package and other files referencing it.
3. Add Auth Routes
Define the route logic in routes/auth/controller.go. This controller bridges HTTP requests with the authentication service, handling incoming authentication requests and sending appropriate responses.
Add the following to routes/auth/controller.go:
package auth
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/dto"
"github.com/paigexx/telegram-go-server/services"
)
type Handler struct {
service services.AuthService
}
func newHandler(authService services.AuthService) *Handler {
return &Handler{
service: authService,
}
}
func (h Handler) Authenticate(c *gin.Context) {
input := dto.AuthRequest{}
if err := c.ShouldBind(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid form data"})
return
}
result, err := h.service.Authenticate(c, &input.InitData, input.IsMocked)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Authentication failed"})
return
}
c.JSON(http.StatusOK, result)
}
4. Create the Auth Handler
Add the following to routes/auth/handler.go. This file initializes the authentication routes and associates them with their respective handlers.
package auth
import (
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/services"
)
func NewHandler(r *gin.RouterGroup) {
auth := r.Group("/auth")
service := services.NewAuthService()
h := newHandler(*service)
auth.POST("", h.Authenticate)
}
Step 8. Apply the routes
1. Add routes/routes.go
Locate the routes/routes.go file and add the following code to apply the authentication routes:
package routes
import (
"github.com/gin-gonic/gin"
"github.com/paigexx/miniapp-server/routes/auth"
)
func ApplyRoutes(r *gin.Engine) {
api := r.Group("/")
auth.NewHandler(api)
}
2. Ensure Routes are Applied in main.go
Confirm that main.go applies the routes by including the following import and function call:
import (
// ... other imports
"github.com/paigexx/miniapp-server/routes"
)
func main() {
// ... existing code
// Apply routes
routes.ApplyRoutes(r)
// ... existing code
}
This step integrates all defined routes into the Gin router, enabling the server to handle incoming requests appropriately.
Step 9: Create the Files Logic
To enable file uploads, signed URL generation, and file listing, we'll integrate Pinata’s Files API into the Go server. This involves creating services, DTOs, and routes for managing files.
1. Create the Files DTO
The Data Transfer Object (DTO) defines the structure for file-related data. Add the following to dto/files.go:
package dto
type FileUploadRequest struct {
File string `json:"file"`
TelegramID string `json:"tg_id"`
}
//responses from Pinata Files API
type FileUploadResponse struct {
Data struct {
ID string `json:"id"`
Name string `json:"name"`
CID string `json:"cid"`
CreatedAt string `json:"created_at"`
Size int `json:"size"`
NumberOfFiles int `json:"number_of_files"`
MimeType string `json:"mime_type"`
UserID string `json:"user_id"`
KeyValues map[string]string `json:"keyvalues"`
IsDuplicate *bool `json:"is_duplicate"`
} `json:"data"`
}
type File struct {
ID string `json:"id"`
Name string `json:"name"`
CID string `json:"cid"`
Size int `json:"size"`
NumberOfFiles int `json:"number_of_files"`
MimeType string `json:"mime_type"`
GroupID string `json:"group_id"`
KeyValues map[string]string `json:"keyvalues"`
CreatedAt string `json:"created_at"`
}
type ListFilesResponse struct {
Data struct {
Files []File `json:"files"`
NextPageToken string `json:"next_page_token"`
} `json:"data"`
}
type UpdateFileResponse struct {
Data File `json:"data"`
}
type SignedURLResponse struct {
Data string `json:"data"`
}
2. Create the Files Service
The files service encapsulates the business logic required to interact with Pinata’s Files API, handling tasks such as uploading files, generating signed URLs, and listing files associated with specific chat sessions.
Add the following to services/files.go:
a. Upload Files to Pinata
package services
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/dto"
)
type FilesService struct{}
func NewFilesService() *FilesService {
return &FilesService{}
}
func (s *FilesService) Upload(c *gin.Context, file multipart.File, fileName string, chatID string) (string, error) {
// Create a buffer to hold the multipart form data for Pinata
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
// Create a form file field named "file"
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
return "", fmt.Errorf("error creating form file: %s", err)
}
// Copy the uploaded file data to the form file field
_, err = io.Copy(part, file)
if err != nil {
return "", fmt.Errorf("error copying file data: %s", err)
}
// Create a map with your key-value pairs
keyvaluesData := map[string]interface{}{
fmt.Sprintf("%v", chatID): "true",
}
// Marshal the map into a JSON string
keyvaluesJSON, err := json.Marshal(keyvaluesData)
if err != nil {
return "", fmt.Errorf("error marshaling keyvalues: %s", err)
}
// Write the JSON string to the form field
err = writer.WriteField("keyvalues", string(keyvaluesJSON))
if err != nil {
return "", fmt.Errorf("error writing keyvalues field: %s", err)
}
// Close the writer to finalize the multipart form data
err = writer.Close()
if err != nil {
return "", fmt.Errorf("error closing writer: %s", err)
}
// Continue with the rest of your code...
// Create a new POST request to Pinata's file upload endpoint
url := "<https://uploads.pinata.cloud/v3/files>"
req, err := http.NewRequest("POST", url, &buf)
if err != nil {
return "", fmt.Errorf("error creating request: %s", err)
}
// Set the appropriate headers, including your Pinata JWT token
jwt := os.Getenv("PINATA_JWT")
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt)) // Replace with your actual token
// Send the request to Pinata
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error sending request: %s", err)
}
defer resp.Body.Close()
// Read the response from Pinata
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response: %s", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("error uploading file: %s", responseBody)
}
var pinataResp dto.FileUploadResponse
err = json.Unmarshal(responseBody, &pinataResp)
if err != nil {
return "", fmt.Errorf("error unmarshaling response: %s", err)
}
// Check if the file is a duplicate, if so update the metadata with the chatID
if pinataResp.Data.IsDuplicate != nil && *pinataResp.Data.IsDuplicate {
s.UpdateMetadata(pinataResp.Data.ID, chatID)
}
return pinataResp.Data.ID, nil
}
func (s *FilesService) UpdateMetadata(fileId string, chatId string) (string, error) {
url := fmt.Sprintf(`https://api.pinata.cloud/v3/files/%s`, fileId)
// Create payload with the new keyvalues
payloadData := map[string]interface{}{
"keyvalues": map[string]string{
fmt.Sprintf("%v", chatId): "true",
},
}
payloadBytes, err := json.Marshal(payloadData)
if err != nil {
return "", fmt.Errorf("error marshalling payload: %s", err)
}
// Create the PUT request
req, err := http.NewRequest("PUT", url, bytes.NewBuffer(payloadBytes))
if err != nil {
return "", fmt.Errorf("error creating request: %s", err)
}
jwt := os.Getenv("PINATA_JWT")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt))
req.Header.Set("Content-Type", "application/json")
// Send the PUT request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error sending request: %s", err)
}
defer resp.Body.Close()
// Read the response
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response: %s", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("error updating metadata: %s", responseBody)
}
var updateResp dto.UpdateFileResponse
err = json.Unmarshal(responseBody, &updateResp)
if err != nil {
return "", fmt.Errorf("error unmarshaling response: %s", err)
}
return updateResp.Data.ID, nil
}
The Upload function manages file uploads to Pinata’s Files API and associates each file with specific chat instances through metadata. It performs two main tasks:
b. Generate Signed URLs
func (s *FilesService) GetSignedUrl(c *gin.Context, cid string) (string, error) {
url := `https://api.pinata.cloud/v3/files/sign`
gateway := os.Getenv("PINATA_GATEWAY")
// Construct the full URL as per the required format
fileURL := fmt.Sprintf("%s/files/%s", gateway, cid)
payloadData := map[string]interface{}{
"url": fileURL,
"method": "GET",
"date": time.Now().Unix(), // Current Unix timestamp
"expires": 3600, // URL valid for 1 hour
}
payloadBytes, err := json.Marshal(payloadData)
if err != nil {
return "", fmt.Errorf("error marshalling payload: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes))
if err != nil {
return "", fmt.Errorf("error creating request: %s", err)
}
jwt := os.Getenv("PINATA_JWT")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt))
req.Header.Set("Content-Type", "application/json") // Set the Content-Type header
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error sending request: %s", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response: %s", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("error getting signed URL: %s", responseBody)
}
var pinataResp dto.SignedURLResponse
err = json.Unmarshal(responseBody, &pinataResp)
if err != nil {
return "", fmt.Errorf("error unmarshaling response: %s", err)
}
return pinataResp.Data, nil
}
GetSignedUrl, generates a signed URL for accessing a file stored with Pinata. You can specify the expiration time for the link within payload data.
c. List Files
func (s *FilesService) List(c *gin.Context, chatID string, pageToken string) (dto.ListFilesResponse, error) {
url := fmt.Sprintf(`https://api.pinata.cloud/v3/files?pageToken=%v&metadata[%v]=true&limit=5`, pageToken, chatID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return dto.ListFilesResponse{}, fmt.Errorf("error creating request: %s", err)
}
jwt := os.Getenv("PINATA_JWT")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt)) // Replace with your actual token
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return dto.ListFilesResponse{}, fmt.Errorf("error sending request: %s", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return dto.ListFilesResponse{}, fmt.Errorf("error reading response: %s", err)
}
if resp.StatusCode != http.StatusOK {
return dto.ListFilesResponse{}, fmt.Errorf("error listing files: %s", responseBody)
}
var pinataResp dto.ListFilesResponse
err = json.Unmarshal(responseBody, &pinataResp)
if err != nil {
return dto.ListFilesResponse{}, fmt.Errorf("error unmarshaling response: %s", err)
}
return pinataResp, nil
}
List constructs a request to Pinata’s Files API, filtering files by chatID and handling pagination with pageToken. The limit parameter restricts the number of files returned per request (set to 5 in this example
3. Add File Routes
Define the routes in routes/files/controller.go. This controller manages the endpoints related to file operations, such as uploading files, listing files, and generating signed URLs.
Add the following to routes/files/controller.go:
package files
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/services"
)
type Handler struct {
service services.FilesService
}
func newHandler(filesService services.FilesService) *Handler {
return &Handler{
service: filesService,
}
}
func (h Handler) Upload(c *gin.Context) {
err := c.Request.ParseMultipartForm(10 << 20) // 10MB
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Error parsing form data"})
return
}
// Retrieve the file from the form data
file, handler, err := c.Request.FormFile("file")
if err != nil {
http.Error(c.Writer, "Error retrieving file: "+err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
chatID := c.Request.FormValue("chat_id")
id, err := h.service.Upload(c, file, handler.Filename, chatID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": id})
}
func (h Handler) List(c *gin.Context) {
chatID := c.Param("chat_id")
pageToken := c.Query("pageToken")
files, err := h.service.List(c, chatID, pageToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, files)
}
func (h Handler) GetSignedUrl(c *gin.Context) {
cid := c.Param("cid")
url, err := h.service.GetSignedUrl(c, cid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
4. Create the Files Handler
Add the following to routes/files/handler.go. This file initializes the file-related routes and associates them with their respective handlers.
package files
import (
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/services"
)
func NewHandler(r *gin.RouterGroup) {
files := r.Group("/files")
service := services.NewFilesService()
h := newHandler(*service)
files.POST("", h.Upload)
files.GET(":chat_id", h.List)
files.GET("signedUrl/:cid", h.GetSignedUrl)
}
5. Register the File Routes
Ensure that both auth and file routes are registered by updating routes/routes.go:
package routes
import (
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/routes/auth"
"github.com/paigexx/telegram-go-serverr/routes/files"
)
func ApplyRoutes(r *gin.Engine) {
api := r.Group("/")
auth.NewHandler(api)
files.NewHandler(api)
}
Step 10. Deploy Your Server
While local development is essential, deploying your server ensures it’s accessible to Telegram and your frontend application. Render is a good choice for deploying Go applications seamlessly. Follow these steps to deploy your server:
1. Initialize a Git Repository
Commit your changes:
git commit -m "Initial commit"
Add all your files to the repository:
git add .
In your project directory, initialize a Git repository:
git init
2. Push to GitHub
领英推荐
git remote add origin <https://github.com/username/repository-name.git>
Replace username and repository-name with your GitHub username and the name of the repository you just created.
git push -u origin main
3. Set Up a Render Account
4. Connect GitHub Repository
5. Configure Deployment Settings
In most cases, Render will handle the deployment settings. However, if you need to manually add them these are the details:
Start Command: Specify how to start your server, e.g.:
./main
Build Command: For a Go server, use:
go build -o main .
6. Add Environment Variables
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
# Create a bot & app with BotFather: <https://core.telegram.org/bots#6-botfather>
PINATA_JWT=your_pinata_jwt
PINATA_GATEWAY=your_pinata_gateway
# Sign up for a free account: <https://app.pinata.cloud>
WEB_APP_URL=*
# The URL of your frontend app, you can use ngrok url or a deployed frontend URL later.
7. Test Your Deployment
Once deployed, Render provides a live URL for your service. Follow these steps to test your server:
Example Test with curl:
curl <https://your-service-name.onrender.com/auth> \\
-X POST \\
-H "Content-Type: application/json" \\
-d '{"initData":"test_init_data","isMocked":true}'
Replace your-service-name.onrender.com with your actual Render service URL and your_init_data_here with valid initData from Telegram.
Congrats!??
You just successfully built and deployed a Go server to support file storage and user authentication. In the next part of this series, we'll focus on building the frontend client that interacts with this backend, completing the full-stack application.
Part 2: Frontend Telegram Mini App Client
In this section, we’ll build the frontend React application that interacts with the Golang backend server. The frontend will handle user authentication, file uploads, and displaying uploaded files within the Telegram Mini App interface.
Objectives
By the end of this section, you will:
Step 1: Create Your React Project
Begin by setting up a new React project with TypeScript support. Open your terminal and run the following command in the target directory where you want to create your frontend client:
npx create-react-app your_app_name --template typescript
cd your_app_name
Replace your_app_name with your desired application name.
This command initializes a new React project with TypeScript configuration, setting up all necessary files and dependencies.
Step 2: Install Required Dependencies
Install the necessary packages for integrating with Telegram, managing environment variables, handling API requests, and enhancing UI components
npm install @telegram-apps/sdk-react @telegram-apps/telegram-ui dotenv @tanstack/react-query pinata @fortawesome/react-fontawesome @fortawesome/free-solid-svg-icons
Step 3: Set Up Environment Variables
Create a .env file in your project directory:
touch .env
Add the following environment variables to the .env file:
REACT_APP_SERVER_URL=your_server_url
# Your deployed server URL from Part 1.
REACT_APP_MOCK_TELEGRAM=true
# Set to false to use Telegram init data
Replace your_server_url with the URL of your deployed Golang server from Part 1.
Step 4: Create a Public URL for Telegram
During development, your React application runs on localhost, which isn't accessible to Telegram. To allow Telegram to communicate with your local server, you need to create a public URL that tunnels into your localhost. Ngrok is a popular tool for this purpose.
Retrieve the Forwarded Address: After running the above command, Ngrok will provide a forwarded address similar to:
<https://ca57-159-192-20-21.ngrok-free.app>
Run Ngrok to Create a Public URL:Open a new terminal tab or window and run the following command:
ngrok http <https://localhost:3000>
This command creates a secure tunnel to your local development server running on port 3000.
Note: Remember that Ngrok URLs are temporary and change each time you restart Ngrok unless you have a paid plan with reserved URLs.
Step 5: Update the index.tsx
The index.tsx file is the entry point for your React application. We'll need to wrap our app with several providers to utilize the installed dependencies effectively. Replace your current index.tsx code with the following:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { AppRoot } from '@telegram-apps/telegram-ui';
import './index.css';
import '@telegram-apps/telegram-ui/dist/styles.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
const queryClient = new QueryClient()
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<AppRoot>
<App/>
</AppRoot>
</QueryClientProvider>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: <https://bit.ly/CRA-vitals>
reportWebVitals();
Explanation:
Step 6: View Your Application in Telegram
Since we want to debug and work locally within Telegram, you'll need to access your application through Telegram's interface using the public URL created by Ngrok.
Ensure Your Application is Running: In your project directory, start your React application:
npm run start
This command starts the development server on https://localhost:3000. You should see the following output:
Compiled successfully!
You can now view your_app_name in the browser.
Local: <https://localhost:3000>
On Your Network: <https://192.168.x.x:3000>
Important: Ensure that you have set the Web App URL in BotFather to your Ngrok URL and that your React application is running on localhost:3000. This setup allows Telegram to communicate with your frontend application during development.
Step 7: Handle User Authentication
User authentication ensures that only authorized users can access and interact with your Mini App. We'll manage authentication by handling Telegram's initData and communicating with our Golang backend server.
Locate the App.tsx file in your application and replace the contents with the following code:
import { useEffect, useState } from "react";
import { useLaunchParams } from "@telegram-apps/sdk-react";
import { useTelegramMock } from "./hooks/useMockTelegramInitData";
import "./App.css";
import '@telegram-apps/telegram-ui/dist/styles.css';
import UploadFile from "./components /UploadFiles";
import Files from "./components /Files";
import { Spinner, Title } from "@telegram-apps/telegram-ui";
function App() {
const {initDataRaw} = useLaunchParams() || {};
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [chatId, setChatId] = useState<string>("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useTelegramMock();
const isMocked = process.env.REACT_APP_MOCK_TELEGRAM === "true";
useEffect(() => {
if (initDataRaw) {
const fetchData = async () => {
try {
setIsLoading(true);
const payload: any = {
initData: initDataRaw,
isMocked: isMocked,
};
const serverUrl = process.env.REACT_APP_SERVER_URL;
const response = await fetch(
`${serverUrl}/auth`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
}
);
if (!response.ok) {
throw new Error(
`Server error: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
setIsAuthenticated(true);
setChatId(data.chat_id);
} catch (error) {
setErrorMessage(`An error occurred during authentication: ${error}`);
} finally {
setIsLoading(false);
}
};
fetchData();
} else {
setIsLoading(false);
}
}, [initDataRaw]);
return (
<div style={{width: "100vw", height: "100vh", textAlign: "center", backgroundImage: `url(${"https://refactor_gateway_create.dev-mypinata.cloud/ipfs/bafkreicsb4n2y2evpb7xcqel3bsej2omos4itx3v56sfzmjtp4fh2gzpru"})`,
backgroundSize: 'cover',
overflow: 'hidden',
backgroundRepeat: 'no-repeat', }}>
<div style={{padding: "15px", backgroundColor: "black", display: "flex", alignItems: "center", justifyContent: "space-between", textAlign: "left"}}>
<Title level="1" weight="1">
PinnieBox
</Title>
{isAuthenticated && <UploadFile chatId={chatId}/>}
</div>
{isLoading ? ( <Spinner size="l" /> )
: isAuthenticated ? (<Files chatId={chatId}/>)
: errorMessage ? ( <p>{errorMessage}</p>)
: (<p>User is not authenticated</p>
)}
</div>
);
}
export default App;
The App.tsx component manages user authentication via Telegram. Upon successful authentication, it will display the file upload and management interface; otherwise, it shows an error message. The chatID returned from the Go server is captured and plays a crucial role in the file upload process. useTelegramMock() is a ****custom hook that simulates the Telegram environment for local development. We'll create this hook in the next step.
Step 8: Create the useTelegramMock() Hook
To simulate the Telegram environment during local development, we'll use a custom React hook named useTelegramMock(). This hook generates mock initData when running in development mode, allowing you to test the frontend without relying on real Telegram data.
Add the Hook Code: Open useMockTelegramInitData.ts in your text editor and add the following code:
/* eslint-disable camelcase */
import { mockTelegramEnv, parseInitData, retrieveLaunchParams } from "@telegram-apps/sdk-react";
/**
* Mocks
Telegram environment in development mode.
*/
export function useTelegramMock(): void {
if (process.env.NODE_ENV !== "development") return;
let shouldMock: boolean;
try {
retrieveLaunchParams();
shouldMock = !!sessionStorage.getItem("____mocked");
} catch (e) {
shouldMock = true;
}
if (shouldMock) {
const randomId = Math.floor(Math.random() * 1000000000);
const initDataRaw = new URLSearchParams([
[
"user",
JSON.stringify({
id: randomId,
first_name: "Andrew",
last_name: "Rogue",
username: "rogue",
language_code: "en",
is_premium: true,
allows_write_to_pm: true,
}),
],
["hash", "89d6079ad6762351f38c6dbbc41bb53048019256a9443988af7a48bcad16ba31"],
["auth_date", "1716922846"],
["start_param", "debug"],
["chat_type", "sender"],
["chat_instance", "8428209589180549439"],
]).toString();
mockTelegramEnv({
themeParams: {
accentTextColor: "#6ab2f2",
bgColor: "#17212b",
buttonColor: "#5288c1",
buttonTextColor: "#ffffff",
destructiveTextColor: "#ec3942",
headerBgColor: "#17212b",
hintColor: "#708499",
linkColor: "#6ab3f3",
secondaryBgColor: "#232e3c",
sectionBgColor: "#17212b",
sectionHeaderTextColor: "#6ab3f3",
subtitleTextColor: "#708499",
textColor: "#f5f5f5",
},
initData: parseInitData(initDataRaw),
initDataRaw,
version: "7.7",
platform: "tdesktop",
});
sessionStorage.setItem("____mocked", "1");
}
}
Create the Hook File: Inside the hooks folder, create a file named useMockTelegramInitData.ts:
touch src/hooks/useMockTelegramInitData.ts
Create a Hooks Directory: Inside your src folder, create a new folder named hooks:
mkdir src/hooks
The useTelegramMock hook simulates the Telegram environment by generating mock initData that mimics the data Telegram provides when launching a Mini App. This is particularly useful during local development and testing.
Step 9: Create the Files Query
To efficiently fetch and manage data related to files stored on Pinata, we'll use the @tanstack/react-query library. This library simplifies data fetching, caching, and synchronization in React applications.
Add the Query Code: Open UseFiles.ts in your text editor and add the following code:
import { useQuery } from "@tanstack/react-query";
export const useGetFiles = (chatId: string, pageToken: string) => {
const serverUrl = process.env.REACT_APP_SERVER_URL;
return useQuery({
queryKey: ["getFiles", chatId, pageToken],
queryFn: async () => {
const response = await fetch(
`${serverUrl}/files/${chatId}?pageToken=${pageToken}`,
{
method: "GET",
headers: {
Accept: "application/json",
},
}
);
if (!response.ok) {
throw new Error(`Error: ${response.statusText}`);
}
return response.json();
},
});
};
Create the Query File: Inside the queries folder, create a file named UseFiles.ts:
touch src/hooks/queries/UseFiles.ts
Create a Queries Directory: Inside your hooks folder, create a new folder named queries:
mkdir src/hooks/queries
Step 10: Add the Missing Components
Now it’s time to create the missing components that you see in the App.tsx. These components are responsible for uploading and listing the files that will be stored with Pinata.
a. Uploading Files
We will start with the component responsible for handling file uploads.
Add the Upload Files Code: Open UploadFiles.tsx in your text editor and add the following code:
import { Spinner } from "@telegram-apps/telegram-ui";
import { useState, useEffect, useRef } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faFileUpload,
faCheckCircle,
faTimesCircle,
} from "@fortawesome/free-solid-svg-icons";
import { useGetFiles } from "../hooks/queries/UseFiles";
interface UploadFileProps {
chatId: string;
}
const UploadFile: React.FC<UploadFileProps> = ({ chatId}) => {
const [uploadStatus, setUploadStatus] = useState<
"idle" | "uploading" | "success" | "error"
>("idle");
const fileInputRef = useRef<HTMLInputElement>(null);
const { refetch } = useGetFiles(chatId, "");
const handleFileUpload = async (file: File | undefined) => {
if (!file) return;
const serverUrl = process.env.REACT_APP_SERVER_URL;
try {
setUploadStatus("uploading");
const formData = new FormData();
formData.append("file", file);
formData.append("chat_id", chatId);
const response = await fetch(`${serverUrl}/files`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(
`Upload failed: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
console.log("File uploaded successfully:", data);
setUploadStatus("success");
// Refetch files after successful upload
await refetch();
} catch (error) {
console.error("Error during file upload:", error);
setUploadStatus("error");
}
};
useEffect(() => {
if (uploadStatus === "success" || uploadStatus === "error") {
const timer = setTimeout(() => {
setUploadStatus("idle");
}, 3000);
return () => clearTimeout(timer);
}
}, [uploadStatus]);
return (
<div style={{ textAlign: "center" }}>
{uploadStatus === "idle" && (
<div
onClick={() => fileInputRef.current?.click()}
style={{ cursor: "pointer" }}
>
<FontAwesomeIcon icon={faFileUpload} size="2x" />
<input
type="file"
ref={fileInputRef}
style={{ display: "none" }}
onChange={(e) => handleFileUpload(e.target.files?.[0])}
/>
</div>
)}
{uploadStatus === "uploading" && <Spinner size="l" />}
{uploadStatus === "success" && (
<div>
<FontAwesomeIcon icon={faCheckCircle} size="2x" color="green" />
</div>
)}
{uploadStatus === "error" && (
<div>
<FontAwesomeIcon icon={faTimesCircle} size="2x" color="red" />
</div>
)}
</div>
);
};
export default UploadFile;
Create the Upload Files Component: Inside the components folder, create a file named UploadFiles.tsx:
touch src/components/UploadFiles.tsx
The UploadFiles.tsx component is responsible for sending a selected file to the Go server's POST /files endpoint. Along with the file, it includes the associated chatId, which is extracted from the Telegram init data. This init data was validated on the Go server to ensure security and authenticity before processing.
b. Listing Files
Next, we will create the component responsible for listing and navigating through uploaded files.
Add the Files List Code: Open Files.tsx in your text editor and add the following code:
import { Button } from "@telegram-apps/telegram-ui";
import FileModal from "./FileModal";
import { useGetFiles } from "../hooks/queries/UseFiles";
import { useState } from "react";
interface FilesProps {
chatId: string;
}
const Files: React.FC<FilesProps> = ({ chatId }) => {
const [currentPage, setCurrentPage] = useState<number>(0);
const [pageTokens, setPageTokens] = useState<string[]>([""]);
const pageToken = pageTokens[currentPage];
const { data: files, isLoading, error, isFetching } = useGetFiles(chatId, pageToken);
const handleNextPage = () => {
const nextPageToken = files?.data?.next_page_token;
if (nextPageToken) {
if (!pageTokens.includes(nextPageToken)) {
setPageTokens((prev) => [...prev, nextPageToken]);
}
setCurrentPage((prev) => prev + 1);
}
};
const handlePrevPage = () => {
if (currentPage > 0) {
setCurrentPage((prev) => prev - 1);
}
};
const truncateFileName = (fileName: string, maxLength: number) => {
const extension = fileName.substring(fileName.lastIndexOf("."));
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf("."));
if (nameWithoutExt.length > maxLength) {
return `${nameWithoutExt.substring(0, maxLength)}...${extension}`;
}
return fileName;
};
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
backgroundColor: "white",
marginTop: "20px",
borderRadius: "10px",
}}
>
<Button
style={{
width: "50%",
backgroundColor: "#33f9a1",
color: "black",
}}
disabled={currentPage === 0 || isFetching}
onClick={handlePrevPage}
>
Prev
</Button>
<Button
style={{
width: "50%",
backgroundColor: "#33f9a1",
color: "black",
}}
disabled={files?.data?.files.length < 5 || isFetching}
onClick={handleNextPage}
>
Next
</Button>
</div>
<div>
{error && <p>{`Error: ${error.message}`}</p>}
{isLoading ? (
<p>Loading...</p>
) : (
<div style={{marginTop: "10px"}}>
{files?.data?.files?.map((file: any, fileIndex: number) => (
<div
key={fileIndex}
style={{
display: "flex",
marginTop: "5px",
justifyContent: "space-between",
padding: "15px",
backgroundColor: "black",
opacity: ".9",
borderRadius: "20px",
}}
>
<p>{truncateFileName(file.name, 15)}</p>
<FileModal file={file} />
</div>
))}
</div>
)}
</div>
</div>
);
};
export default Files;
Create the Files List Component: Inside the components folder, create a file named Files.tsx:
touch src/components/Files.tsx
c. Display File Content
Next, we will create the component that allows users to view the uploaded content in a modal.
Add the File Modal Code: Open FileModal.tsx in your text editor and add the following code:
import { Modal, Button, Placeholder, FixedLayout } from "@telegram-apps/telegram-ui";
import { ModalHeader } from "@telegram-apps/telegram-ui/dist/components/Overlays/Modal/components/ModalHeader/ModalHeader";
import { useState } from "react";
const FileModal = ({ file }: any) => {
const [open, setOpen] = useState(false);
const [signedUrl, setSignedUrl] = useState<string | null>(null);
const [loadingUrl, setLoadingUrl] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleOpenChange = async (isOpen: boolean) => {
setOpen(isOpen);
if (isOpen) {
setLoadingUrl(true);
const url = await getSignedUrl(file);
if (url) {
setSignedUrl(url);
} else {
setErrorMessage("Failed to load image.");
}
setLoadingUrl(false);
} else {
setSignedUrl(null);
setErrorMessage(null);
}
};
const getSignedUrl = async (file: any) => {
const serverUrl = process.env.REACT_APP_SERVER_URL;
try {
const response = await fetch(
`${serverUrl}/files/signedUrl/${file.cid}`,
{
method: "GET",
headers: {
Accept: "application/json",
},
}
);
if (!response.ok) {
throw new Error(
`Failed to get signed URL: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
return data.url;
} catch (error) {
console.error("Error getting signed URL:", error);
return null;
}
};
return (
<Modal
header={<ModalHeader>{file.name}</ModalHeader>}
trigger={<Button style={{backgroundColor: "#33f9a1", color: "black"}}>View</Button>}
open={open}
onOpenChange={handleOpenChange}
>
<Placeholder>
{loadingUrl ? (
<p>Loading image...</p>
) : signedUrl ? (
<a
href={signedUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => handleOpenChange(false)}
>
<img
alt={file.name}
src={signedUrl}
style={{
width: '100%',
height: 'auto',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
/>
</a>
) : (
<p>{errorMessage}</p>
)}
</Placeholder>
</Modal>
);
};
export default FileModal;
Create the File Modal Component: Inside the components folder, create a file named FileModal.tsx:
touch src/components/FileModal.tsx
Step 11: Deploy the Frontend Application (Optional)
Deploying your React frontend application ensures that your Telegram Mini App is accessible beyond your local development environment. While this step is optional, deploying your app provides a live URL that Telegram can interact with, enabling real-world usage and testing.
Helpful Notes & Debugging:
This feature allows you to inspect network requests, view console logs, and troubleshoot issues directly within the Telegram environment.
Full Code Reference
Conclusion
Congratulations! ??
You've successfully built and deployed a full-stack Telegram Mini App. This application leverages the power of Go for backend operations and React for a responsive and dynamic frontend experience, all while utilizing Pinata's robust file management capabilities with the Files API!