Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"jira-plugin.workingProject": ""
}
81 changes: 78 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

# go-akavelink

A Go-based HTTP server that wraps the Akave SDK, exposing Akave APIs over REST. The previous version of this repository was a CLI wrapper around the Akave SDK; refer to [akavelink](https://github.com/akave-ai/akavelink).
Expand Down Expand Up @@ -102,9 +103,76 @@ Implemented routes (see `internal/handlers/`):

---

## Project Structure
## Input Validation & Security

The API implements comprehensive input validation and sanitization to protect against common attacks:

### Bucket Name Validation

- **Length**: 1-63 characters
- **Allowed characters**: Alphanumeric (a-z, A-Z, 0-9), hyphens (-), underscores (_)
- **Format**: Must start and end with an alphanumeric character
- **Security**: Prevents path traversal attacks, special characters, and malicious patterns

**Examples:**
- ✅ Valid: `my-bucket`, `data_store_123`, `bucket1`
- ❌ Invalid: `../etc`, `my bucket`, `bucket!@#`, `-bucket`, `bucket-`

### File Name Validation

- **Length**: Maximum 255 characters
- **Allowed characters**: Alphanumeric (a-z, A-Z, 0-9), dots (.), hyphens (-), underscores (_)
- **Format**: Must start and end with an alphanumeric character
- **Security**: Prevents path traversal, null bytes, and invalid filename characters

**Examples:**
- ✅ Valid: `document.pdf`, `my-file_v2.txt`, `data.backup.tar.gz`
- ❌ Invalid: `../../../etc/passwd`, `file<>name.txt`, `.hidden`, `file.`

### File Upload Validation

- **Maximum file size**: 100 MB (104,857,600 bytes)
- **Minimum file size**: Must not be empty (> 0 bytes)
- **Allowed MIME types**:
- `application/octet-stream` (binary/default)
- `text/plain`, `text/csv`
- `application/json`, `application/pdf`
- `image/jpeg`, `image/png`, `image/gif`, `image/webp`
- `video/mp4`
- `audio/mpeg`

**Security features:**
- Content-Length validation before parsing
- MIME type verification
- File name sanitization
- Protection against malicious file uploads

### Error Responses

Validation errors return HTTP 400 (Bad Request) with a JSON response:

```json
{
"error": "Validation Error",
"field": "bucketName",
"message": "bucket name must contain only alphanumeric characters, hyphens, and underscores"
}
```

### Security Headers

All responses include security headers:
- `X-Content-Type-Options: nosniff` - Prevents MIME type sniffing
- `X-Frame-Options: DENY` - Prevents clickjacking
- `X-XSS-Protection: 1; mode=block` - Enables XSS protection
- `Content-Security-Policy: default-src 'self'` - Restricts resource loading

---

## Project Structure

```markdown

go-akavelink/
├── CONTRIBUTING.md
├── LICENSE
Expand All @@ -118,11 +186,15 @@ go-akavelink/
│ └── architecture.md
├── internal/
│ ├── handlers/
│ │ ├── router.go # Wires routes only
│ │ ├── router.go # Wires routes and middleware
│ │ ├── response.go # JSON envelope + helpers
│ │ ├── health.go # /health
│ │ ├── buckets.go # bucket endpoints
│ │ └── files.go # file endpoints
│ ├── middleware/
│ │ └── validation.go # Validation middleware
│ ├── validation/
│ │ └── validator.go # Input validation & sanitization
│ ├── sdk/
│ │ └── sdk.go
│ └── utils/
Expand All @@ -131,7 +203,10 @@ go-akavelink/
├── http_endpoints_test.go
├── integration_sdk_test.go
├── main_test.go
└── sdk_test.go
├── sdk_test.go
├── validation_test.go
├── middleware_test.go
└── validation_integration_test.go
```

---
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ require (
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
github.com/spacemonkeygo/monkit/v3 v3.0.24 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/supranational/blst v0.3.14 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,8 @@ github.com/spacemonkeygo/monkit/v3 v3.0.24/go.mod h1:XkZYGzknZwkD0AKUnZaSXhRiVTL
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
Expand Down
19 changes: 15 additions & 4 deletions internal/handlers/buckets.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log"
"net/http"

"github.com/akave-ai/go-akavelink/internal/validation"
"github.com/gorilla/mux"
)

Expand All @@ -17,11 +18,16 @@ func (s *Server) createBucketHandler(w http.ResponseWriter, r *http.Request) {

vars := mux.Vars(r)
bucketName := vars["bucketName"]
if bucketName == "" {
s.writeErrorResponse(w, http.StatusBadRequest, "bucketName is required")

// Validate bucket name
if err := validation.ValidateBucketName(bucketName); err != nil {
s.writeErrorResponse(w, http.StatusBadRequest, err.Error())
return
}

// Sanitize bucket name for extra safety
bucketName = validation.SanitizeBucketName(bucketName)

if err := s.client.CreateBucket(r.Context(), bucketName); err != nil {
s.writeErrorResponse(w, http.StatusInternalServerError, "Failed to create bucket")
log.Printf("create bucket error: %v", err)
Expand All @@ -42,11 +48,16 @@ func (s *Server) deleteBucketHandler(w http.ResponseWriter, r *http.Request) {
}
vars := mux.Vars(r)
bucketName := vars["bucketName"]
if bucketName == "" {
s.writeErrorResponse(w, http.StatusBadRequest, "bucketName is required")

// Validate bucket name
if err := validation.ValidateBucketName(bucketName); err != nil {
s.writeErrorResponse(w, http.StatusBadRequest, err.Error())
return
}

// Sanitize bucket name for extra safety
bucketName = validation.SanitizeBucketName(bucketName)

ctx := r.Context()
files, err := s.client.ListFiles(ctx, bucketName)
if err != nil {
Expand Down
102 changes: 83 additions & 19 deletions internal/handlers/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"log"
"net/http"

"github.com/akave-ai/go-akavelink/internal/validation"
"github.com/gorilla/mux"
)

Expand All @@ -12,11 +13,23 @@ func (s *Server) fileInfoHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
bucketName := vars["bucketName"]
fileName := vars["fileName"]
if bucketName == "" || fileName == "" {
s.writeErrorResponse(w, http.StatusBadRequest, "bucketName and fileName are required")

// Validate bucket name
if err := validation.ValidateBucketName(bucketName); err != nil {
s.writeErrorResponse(w, http.StatusBadRequest, err.Error())
return
}

// Validate file name
if err := validation.ValidateFileName(fileName); err != nil {
s.writeErrorResponse(w, http.StatusBadRequest, err.Error())
return
}

// Sanitize inputs
bucketName = validation.SanitizeBucketName(bucketName)
fileName = validation.SanitizeFileName(fileName)

info, err := s.client.FileInfo(r.Context(), bucketName, fileName)
if err != nil {
s.writeErrorResponse(w, http.StatusInternalServerError, "Failed to retrieve file info")
Expand All @@ -31,11 +44,16 @@ func (s *Server) fileInfoHandler(w http.ResponseWriter, r *http.Request) {
func (s *Server) listFilesHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
bucketName := vars["bucketName"]
if bucketName == "" {
s.writeErrorResponse(w, http.StatusBadRequest, "bucketName is required")

// Validate bucket name
if err := validation.ValidateBucketName(bucketName); err != nil {
s.writeErrorResponse(w, http.StatusBadRequest, err.Error())
return
}

// Sanitize bucket name
bucketName = validation.SanitizeBucketName(bucketName)

files, err := s.client.ListFiles(r.Context(), bucketName)
if err != nil {
s.writeErrorResponse(w, http.StatusInternalServerError, "Failed to list files")
Expand All @@ -50,8 +68,19 @@ func (s *Server) listFilesHandler(w http.ResponseWriter, r *http.Request) {
func (s *Server) uploadHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
bucketName := vars["bucketName"]
if bucketName == "" {
s.writeErrorResponse(w, http.StatusBadRequest, "bucketName is required")

// Validate bucket name
if err := validation.ValidateBucketName(bucketName); err != nil {
s.writeErrorResponse(w, http.StatusBadRequest, err.Error())
return
}

// Sanitize bucket name
bucketName = validation.SanitizeBucketName(bucketName)

// Validate content length before parsing
if err := validation.ValidateContentLength(r.ContentLength); err != nil {
s.writeErrorResponse(w, http.StatusBadRequest, err.Error())
return
}

Expand All @@ -73,8 +102,17 @@ func (s *Server) uploadHandler(w http.ResponseWriter, r *http.Request) {
}
}()

// Validate file upload (size, MIME type, filename)
if err := validation.ValidateFileUpload(handler); err != nil {
s.writeErrorResponse(w, http.StatusBadRequest, err.Error())
return
}

// Sanitize filename
fileName := validation.SanitizeFileName(handler.Filename)

ctx := r.Context()
upload, err := s.client.CreateFileUpload(ctx, bucketName, handler.Filename)
upload, err := s.client.CreateFileUpload(ctx, bucketName, fileName)
if err != nil {
s.writeErrorResponse(w, http.StatusInternalServerError, "Failed to create file upload stream")
log.Printf("create upload error: %v", err)
Expand All @@ -89,14 +127,16 @@ func (s *Server) uploadHandler(w http.ResponseWriter, r *http.Request) {
}

resp := map[string]interface{}{
"message": "File uploaded successfully",
"rootCID": meta.RootCID,
"bucketName": meta.BucketName,
"fileName": meta.Name,
"size": meta.Size,
"encodedSize": meta.EncodedSize,
"createdAt": meta.CreatedAt,
"committedAt": meta.CommittedAt,
"message": "File uploaded successfully",
"rootCID": meta.RootCID,
"bucketName": meta.BucketName,
"fileName": meta.Name,
"originalName": handler.Filename,
"sanitizedName": fileName,
"size": meta.Size,
"encodedSize": meta.EncodedSize,
"createdAt": meta.CreatedAt,
"committedAt": meta.CommittedAt,
}
s.writeSuccessResponse(w, http.StatusCreated, resp)
}
Expand All @@ -106,11 +146,23 @@ func (s *Server) downloadHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
bucketName := vars["bucketName"]
fileName := vars["fileName"]
if bucketName == "" || fileName == "" {
s.writeErrorResponse(w, http.StatusBadRequest, "bucketName and fileName are required")

// Validate bucket name
if err := validation.ValidateBucketName(bucketName); err != nil {
s.writeErrorResponse(w, http.StatusBadRequest, err.Error())
return
}

// Validate file name
if err := validation.ValidateFileName(fileName); err != nil {
s.writeErrorResponse(w, http.StatusBadRequest, err.Error())
return
}

// Sanitize inputs
bucketName = validation.SanitizeBucketName(bucketName)
fileName = validation.SanitizeFileName(fileName)

dl, err := s.client.CreateFileDownload(r.Context(), bucketName, fileName)
if err != nil {
s.writeErrorResponse(w, http.StatusNotFound, "Failed to create file download")
Expand All @@ -131,11 +183,23 @@ func (s *Server) fileDeleteHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
bucketName := vars["bucketName"]
fileName := vars["fileName"]
if bucketName == "" || fileName == "" {
s.writeErrorResponse(w, http.StatusBadRequest, "bucketName and fileName are required")

// Validate bucket name
if err := validation.ValidateBucketName(bucketName); err != nil {
s.writeErrorResponse(w, http.StatusBadRequest, err.Error())
return
}

// Validate file name
if err := validation.ValidateFileName(fileName); err != nil {
s.writeErrorResponse(w, http.StatusBadRequest, err.Error())
return
}

// Sanitize inputs
bucketName = validation.SanitizeBucketName(bucketName)
fileName = validation.SanitizeFileName(fileName)

if err := s.client.FileDelete(r.Context(), bucketName, fileName); err != nil {
s.writeErrorResponse(w, http.StatusInternalServerError, "Failed to delete file")
log.Printf("file delete error: %v", err)
Expand Down
14 changes: 11 additions & 3 deletions internal/handlers/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"net/http"

"github.com/akave-ai/go-akavelink/internal/middleware"
"github.com/gorilla/mux"

sdksym "github.com/akave-ai/akavesdk/sdk"
Expand Down Expand Up @@ -41,16 +42,23 @@ func NewRouter(client ClientAPI) http.Handler {
r := mux.NewRouter().StrictSlash(true)
s := &Server{client: client}

// Apply global middleware
r.Use(middleware.SecurityHeaders)
r.Use(middleware.LogRequest)

// Health check (no validation needed)
r.HandleFunc("/health", s.healthHandler).Methods("GET")

// Buckets
r.HandleFunc("/buckets/{bucketName}", s.createBucketHandler).Methods("POST")
r.HandleFunc("/buckets/{bucketName}", s.deleteBucketHandler).Methods("DELETE")
// Normalized route without trailing slash; keep both for compatibility
r.HandleFunc("/buckets", s.viewBucketHandler).Methods("GET")
// r.HandleFunc("/buckets/", viewBucketHandler).Methods("GET")
// Files

// Files - List and Upload (bucket name only)
r.HandleFunc("/buckets/{bucketName}/files", s.listFilesHandler).Methods("GET")
r.HandleFunc("/buckets/{bucketName}/files", s.uploadHandler).Methods("POST")

// Files - Operations requiring both bucket and file name
r.HandleFunc("/buckets/{bucketName}/files/{fileName}", s.fileInfoHandler).Methods("GET")
r.HandleFunc("/buckets/{bucketName}/files/{fileName}/download", s.downloadHandler).Methods("GET")
r.HandleFunc("/buckets/{bucketName}/files/{fileName}", s.fileDeleteHandler).Methods("DELETE")
Expand Down
Loading