feat: initial commit of intranet-drive service

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parents
service-account.json
credentials.json
token.json
*.env
bin/
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Code comments
No comments in code unless explicitly requested by the user. Identifiers should be self-explanatory; don't explain what code does.
# intranet-drive
Gin service that uploads/downloads files to Google Drive via a service account.
## Setup
1. Create a Google Cloud service account, enable the Drive API, download its JSON key.
2. Share the target Drive folder with the service account's email (Editor access).
3. Set env vars:
- `GOOGLE_SERVICE_ACCOUNT_FILE` (default `service-account.json`)
- `PORT` (default `8080`)
## Run
```
go run ./cmd
```
## Endpoints
### Upload
`POST /files` — multipart form: `folder_id`, `file`
```
curl -F folder_id=<DRIVE_FOLDER_ID> -F file=@./example.pdf http://localhost:8080/files
```
Response: `{"id": "<DRIVE_FILE_ID>"}`
### Download
`GET /files/:id/download`
```
curl -OJ http://localhost:8080/files/<DRIVE_FILE_ID>/download
```
package main
import (
"context"
"log"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"intranet-drive/internal/config"
"intranet-drive/internal/drive"
"intranet-drive/internal/handler"
)
func main() {
_ = godotenv.Load()
cfg := config.Load()
ctx := context.Background()
driveSvc, err := drive.NewService(ctx, cfg.OAuthCredsFile, cfg.OAuthTokenFile)
if err != nil {
log.Fatalf("failed to init drive service: %v", err)
}
driveHandler := handler.NewDriveHandler(driveSvc)
r := gin.Default()
corsCfg := cors.DefaultConfig()
corsCfg.AllowAllOrigins = true
corsCfg.AllowHeaders = append(corsCfg.AllowHeaders, "Authorization")
r.Use(cors.New(corsCfg))
files := r.Group("/files")
{
files.POST("", driveHandler.Upload)
files.GET("/:id/download", driveHandler.Download)
}
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("server failed: %v", err)
}
}
module intranet-drive
go 1.26.5
require (
github.com/gin-contrib/cors v1.7.7
github.com/gin-gonic/gin v1.12.0
github.com/joho/godotenv v1.5.1
golang.org/x/oauth2 v0.36.0
google.golang.org/api v0.292.0
)
require (
cloud.google.com/go/auth v0.22.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect
github.com/googleapis/gax-go/v2 v2.23.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/grpc v1.83.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
This diff is collapsed. Click to expand it.
package config
import "os"
type Config struct {
Port string
OAuthCredsFile string
OAuthTokenFile string
}
func Load() Config {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
credsFile := os.Getenv("GOOGLE_OAUTH_CREDENTIALS_FILE")
if credsFile == "" {
credsFile = "credentials.json"
}
tokenFile := os.Getenv("GOOGLE_OAUTH_TOKEN_FILE")
if tokenFile == "" {
tokenFile = "token.json"
}
return Config{
Port: port,
OAuthCredsFile: credsFile,
OAuthTokenFile: tokenFile,
}
}
package drive
import "os"
func readFile(path string) ([]byte, error) {
return os.ReadFile(path)
}
func writeFile(path string, data []byte) error {
return os.WriteFile(path, data, 0600)
}
package drive
import (
"context"
"encoding/json"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
)
const oauthRedirectAddr = "localhost:8085"
const oauthRedirectPath = "/oauth/callback"
const downloadCacheDir = "/var/tmp"
const downloadCacheTTL = 10 * time.Minute
type Service struct {
svc *drive.Service
}
func NewService(ctx context.Context, credentialsFile, tokenFile string) (*Service, error) {
data, err := readFile(credentialsFile)
if err != nil {
return nil, fmt.Errorf("reading oauth credentials file: %w", err)
}
config, err := google.ConfigFromJSON(data, drive.DriveScope)
if err != nil {
return nil, fmt.Errorf("parsing oauth client config: %w", err)
}
token, err := tokenFromFile(tokenFile)
if err != nil {
token, err = tokenFromWeb(config)
if err != nil {
return nil, fmt.Errorf("obtaining oauth token: %w", err)
}
if err := saveToken(tokenFile, token); err != nil {
return nil, fmt.Errorf("saving oauth token: %w", err)
}
}
client := config.Client(ctx, token)
svc, err := drive.NewService(ctx, option.WithHTTPClient(client))
if err != nil {
return nil, fmt.Errorf("creating drive service: %w", err)
}
return &Service{svc: svc}, nil
}
func tokenFromFile(path string) (*oauth2.Token, error) {
data, err := readFile(path)
if err != nil {
return nil, err
}
tok := &oauth2.Token{}
if err := json.Unmarshal(data, tok); err != nil {
return nil, err
}
return tok, nil
}
func tokenFromWeb(config *oauth2.Config) (*oauth2.Token, error) {
config.RedirectURL = "http://" + oauthRedirectAddr + oauthRedirectPath
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
mux := http.NewServeMux()
mux.HandleFunc(oauthRedirectPath, func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if code == "" {
errCh <- fmt.Errorf("no code in callback: %s", r.URL.RawQuery)
http.Error(w, "falta el parámetro code", http.StatusBadRequest)
return
}
fmt.Fprint(w, "Autorización recibida, ya podés cerrar esta pestaña.")
codeCh <- code
})
srv := &http.Server{Addr: oauthRedirectAddr, Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- fmt.Errorf("callback server: %w", err)
}
}()
defer srv.Close()
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
fmt.Printf("Abrí este link en el navegador y autorizá el acceso:\n%v\n", authURL)
var code string
select {
case code = <-codeCh:
case err := <-errCh:
return nil, err
}
tok, err := config.Exchange(context.Background(), code)
if err != nil {
return nil, fmt.Errorf("exchanging auth code: %w", err)
}
return tok, nil
}
func saveToken(path string, token *oauth2.Token) error {
data, err := json.MarshalIndent(token, "", " ")
if err != nil {
return err
}
return writeFile(path, data)
}
func (s *Service) UploadFile(ctx context.Context, folderID string, fileHeader *multipart.FileHeader) (string, error) {
f, err := fileHeader.Open()
if err != nil {
return "", fmt.Errorf("opening uploaded file: %w", err)
}
defer f.Close()
metadata := &drive.File{
Name: fileHeader.Filename,
Parents: []string{folderID},
}
created, err := s.svc.Files.Create(metadata).
Media(f).
SupportsAllDrives(true).
Context(ctx).
Fields("id, name, webViewLink").
Do()
if err != nil {
return "", fmt.Errorf("uploading file to drive: %w", err)
}
return created.Id, nil
}
func (s *Service) DownloadFile(ctx context.Context, fileID string) (io.ReadCloser, string, string, error) {
if path, ok := findCachedDownload(fileID); ok {
f, err := os.Open(path)
if err == nil {
name := filepath.Base(path)
mimeType := mime.TypeByExtension(filepath.Ext(path))
if mimeType == "" {
mimeType = "application/octet-stream"
}
return f, name, mimeType, nil
}
}
meta, err := s.svc.Files.Get(fileID).SupportsAllDrives(true).Context(ctx).Fields("name, mimeType").Do()
if err != nil {
return nil, "", "", fmt.Errorf("getting file metadata: %w", err)
}
resp, err := s.svc.Files.Get(fileID).SupportsAllDrives(true).Context(ctx).Download()
if err != nil {
return nil, "", "", fmt.Errorf("downloading file: %w", err)
}
cachePath := filepath.Join(downloadCacheDir, fileID+filepath.Ext(meta.Name))
body, err := cacheDownload(resp.Body, cachePath)
if err != nil {
return nil, "", "", fmt.Errorf("caching downloaded file: %w", err)
}
return body, meta.Name, meta.MimeType, nil
}
func findCachedDownload(fileID string) (string, bool) {
if matches, err := filepath.Glob(filepath.Join(downloadCacheDir, fileID+".*")); err == nil && len(matches) > 0 {
return matches[0], true
}
path := filepath.Join(downloadCacheDir, fileID)
if _, err := os.Stat(path); err == nil {
return path, true
}
return "", false
}
func cacheDownload(body io.ReadCloser, path string) (io.ReadCloser, error) {
defer body.Close()
f, err := os.Create(path)
if err != nil {
return nil, err
}
if _, err := io.Copy(f, body); err != nil {
f.Close()
os.Remove(path)
return nil, err
}
f.Close()
time.AfterFunc(downloadCacheTTL, func() {
os.Remove(path)
})
return os.Open(path)
}
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"intranet-drive/internal/drive"
)
type DriveHandler struct {
drive *drive.Service
}
func NewDriveHandler(d *drive.Service) *DriveHandler {
return &DriveHandler{drive: d}
}
func (h *DriveHandler) Upload(c *gin.Context) {
folderID := c.PostForm("folder_id")
if folderID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "folder_id is required"})
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
fileID, err := h.drive.UploadFile(c.Request.Context(), folderID, fileHeader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": fileID})
}
func (h *DriveHandler) Download(c *gin.Context) {
fileID := c.Param("id")
if fileID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "file id is required"})
return
}
body, name, mimeType, err := h.drive.DownloadFile(c.Request.Context(), fileID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer body.Close()
c.Header("Content-Disposition", "attachment; filename=\""+name+"\"")
c.DataFromReader(http.StatusOK, -1, mimeType, body, nil)
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment