Skip to content
Open
Show file tree
Hide file tree
Changes from all 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