Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View file

@ -0,0 +1,109 @@
// Package client provides a Go SDK for interacting with the MapleFile API.
package client
import (
"context"
)
// Register creates a new user account.
func (c *Client) Register(ctx context.Context, input *RegisterInput) (*RegisterResponse, error) {
var resp RegisterResponse
if err := c.doRequest(ctx, "POST", "/api/v1/register", input, &resp, false); err != nil {
return nil, err
}
return &resp, nil
}
// VerifyEmailCode verifies the email verification code.
func (c *Client) VerifyEmailCode(ctx context.Context, input *VerifyEmailInput) (*VerifyEmailResponse, error) {
var resp VerifyEmailResponse
if err := c.doRequest(ctx, "POST", "/api/v1/verify-email-code", input, &resp, false); err != nil {
return nil, err
}
return &resp, nil
}
// ResendVerification resends the email verification code.
func (c *Client) ResendVerification(ctx context.Context, email string) error {
input := ResendVerificationInput{Email: email}
return c.doRequest(ctx, "POST", "/api/v1/resend-verification", input, nil, false)
}
// RequestOTT requests a One-Time Token for login.
func (c *Client) RequestOTT(ctx context.Context, email string) (*OTTResponse, error) {
input := map[string]string{"email": email}
var resp OTTResponse
if err := c.doRequest(ctx, "POST", "/api/v1/request-ott", input, &resp, false); err != nil {
return nil, err
}
return &resp, nil
}
// VerifyOTT verifies a One-Time Token and returns the encrypted challenge.
func (c *Client) VerifyOTT(ctx context.Context, email, ott string) (*VerifyOTTResponse, error) {
input := map[string]string{
"email": email,
"ott": ott,
}
var resp VerifyOTTResponse
if err := c.doRequest(ctx, "POST", "/api/v1/verify-ott", input, &resp, false); err != nil {
return nil, err
}
return &resp, nil
}
// CompleteLogin completes the login process with the decrypted challenge.
// On success, the client automatically stores the tokens and calls the OnTokenRefresh callback.
func (c *Client) CompleteLogin(ctx context.Context, input *CompleteLoginInput) (*LoginResponse, error) {
var resp LoginResponse
if err := c.doRequest(ctx, "POST", "/api/v1/complete-login", input, &resp, false); err != nil {
return nil, err
}
// Store the tokens
c.SetTokens(resp.AccessToken, resp.RefreshToken)
// Notify callback if set, passing the expiry date
if c.onTokenRefresh != nil {
c.onTokenRefresh(resp.AccessToken, resp.RefreshToken, resp.AccessTokenExpiryDate)
}
return &resp, nil
}
// RefreshToken manually refreshes the access token using the stored refresh token.
// On success, the client automatically updates the stored tokens and calls the OnTokenRefresh callback.
func (c *Client) RefreshToken(ctx context.Context) error {
return c.refreshAccessToken(ctx)
}
// RecoveryInitiate initiates the account recovery process.
func (c *Client) RecoveryInitiate(ctx context.Context, email, method string) (*RecoveryInitiateResponse, error) {
input := RecoveryInitiateInput{
Email: email,
Method: method,
}
var resp RecoveryInitiateResponse
if err := c.doRequest(ctx, "POST", "/api/v1/recovery/initiate", input, &resp, false); err != nil {
return nil, err
}
return &resp, nil
}
// RecoveryVerify verifies the recovery challenge.
func (c *Client) RecoveryVerify(ctx context.Context, input *RecoveryVerifyInput) (*RecoveryVerifyResponse, error) {
var resp RecoveryVerifyResponse
if err := c.doRequest(ctx, "POST", "/api/v1/recovery/verify", input, &resp, false); err != nil {
return nil, err
}
return &resp, nil
}
// RecoveryComplete completes the account recovery and resets credentials.
func (c *Client) RecoveryComplete(ctx context.Context, input *RecoveryCompleteInput) (*RecoveryCompleteResponse, error) {
var resp RecoveryCompleteResponse
if err := c.doRequest(ctx, "POST", "/api/v1/recovery/complete", input, &resp, false); err != nil {
return nil, err
}
return &resp, nil
}

View file

@ -0,0 +1,468 @@
// Package client provides a Go SDK for interacting with the MapleFile API.
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
)
// Logger is an interface for logging API requests.
// This allows the client to work with any logging library (zap, logrus, etc.)
type Logger interface {
// Debug logs a debug message with optional key-value pairs
Debug(msg string, keysAndValues ...interface{})
// Info logs an info message with optional key-value pairs
Info(msg string, keysAndValues ...interface{})
// Warn logs a warning message with optional key-value pairs
Warn(msg string, keysAndValues ...interface{})
// Error logs an error message with optional key-value pairs
Error(msg string, keysAndValues ...interface{})
}
// Client is the MapleFile API client.
type Client struct {
baseURL string
httpClient *http.Client
logger Logger
// Token storage with mutex for thread safety
mu sync.RWMutex
accessToken string
refreshToken string
// Callback when tokens are refreshed
// Parameters: accessToken, refreshToken, accessTokenExpiryDate (RFC3339 format)
onTokenRefresh func(accessToken, refreshToken, accessTokenExpiryDate string)
// Flag to prevent recursive token refresh (atomic for lock-free reads)
isRefreshing atomic.Bool
}
// Predefined environment URLs
const (
// ProductionURL is the production API endpoint
ProductionURL = "https://maplefile.ca"
// LocalURL is the default local development API endpoint
LocalURL = "http://localhost:8000"
)
// Config holds the configuration for creating a new Client.
type Config struct {
// BaseURL is the base URL of the MapleFile API (e.g., "https://maplefile.ca")
// You can use predefined constants: ProductionURL or LocalURL
BaseURL string
// HTTPClient is an optional custom HTTP client. If nil, a default client with 30s timeout is used.
HTTPClient *http.Client
// Logger is an optional logger for API request logging. If nil, no logging is performed.
Logger Logger
}
// New creates a new MapleFile API client with the given configuration.
//
// Security Note: This client uses Go's standard http.Client without certificate
// pinning. This is intentional and secure because:
//
// 1. TLS termination is handled by a reverse proxy (Caddy/Nginx) in production,
// which manages certificates via Let's Encrypt with automatic renewal.
// 2. Go's default TLS configuration already validates certificate chains,
// expiration, and hostname matching against system CA roots.
// 3. The application uses end-to-end encryption (E2EE) - even if TLS were
// compromised, attackers would only see encrypted data they cannot decrypt.
// 4. Certificate pinning would require app updates every 90 days (Let's Encrypt
// rotation) or risk bricking deployed applications.
//
// See: docs/OWASP_AUDIT_REPORT.md (Finding 4.1) for full security analysis.
func New(cfg Config) *Client {
httpClient := cfg.HTTPClient
if httpClient == nil {
// Standard HTTP client with timeout. Certificate pinning is intentionally
// not implemented - see security note above.
httpClient = &http.Client{
Timeout: 30 * time.Second,
}
}
// Ensure baseURL doesn't have trailing slash
baseURL := strings.TrimSuffix(cfg.BaseURL, "/")
return &Client{
baseURL: baseURL,
httpClient: httpClient,
logger: cfg.Logger,
}
}
// NewProduction creates a new MapleFile API client configured for production.
func NewProduction() *Client {
return New(Config{BaseURL: ProductionURL})
}
// NewLocal creates a new MapleFile API client configured for local development.
func NewLocal() *Client {
return New(Config{BaseURL: LocalURL})
}
// NewWithURL creates a new MapleFile API client with a custom URL.
func NewWithURL(baseURL string) *Client {
return New(Config{BaseURL: baseURL})
}
// SetTokens sets the access and refresh tokens for authentication.
func (c *Client) SetTokens(accessToken, refreshToken string) {
c.mu.Lock()
defer c.mu.Unlock()
c.accessToken = accessToken
c.refreshToken = refreshToken
}
// GetTokens returns the current access and refresh tokens.
func (c *Client) GetTokens() (accessToken, refreshToken string) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.accessToken, c.refreshToken
}
// OnTokenRefresh sets a callback function that will be called when tokens are refreshed.
// This is useful for persisting the new tokens to storage.
// The callback receives: accessToken, refreshToken, and accessTokenExpiryDate (RFC3339 format).
func (c *Client) OnTokenRefresh(callback func(accessToken, refreshToken, accessTokenExpiryDate string)) {
c.onTokenRefresh = callback
}
// SetBaseURL changes the base URL of the API.
// This is useful for switching between environments at runtime.
func (c *Client) SetBaseURL(baseURL string) {
c.mu.Lock()
defer c.mu.Unlock()
c.baseURL = strings.TrimSuffix(baseURL, "/")
}
// GetBaseURL returns the current base URL.
func (c *Client) GetBaseURL() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.baseURL
}
// Health checks if the API is healthy.
func (c *Client) Health(ctx context.Context) (*HealthResponse, error) {
var resp HealthResponse
if err := c.doRequest(ctx, "GET", "/health", nil, &resp, false); err != nil {
return nil, err
}
return &resp, nil
}
// Version returns the API version information.
func (c *Client) Version(ctx context.Context) (*VersionResponse, error) {
var resp VersionResponse
if err := c.doRequest(ctx, "GET", "/version", nil, &resp, false); err != nil {
return nil, err
}
return &resp, nil
}
// doRequest performs an HTTP request with automatic token refresh on 401.
func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}, result interface{}, requiresAuth bool) error {
return c.doRequestWithRetry(ctx, method, path, body, result, requiresAuth, true)
}
// doRequestWithRetry performs an HTTP request with optional retry on 401.
func (c *Client) doRequestWithRetry(ctx context.Context, method, path string, body interface{}, result interface{}, requiresAuth bool, allowRetry bool) error {
// Build URL
url := c.baseURL + path
// Log API request
if c.logger != nil {
c.logger.Info("API request", "method", method, "url", url)
}
// Prepare request body
var bodyReader io.Reader
if body != nil {
jsonData, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
bodyReader = bytes.NewReader(jsonData)
}
// Create request
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Set headers
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
// Accept both standard JSON and RFC 9457 problem+json responses
req.Header.Set("Accept", "application/json, application/problem+json")
// Add authorization header if required
if requiresAuth {
c.mu.RLock()
token := c.accessToken
c.mu.RUnlock()
if token == "" {
return &APIError{
ProblemDetail: ProblemDetail{
Status: 401,
Title: "Unauthorized",
Detail: "No access token available",
},
}
}
req.Header.Set("Authorization", fmt.Sprintf("JWT %s", token))
}
// Execute request
resp, err := c.httpClient.Do(req)
if err != nil {
if c.logger != nil {
c.logger.Error("API request failed", "method", method, "url", url, "error", err.Error())
}
return fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
// Log API response
if c.logger != nil {
c.logger.Info("API response", "method", method, "url", url, "status", resp.StatusCode)
}
// Read response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
// Handle 401 with automatic token refresh
// Use atomic.Bool for lock-free check to avoid unnecessary lock acquisition
if resp.StatusCode == http.StatusUnauthorized && requiresAuth && allowRetry && !c.isRefreshing.Load() {
c.mu.Lock()
// Double-check under lock and verify refresh token exists
if c.refreshToken != "" && !c.isRefreshing.Load() {
c.isRefreshing.Store(true)
c.mu.Unlock()
// Attempt to refresh token
refreshErr := c.refreshAccessToken(ctx)
c.isRefreshing.Store(false)
if refreshErr == nil {
// Retry the original request without allowing another retry
return c.doRequestWithRetry(ctx, method, path, body, result, requiresAuth, false)
}
} else {
c.mu.Unlock()
}
}
// Handle error status codes
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return parseErrorResponse(respBody, resp.StatusCode)
}
// Parse successful response
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
}
return nil
}
// refreshAccessToken attempts to refresh the access token using the refresh token.
func (c *Client) refreshAccessToken(ctx context.Context) error {
c.mu.RLock()
refreshToken := c.refreshToken
c.mu.RUnlock()
if refreshToken == "" {
return fmt.Errorf("no refresh token available")
}
// Build refresh request
url := c.baseURL + "/api/v1/token/refresh"
reqBody := map[string]string{
"value": refreshToken, // Backend expects "value" field, not "refresh_token"
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return parseErrorResponse(respBody, resp.StatusCode)
}
// Parse the refresh response
// Note: Backend returns access_token_expiry_date and refresh_token_expiry_date,
// but the callback currently only passes tokens. Expiry dates are available
// in the LoginResponse type if needed for future enhancements.
var tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
AccessTokenExpiryDate string `json:"access_token_expiry_date"`
RefreshTokenExpiryDate string `json:"refresh_token_expiry_date"`
}
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
return err
}
// Update stored tokens
c.mu.Lock()
c.accessToken = tokenResp.AccessToken
c.refreshToken = tokenResp.RefreshToken
c.mu.Unlock()
// Notify callback if set, passing the expiry date so callers can track actual expiration
if c.onTokenRefresh != nil {
c.onTokenRefresh(tokenResp.AccessToken, tokenResp.RefreshToken, tokenResp.AccessTokenExpiryDate)
}
return nil
}
// doRequestRaw performs an HTTP request and returns the raw response body.
// This is useful for endpoints that return non-JSON responses.
func (c *Client) doRequestRaw(ctx context.Context, method, path string, body interface{}, requiresAuth bool) ([]byte, error) {
return c.doRequestRawWithRetry(ctx, method, path, body, requiresAuth, true)
}
// doRequestRawWithRetry performs an HTTP request with optional retry on 401.
func (c *Client) doRequestRawWithRetry(ctx context.Context, method, path string, body interface{}, requiresAuth bool, allowRetry bool) ([]byte, error) {
// Build URL
url := c.baseURL + path
// Log API request
if c.logger != nil {
c.logger.Info("API request", "method", method, "url", url)
}
// Prepare request body - we need to be able to re-read it for retry
var bodyData []byte
if body != nil {
var err error
bodyData, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
}
// Create request
var bodyReader io.Reader
if bodyData != nil {
bodyReader = bytes.NewReader(bodyData)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set headers
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
// Add authorization header if required
if requiresAuth {
c.mu.RLock()
token := c.accessToken
c.mu.RUnlock()
if token == "" {
return nil, &APIError{
ProblemDetail: ProblemDetail{
Status: 401,
Title: "Unauthorized",
Detail: "No access token available",
},
}
}
req.Header.Set("Authorization", fmt.Sprintf("JWT %s", token))
}
// Execute request
resp, err := c.httpClient.Do(req)
if err != nil {
if c.logger != nil {
c.logger.Error("API request failed", "method", method, "url", url, "error", err.Error())
}
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
// Log API response
if c.logger != nil {
c.logger.Info("API response", "method", method, "url", url, "status", resp.StatusCode)
}
// Read response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
// Handle 401 with automatic token refresh
if resp.StatusCode == http.StatusUnauthorized && requiresAuth && allowRetry && !c.isRefreshing.Load() {
c.mu.Lock()
if c.refreshToken != "" && !c.isRefreshing.Load() {
c.isRefreshing.Store(true)
c.mu.Unlock()
// Attempt to refresh token
refreshErr := c.refreshAccessToken(ctx)
c.isRefreshing.Store(false)
if refreshErr == nil {
// Retry the original request without allowing another retry
return c.doRequestRawWithRetry(ctx, method, path, body, requiresAuth, false)
}
} else {
c.mu.Unlock()
}
}
// Handle error status codes
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, parseErrorResponse(respBody, resp.StatusCode)
}
return respBody, nil
}

View file

@ -0,0 +1,165 @@
// Package client provides a Go SDK for interacting with the MapleFile API.
package client
import (
"context"
"fmt"
)
// CreateCollection creates a new collection.
func (c *Client) CreateCollection(ctx context.Context, input *CreateCollectionInput) (*Collection, error) {
var resp Collection
if err := c.doRequest(ctx, "POST", "/api/v1/collections", input, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// ListCollections returns all collections for the current user.
func (c *Client) ListCollections(ctx context.Context) ([]*Collection, error) {
var resp struct {
Collections []*Collection `json:"collections"`
}
if err := c.doRequest(ctx, "GET", "/api/v1/collections", nil, &resp, true); err != nil {
return nil, err
}
return resp.Collections, nil
}
// GetCollection returns a single collection by ID.
func (c *Client) GetCollection(ctx context.Context, id string) (*Collection, error) {
path := fmt.Sprintf("/api/v1/collections/%s", id)
var resp Collection
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// UpdateCollection updates a collection.
func (c *Client) UpdateCollection(ctx context.Context, id string, input *UpdateCollectionInput) (*Collection, error) {
path := fmt.Sprintf("/api/v1/collections/%s", id)
var resp Collection
if err := c.doRequest(ctx, "PUT", path, input, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// DeleteCollection soft-deletes a collection.
func (c *Client) DeleteCollection(ctx context.Context, id string) error {
path := fmt.Sprintf("/api/v1/collections/%s", id)
return c.doRequest(ctx, "DELETE", path, nil, nil, true)
}
// GetRootCollections returns all root-level collections (no parent).
func (c *Client) GetRootCollections(ctx context.Context) ([]*Collection, error) {
var resp struct {
Collections []*Collection `json:"collections"`
}
if err := c.doRequest(ctx, "GET", "/api/v1/collections/root", nil, &resp, true); err != nil {
return nil, err
}
return resp.Collections, nil
}
// GetCollectionsByParent returns all collections with the specified parent.
func (c *Client) GetCollectionsByParent(ctx context.Context, parentID string) ([]*Collection, error) {
path := fmt.Sprintf("/api/v1/collections/parent/%s", parentID)
var resp struct {
Collections []*Collection `json:"collections"`
}
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
return nil, err
}
return resp.Collections, nil
}
// MoveCollection moves a collection to a new parent.
func (c *Client) MoveCollection(ctx context.Context, id string, input *MoveCollectionInput) (*Collection, error) {
path := fmt.Sprintf("/api/v1/collections/%s/move", id)
var resp Collection
if err := c.doRequest(ctx, "PUT", path, input, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// ShareCollection shares a collection with another user.
func (c *Client) ShareCollection(ctx context.Context, id string, input *ShareCollectionInput) error {
path := fmt.Sprintf("/api/v1/collections/%s/share", id)
return c.doRequest(ctx, "POST", path, input, nil, true)
}
// RemoveCollectionMember removes a user from a shared collection.
func (c *Client) RemoveCollectionMember(ctx context.Context, collectionID, userID string) error {
path := fmt.Sprintf("/api/v1/collections/%s/members/%s", collectionID, userID)
return c.doRequest(ctx, "DELETE", path, nil, nil, true)
}
// ListSharedCollections returns all collections shared with the current user.
func (c *Client) ListSharedCollections(ctx context.Context) ([]*Collection, error) {
var resp struct {
Collections []*Collection `json:"collections"`
}
if err := c.doRequest(ctx, "GET", "/api/v1/collections/shared", nil, &resp, true); err != nil {
return nil, err
}
return resp.Collections, nil
}
// ArchiveCollection archives a collection.
func (c *Client) ArchiveCollection(ctx context.Context, id string) (*Collection, error) {
path := fmt.Sprintf("/api/v1/collections/%s/archive", id)
var resp Collection
if err := c.doRequest(ctx, "PUT", path, nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// RestoreCollection restores an archived collection.
func (c *Client) RestoreCollection(ctx context.Context, id string) (*Collection, error) {
path := fmt.Sprintf("/api/v1/collections/%s/restore", id)
var resp Collection
if err := c.doRequest(ctx, "PUT", path, nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// GetFilteredCollections returns collections matching the specified filter.
func (c *Client) GetFilteredCollections(ctx context.Context, filter *CollectionFilter) ([]*Collection, error) {
path := "/api/v1/collections/filtered"
if filter != nil {
params := ""
if filter.State != "" {
params += fmt.Sprintf("state=%s", filter.State)
}
if filter.ParentID != "" {
if params != "" {
params += "&"
}
params += fmt.Sprintf("parent_id=%s", filter.ParentID)
}
if params != "" {
path += "?" + params
}
}
var resp struct {
Collections []*Collection `json:"collections"`
}
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
return nil, err
}
return resp.Collections, nil
}
// SyncCollections fetches collection changes since the given cursor.
func (c *Client) SyncCollections(ctx context.Context, input *SyncInput) (*CollectionSyncResponse, error) {
var resp CollectionSyncResponse
if err := c.doRequest(ctx, "POST", "/api/v1/collections/sync", input, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}

View file

@ -0,0 +1,157 @@
// Package client provides a Go SDK for interacting with the MapleFile API.
package client
import (
"encoding/json"
"fmt"
"strings"
)
// ProblemDetail represents an RFC 9457 problem detail response from the API.
type ProblemDetail struct {
Type string `json:"type"`
Status int `json:"status"`
Title string `json:"title"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
Errors map[string]string `json:"errors,omitempty"`
Timestamp string `json:"timestamp"`
TraceID string `json:"trace_id,omitempty"`
}
// APIError wraps ProblemDetail for the error interface.
type APIError struct {
ProblemDetail
}
// Error returns a formatted error message from the ProblemDetail.
func (e *APIError) Error() string {
var errMsg strings.Builder
if e.Detail != "" {
errMsg.WriteString(e.Detail)
} else {
errMsg.WriteString(e.Title)
}
if len(e.Errors) > 0 {
errMsg.WriteString("\n\nValidation errors:")
for field, message := range e.Errors {
errMsg.WriteString(fmt.Sprintf("\n - %s: %s", field, message))
}
}
return errMsg.String()
}
// StatusCode returns the HTTP status code from the error.
func (e *APIError) StatusCode() int {
return e.Status
}
// GetValidationErrors returns the validation errors map.
func (e *APIError) GetValidationErrors() map[string]string {
return e.Errors
}
// GetFieldError returns the error message for a specific field, or empty string if not found.
func (e *APIError) GetFieldError(field string) string {
if e.Errors == nil {
return ""
}
return e.Errors[field]
}
// HasFieldError checks if a specific field has a validation error.
func (e *APIError) HasFieldError(field string) bool {
if e.Errors == nil {
return false
}
_, exists := e.Errors[field]
return exists
}
// IsNotFound checks if the error is a 404 Not Found error.
func IsNotFound(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return apiErr.Status == 404
}
return false
}
// IsUnauthorized checks if the error is a 401 Unauthorized error.
func IsUnauthorized(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return apiErr.Status == 401
}
return false
}
// IsForbidden checks if the error is a 403 Forbidden error.
func IsForbidden(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return apiErr.Status == 403
}
return false
}
// IsValidationError checks if the error has validation errors.
func IsValidationError(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return len(apiErr.Errors) > 0
}
return false
}
// IsConflict checks if the error is a 409 Conflict error.
func IsConflict(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return apiErr.Status == 409
}
return false
}
// IsTooManyRequests checks if the error is a 429 Too Many Requests error.
func IsTooManyRequests(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return apiErr.Status == 429
}
return false
}
// parseErrorResponse attempts to parse an error response body into an APIError.
// It tries RFC 9457 format first, then falls back to legacy format.
//
// Note: RFC 9457 specifies that error responses should use Content-Type: application/problem+json,
// but we parse based on the response structure rather than Content-Type for maximum compatibility.
func parseErrorResponse(body []byte, statusCode int) error {
// Try to parse as RFC 9457 ProblemDetail
// The presence of the "type" field distinguishes RFC 9457 from legacy responses
var problem ProblemDetail
if err := json.Unmarshal(body, &problem); err == nil && problem.Type != "" {
return &APIError{ProblemDetail: problem}
}
// Fallback for non-RFC 9457 errors
var errorResponse map[string]interface{}
if err := json.Unmarshal(body, &errorResponse); err == nil {
if errMsg, ok := errorResponse["message"].(string); ok {
return &APIError{
ProblemDetail: ProblemDetail{
Status: statusCode,
Title: errMsg,
Detail: errMsg,
},
}
}
}
// Last resort: return raw body as error
return &APIError{
ProblemDetail: ProblemDetail{
Status: statusCode,
Title: fmt.Sprintf("HTTP %d", statusCode),
Detail: string(body),
},
}
}

View file

@ -0,0 +1,177 @@
package client_test
import (
"context"
"fmt"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/client"
)
// Example of handling RFC 9457 errors with validation details
func ExampleAPIError_validation() {
c := client.NewLocal()
// Attempt to register with invalid data
_, err := c.Register(context.Background(), &client.RegisterInput{
Email: "", // Missing required field
FirstName: "", // Missing required field
// ... other fields
})
if err != nil {
// Check if it's an API error
if apiErr, ok := err.(*client.APIError); ok {
fmt.Printf("Error Type: %s\n", apiErr.Type)
fmt.Printf("Status: %d\n", apiErr.Status)
fmt.Printf("Title: %s\n", apiErr.Title)
// Check for validation errors
if client.IsValidationError(err) {
fmt.Println("\nValidation Errors:")
for field, message := range apiErr.GetValidationErrors() {
fmt.Printf(" %s: %s\n", field, message)
}
// Check for specific field error
if apiErr.HasFieldError("email") {
fmt.Printf("\nEmail error: %s\n", apiErr.GetFieldError("email"))
}
}
}
}
}
// Example of checking specific error types
func ExampleAPIError_statusChecks() {
c := client.NewProduction()
user, err := c.GetMe(context.Background())
if err != nil {
// Use helper functions to check error types
switch {
case client.IsUnauthorized(err):
fmt.Println("Authentication required - please login")
// Redirect to login
case client.IsNotFound(err):
fmt.Println("User not found")
// Handle not found
case client.IsForbidden(err):
fmt.Println("Access denied")
// Show permission error
case client.IsTooManyRequests(err):
fmt.Println("Rate limit exceeded - please try again later")
// Implement backoff
case client.IsValidationError(err):
fmt.Println("Validation failed - please check your input")
// Show validation errors
default:
fmt.Printf("Unexpected error: %v\n", err)
}
return
}
fmt.Printf("Welcome, %s!\n", user.Name)
}
// Example of extracting error details for logging
func ExampleAPIError_logging() {
c := client.NewProduction()
_, err := c.CreateCollection(context.Background(), &client.CreateCollectionInput{
Name: "Test Collection",
})
if err != nil {
if apiErr, ok := err.(*client.APIError); ok {
// Log structured error details
fmt.Printf("API Error Details:\n")
fmt.Printf(" Type: %s\n", apiErr.Type)
fmt.Printf(" Status: %d\n", apiErr.StatusCode())
fmt.Printf(" Title: %s\n", apiErr.Title)
fmt.Printf(" Detail: %s\n", apiErr.Detail)
fmt.Printf(" Instance: %s\n", apiErr.Instance)
fmt.Printf(" TraceID: %s\n", apiErr.TraceID)
fmt.Printf(" Timestamp: %s\n", apiErr.Timestamp)
if len(apiErr.Errors) > 0 {
fmt.Println(" Field Errors:")
for field, msg := range apiErr.Errors {
fmt.Printf(" %s: %s\n", field, msg)
}
}
}
}
}
// Example of handling errors in a form validation context
func ExampleAPIError_formValidation() {
c := client.NewLocal()
type FormData struct {
Email string
FirstName string
LastName string
Password string
}
form := FormData{
Email: "invalid-email",
FirstName: "",
LastName: "Doe",
Password: "weak",
}
_, err := c.Register(context.Background(), &client.RegisterInput{
Email: form.Email,
FirstName: form.FirstName,
LastName: form.LastName,
// ... other fields
})
if err != nil {
if apiErr, ok := err.(*client.APIError); ok {
// Build form error messages
formErrors := make(map[string]string)
if apiErr.HasFieldError("email") {
formErrors["email"] = apiErr.GetFieldError("email")
}
if apiErr.HasFieldError("first_name") {
formErrors["first_name"] = apiErr.GetFieldError("first_name")
}
if apiErr.HasFieldError("last_name") {
formErrors["last_name"] = apiErr.GetFieldError("last_name")
}
// Display errors to user
for field, msg := range formErrors {
fmt.Printf("Form field '%s': %s\n", field, msg)
}
}
}
}
// Example of handling conflict errors
func ExampleAPIError_conflict() {
c := client.NewProduction()
_, err := c.Register(context.Background(), &client.RegisterInput{
Email: "existing@example.com",
// ... other fields
})
if err != nil {
if client.IsConflict(err) {
if apiErr, ok := err.(*client.APIError); ok {
// The Detail field contains the conflict explanation
fmt.Printf("Registration failed: %s\n", apiErr.Detail)
// Output: "Registration failed: User with this email already exists"
}
}
}
}

View file

@ -0,0 +1,191 @@
// Package client provides a Go SDK for interacting with the MapleFile API.
package client
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
)
// CreatePendingFile creates a new file in pending state.
func (c *Client) CreatePendingFile(ctx context.Context, input *CreateFileInput) (*PendingFile, error) {
var resp PendingFile
if err := c.doRequest(ctx, "POST", "/api/v1/files/pending", input, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// GetFile returns a single file by ID.
func (c *Client) GetFile(ctx context.Context, id string) (*File, error) {
path := fmt.Sprintf("/api/v1/file/%s", id)
var resp File
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// UpdateFile updates a file's metadata.
func (c *Client) UpdateFile(ctx context.Context, id string, input *UpdateFileInput) (*File, error) {
path := fmt.Sprintf("/api/v1/file/%s", id)
var resp File
if err := c.doRequest(ctx, "PUT", path, input, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// DeleteFile soft-deletes a file.
func (c *Client) DeleteFile(ctx context.Context, id string) error {
path := fmt.Sprintf("/api/v1/file/%s", id)
return c.doRequest(ctx, "DELETE", path, nil, nil, true)
}
// DeleteMultipleFiles deletes multiple files at once.
func (c *Client) DeleteMultipleFiles(ctx context.Context, fileIDs []string) error {
input := DeleteMultipleFilesInput{FileIDs: fileIDs}
return c.doRequest(ctx, "POST", "/api/v1/files/delete-multiple", input, nil, true)
}
// GetPresignedUploadURL gets a presigned URL for uploading file content.
func (c *Client) GetPresignedUploadURL(ctx context.Context, fileID string) (*PresignedURL, error) {
path := fmt.Sprintf("/api/v1/file/%s/upload-url", fileID)
var resp PresignedURL
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// CompleteFileUpload marks the file upload as complete and transitions it to active state.
func (c *Client) CompleteFileUpload(ctx context.Context, fileID string, input *CompleteUploadInput) (*File, error) {
path := fmt.Sprintf("/api/v1/file/%s/complete", fileID)
var resp File
if err := c.doRequest(ctx, "POST", path, input, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// GetPresignedDownloadURL gets a presigned URL for downloading file content.
func (c *Client) GetPresignedDownloadURL(ctx context.Context, fileID string) (*PresignedDownloadResponse, error) {
path := fmt.Sprintf("/api/v1/file/%s/download-url", fileID)
var resp PresignedDownloadResponse
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// ReportDownloadCompleted reports that a file download has completed.
func (c *Client) ReportDownloadCompleted(ctx context.Context, fileID string) error {
path := fmt.Sprintf("/api/v1/file/%s/download-completed", fileID)
return c.doRequest(ctx, "POST", path, nil, nil, true)
}
// ArchiveFile archives a file.
func (c *Client) ArchiveFile(ctx context.Context, id string) (*File, error) {
path := fmt.Sprintf("/api/v1/file/%s/archive", id)
var resp File
if err := c.doRequest(ctx, "PUT", path, nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// RestoreFile restores an archived file.
func (c *Client) RestoreFile(ctx context.Context, id string) (*File, error) {
path := fmt.Sprintf("/api/v1/file/%s/restore", id)
var resp File
if err := c.doRequest(ctx, "PUT", path, nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// ListFilesByCollection returns all files in a collection.
func (c *Client) ListFilesByCollection(ctx context.Context, collectionID string) ([]*File, error) {
path := fmt.Sprintf("/api/v1/collection/%s/files", collectionID)
var resp struct {
Files []*File `json:"files"`
}
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
return nil, err
}
return resp.Files, nil
}
// ListRecentFiles returns the user's recent files.
func (c *Client) ListRecentFiles(ctx context.Context) ([]*File, error) {
var resp struct {
Files []*File `json:"files"`
}
if err := c.doRequest(ctx, "GET", "/api/v1/files/recent", nil, &resp, true); err != nil {
return nil, err
}
return resp.Files, nil
}
// SyncFiles fetches file changes since the given cursor.
func (c *Client) SyncFiles(ctx context.Context, input *SyncInput) (*FileSyncResponse, error) {
var resp FileSyncResponse
if err := c.doRequest(ctx, "POST", "/api/v1/files/sync", input, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// UploadToPresignedURL uploads data to an S3 presigned URL.
// This is a helper method for uploading encrypted file content directly to S3.
func (c *Client) UploadToPresignedURL(ctx context.Context, presignedURL string, data []byte, contentType string) error {
req, err := http.NewRequestWithContext(ctx, "PUT", presignedURL, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("failed to create upload request: %w", err)
}
req.Header.Set("Content-Type", contentType)
req.ContentLength = int64(len(data))
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to upload to presigned URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// DownloadFromPresignedURL downloads data from an S3 presigned URL.
// This is a helper method for downloading encrypted file content directly from S3.
func (c *Client) DownloadFromPresignedURL(ctx context.Context, presignedURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", presignedURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create download request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to download from presigned URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("download failed with status %d: %s", resp.StatusCode, string(body))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read download response: %w", err)
}
return data, nil
}

View file

@ -0,0 +1,123 @@
// Package client provides a Go SDK for interacting with the MapleFile API.
package client
import (
"context"
"fmt"
)
// CreateTag creates a new tag.
func (c *Client) CreateTag(ctx context.Context, input *CreateTagInput) (*Tag, error) {
var resp Tag
if err := c.doRequest(ctx, "POST", "/api/v1/tags", input, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// ListTags returns all tags for the current user.
func (c *Client) ListTags(ctx context.Context) ([]*Tag, error) {
var resp ListTagsResponse
if err := c.doRequest(ctx, "GET", "/api/v1/tags", nil, &resp, true); err != nil {
return nil, err
}
return resp.Tags, nil
}
// GetTag returns a single tag by ID.
func (c *Client) GetTag(ctx context.Context, id string) (*Tag, error) {
path := fmt.Sprintf("/api/v1/tags/%s", id)
var resp Tag
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// UpdateTag updates a tag.
func (c *Client) UpdateTag(ctx context.Context, id string, input *UpdateTagInput) (*Tag, error) {
path := fmt.Sprintf("/api/v1/tags/%s", id)
var resp Tag
if err := c.doRequest(ctx, "PUT", path, input, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// DeleteTag deletes a tag.
func (c *Client) DeleteTag(ctx context.Context, id string) error {
path := fmt.Sprintf("/api/v1/tags/%s", id)
return c.doRequest(ctx, "DELETE", path, nil, nil, true)
}
// AssignTag assigns a tag to a collection or file.
func (c *Client) AssignTag(ctx context.Context, input *CreateTagAssignmentInput) (*TagAssignment, error) {
path := fmt.Sprintf("/api/v1/tags/%s/assign", input.TagID)
// Create request body without TagID (since it's in the URL)
requestBody := map[string]string{
"entity_id": input.EntityID,
"entity_type": input.EntityType,
}
var resp TagAssignment
if err := c.doRequest(ctx, "POST", path, requestBody, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// UnassignTag removes a tag from a collection or file.
func (c *Client) UnassignTag(ctx context.Context, tagID, entityID, entityType string) error {
path := fmt.Sprintf("/api/v1/tags/%s/entities/%s?entity_type=%s", tagID, entityID, entityType)
return c.doRequest(ctx, "DELETE", path, nil, nil, true)
}
// GetTagsForEntity returns all tags assigned to a specific entity (collection or file).
func (c *Client) GetTagsForEntity(ctx context.Context, entityID, entityType string) ([]*Tag, error) {
path := fmt.Sprintf("/api/v1/tags/%s/%s", entityType, entityID)
var resp ListTagsResponse
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
return nil, err
}
return resp.Tags, nil
}
// GetTagAssignments returns all assignments for a specific tag.
func (c *Client) GetTagAssignments(ctx context.Context, tagID string) ([]*TagAssignment, error) {
path := fmt.Sprintf("/api/v1/tags/%s/assignments", tagID)
var resp ListTagAssignmentsResponse
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
return nil, err
}
return resp.TagAssignments, nil
}
// SearchByTags searches for collections and files matching ALL the specified tags.
// tagIDs: slice of tag UUIDs to search for
// limit: maximum number of results (default 50, max 100 on backend)
func (c *Client) SearchByTags(ctx context.Context, tagIDs []string, limit int) (*SearchByTagsResponse, error) {
if len(tagIDs) == 0 {
return nil, fmt.Errorf("at least one tag ID is required")
}
// Build query string with comma-separated tag IDs
tags := ""
for i, id := range tagIDs {
if i > 0 {
tags += ","
}
tags += id
}
path := fmt.Sprintf("/api/v1/tags/search?tags=%s", tags)
if limit > 0 {
path += fmt.Sprintf("&limit=%d", limit)
}
var resp SearchByTagsResponse
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}

View file

@ -0,0 +1,598 @@
// Package client provides a Go SDK for interacting with the MapleFile API.
package client
import "time"
// -----------------------------------------------------------------------------
// Health & Version Types
// -----------------------------------------------------------------------------
// HealthResponse represents the health check response.
type HealthResponse struct {
Status string `json:"status"`
}
// VersionResponse represents the API version response.
type VersionResponse struct {
Version string `json:"version"`
}
// -----------------------------------------------------------------------------
// Authentication Types
// -----------------------------------------------------------------------------
// RegisterInput represents the registration request.
type RegisterInput struct {
BetaAccessCode string `json:"beta_access_code"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Phone string `json:"phone"`
Country string `json:"country"`
Timezone string `json:"timezone"`
PasswordSalt string `json:"salt"`
KDFAlgorithm string `json:"kdf_algorithm"`
KDFIterations int `json:"kdf_iterations"`
KDFMemory int `json:"kdf_memory"`
KDFParallelism int `json:"kdf_parallelism"`
KDFSaltLength int `json:"kdf_salt_length"`
KDFKeyLength int `json:"kdf_key_length"`
EncryptedMasterKey string `json:"encryptedMasterKey"`
PublicKey string `json:"publicKey"`
EncryptedPrivateKey string `json:"encryptedPrivateKey"`
EncryptedRecoveryKey string `json:"encryptedRecoveryKey"`
MasterKeyEncryptedWithRecoveryKey string `json:"masterKeyEncryptedWithRecoveryKey"`
AgreeTermsOfService bool `json:"agree_terms_of_service"`
AgreePromotions bool `json:"agree_promotions"`
AgreeToTrackingAcrossThirdPartyAppsAndServices bool `json:"agree_to_tracking_across_third_party_apps_and_services"`
}
// RegisterResponse represents the registration response.
type RegisterResponse struct {
Message string `json:"message"`
UserID string `json:"user_id"`
}
// VerifyEmailInput represents the email verification request.
type VerifyEmailInput struct {
Email string `json:"email"`
Code string `json:"code"`
}
// VerifyEmailResponse represents the email verification response.
type VerifyEmailResponse struct {
Message string `json:"message"`
Success bool `json:"success"`
}
// ResendVerificationInput represents the resend verification request.
type ResendVerificationInput struct {
Email string `json:"email"`
}
// OTTResponse represents the OTT request response.
type OTTResponse struct {
Message string `json:"message"`
Success bool `json:"success"`
}
// VerifyOTTResponse represents the OTT verification response.
type VerifyOTTResponse struct {
Message string `json:"message"`
ChallengeID string `json:"challengeId"`
EncryptedChallenge string `json:"encryptedChallenge"`
Salt string `json:"salt"`
EncryptedMasterKey string `json:"encryptedMasterKey"`
EncryptedPrivateKey string `json:"encryptedPrivateKey"`
PublicKey string `json:"publicKey"`
// KDFAlgorithm specifies which key derivation algorithm to use.
// Values: "PBKDF2-SHA256" (web frontend) or "argon2id" (native app legacy)
KDFAlgorithm string `json:"kdfAlgorithm"`
}
// CompleteLoginInput represents the complete login request.
type CompleteLoginInput struct {
Email string `json:"email"`
ChallengeID string `json:"challengeId"`
DecryptedData string `json:"decryptedData"`
}
// LoginResponse represents the login response (from complete-login or token refresh).
type LoginResponse struct {
Message string `json:"message"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
AccessTokenExpiryDate string `json:"access_token_expiry_date"`
RefreshTokenExpiryDate string `json:"refresh_token_expiry_date"`
Username string `json:"username"`
}
// RefreshTokenInput represents the token refresh request.
type RefreshTokenInput struct {
RefreshToken string `json:"value"`
}
// RecoveryInitiateInput represents the recovery initiation request.
type RecoveryInitiateInput struct {
Email string `json:"email"`
Method string `json:"method"` // "recovery_key"
}
// RecoveryInitiateResponse represents the recovery initiation response.
type RecoveryInitiateResponse struct {
Message string `json:"message"`
SessionID string `json:"session_id"`
EncryptedChallenge string `json:"encrypted_challenge"`
}
// RecoveryVerifyInput represents the recovery verification request.
type RecoveryVerifyInput struct {
SessionID string `json:"session_id"`
DecryptedChallenge string `json:"decrypted_challenge"`
}
// RecoveryVerifyResponse represents the recovery verification response.
type RecoveryVerifyResponse struct {
Message string `json:"message"`
RecoveryToken string `json:"recovery_token"`
CanResetCredentials bool `json:"can_reset_credentials"`
}
// RecoveryCompleteInput represents the recovery completion request.
type RecoveryCompleteInput struct {
RecoveryToken string `json:"recovery_token"`
NewSalt string `json:"new_salt"`
NewPublicKey string `json:"new_public_key"`
NewEncryptedMasterKey string `json:"new_encrypted_master_key"`
NewEncryptedPrivateKey string `json:"new_encrypted_private_key"`
NewEncryptedRecoveryKey string `json:"new_encrypted_recovery_key"`
NewMasterKeyEncryptedWithRecoveryKey string `json:"new_master_key_encrypted_with_recovery_key"`
}
// RecoveryCompleteResponse represents the recovery completion response.
type RecoveryCompleteResponse struct {
Message string `json:"message"`
Success bool `json:"success"`
}
// -----------------------------------------------------------------------------
// User/Profile Types
// -----------------------------------------------------------------------------
// User represents a user profile.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Name string `json:"name"`
LexicalName string `json:"lexical_name"`
Role int8 `json:"role"`
Phone string `json:"phone,omitempty"`
Country string `json:"country,omitempty"`
Timezone string `json:"timezone"`
Region string `json:"region,omitempty"`
City string `json:"city,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
AddressLine1 string `json:"address_line1,omitempty"`
AddressLine2 string `json:"address_line2,omitempty"`
AgreePromotions bool `json:"agree_promotions,omitempty"`
AgreeToTrackingAcrossThirdPartyAppsAndServices bool `json:"agree_to_tracking_across_third_party_apps_and_services,omitempty"`
ShareNotificationsEnabled *bool `json:"share_notifications_enabled,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
Status int8 `json:"status"`
ProfileVerificationStatus int8 `json:"profile_verification_status,omitempty"`
WebsiteURL string `json:"website_url"`
Description string `json:"description"`
ComicBookStoreName string `json:"comic_book_store_name,omitempty"`
}
// UpdateUserInput represents the user update request.
type UpdateUserInput struct {
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Phone string `json:"phone,omitempty"`
Country string `json:"country,omitempty"`
Region string `json:"region,omitempty"`
Timezone string `json:"timezone"`
AgreePromotions bool `json:"agree_promotions,omitempty"`
AgreeToTrackingAcrossThirdPartyAppsAndServices bool `json:"agree_to_tracking_across_third_party_apps_and_services,omitempty"`
ShareNotificationsEnabled *bool `json:"share_notifications_enabled,omitempty"`
}
// DeleteUserInput represents the user deletion request.
type DeleteUserInput struct {
Password string `json:"password"`
}
// PublicUser represents public user information returned from lookup.
type PublicUser struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
PublicKeyInBase64 string `json:"public_key_in_base64"`
VerificationID string `json:"verification_id"`
}
// -----------------------------------------------------------------------------
// Blocked Email Types
// -----------------------------------------------------------------------------
// CreateBlockedEmailInput represents the blocked email creation request.
type CreateBlockedEmailInput struct {
Email string `json:"email"`
Reason string `json:"reason,omitempty"`
}
// BlockedEmail represents a blocked email entry.
type BlockedEmail struct {
UserID string `json:"user_id"`
BlockedEmail string `json:"blocked_email"`
BlockedUserID string `json:"blocked_user_id,omitempty"`
Reason string `json:"reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ListBlockedEmailsResponse represents the list of blocked emails response.
type ListBlockedEmailsResponse struct {
BlockedEmails []*BlockedEmail `json:"blocked_emails"`
Count int `json:"count"`
}
// DeleteBlockedEmailResponse represents the blocked email deletion response.
type DeleteBlockedEmailResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
// -----------------------------------------------------------------------------
// Dashboard Types
// -----------------------------------------------------------------------------
// Dashboard represents dashboard data.
type Dashboard struct {
Summary DashboardSummary `json:"summary"`
StorageUsageTrend StorageUsageTrend `json:"storage_usage_trend"`
RecentFiles []RecentFileDashboard `json:"recent_files"`
CollectionKeys []DashboardCollectionKey `json:"collection_keys,omitempty"`
}
// DashboardCollectionKey contains the encrypted collection key for client-side decryption
// This allows clients to decrypt file metadata without making additional API calls
type DashboardCollectionKey struct {
CollectionID string `json:"collection_id"`
EncryptedCollectionKey string `json:"encrypted_collection_key"`
EncryptedCollectionKeyNonce string `json:"encrypted_collection_key_nonce"`
}
// DashboardResponse represents the dashboard response.
type DashboardResponse struct {
Dashboard *Dashboard `json:"dashboard"`
Success bool `json:"success"`
Message string `json:"message"`
}
// DashboardSummary represents dashboard summary data.
type DashboardSummary struct {
TotalFiles int `json:"total_files"`
TotalFolders int `json:"total_folders"`
StorageUsed StorageAmount `json:"storage_used"`
StorageLimit StorageAmount `json:"storage_limit"`
StorageUsagePercentage int `json:"storage_usage_percentage"`
}
// StorageAmount represents a storage amount with value and unit.
type StorageAmount struct {
Value float64 `json:"value"`
Unit string `json:"unit"`
}
// StorageUsageTrend represents storage usage trend data.
type StorageUsageTrend struct {
Period string `json:"period"`
DataPoints []DataPoint `json:"data_points"`
}
// DataPoint represents a single data point in the usage trend.
type DataPoint struct {
Date string `json:"date"`
Usage StorageAmount `json:"usage"`
}
// RecentFileDashboard represents a recent file in the dashboard.
// Note: File metadata is E2EE encrypted. Clients should use locally cached
// decrypted data when available, or show placeholder text for cloud-only files.
type RecentFileDashboard struct {
ID string `json:"id"`
CollectionID string `json:"collection_id"`
OwnerID string `json:"owner_id"`
EncryptedMetadata string `json:"encrypted_metadata"`
EncryptedFileKey EncryptedFileKeyData `json:"encrypted_file_key"`
EncryptionVersion string `json:"encryption_version"`
EncryptedHash string `json:"encrypted_hash"`
EncryptedFileSizeInBytes int64 `json:"encrypted_file_size_in_bytes"`
CreatedAt time.Time `json:"created_at"`
ModifiedAt time.Time `json:"modified_at"`
Version uint64 `json:"version"`
State string `json:"state"`
}
// -----------------------------------------------------------------------------
// Collection Types
// -----------------------------------------------------------------------------
// EncryptedKeyData represents an encrypted key with its nonce (used for collections and tags)
type EncryptedKeyData struct {
Ciphertext string `json:"ciphertext"`
Nonce string `json:"nonce"`
}
// Collection represents a file collection (folder).
type Collection struct {
ID string `json:"id"`
ParentID string `json:"parent_id,omitempty"`
UserID string `json:"user_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
EncryptedCollectionKey EncryptedKeyData `json:"encrypted_collection_key"`
// CustomIcon is the decrypted custom icon for this collection.
// Empty string means use default folder/album icon.
// Contains either an emoji character (e.g., "📷") or "icon:<identifier>" for predefined icons.
CustomIcon string `json:"custom_icon,omitempty"`
// EncryptedCustomIcon is the encrypted version of CustomIcon (for sync operations).
EncryptedCustomIcon string `json:"encrypted_custom_icon,omitempty"`
TotalFiles int `json:"total_files"`
TotalSizeInBytes int64 `json:"total_size_in_bytes"`
State string `json:"state"`
CreatedAt time.Time `json:"created_at"`
ModifiedAt time.Time `json:"modified_at"`
SharedWith []Share `json:"shared_with,omitempty"`
PermissionLevel string `json:"permission_level,omitempty"`
IsOwner bool `json:"is_owner"`
OwnerName string `json:"owner_name,omitempty"`
OwnerEmail string `json:"owner_email,omitempty"`
Tags []EmbeddedTag `json:"tags,omitempty"` // Tags assigned to this collection
}
// Share represents a collection sharing entry.
type Share struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
PermissionLevel string `json:"permission_level"`
SharedAt time.Time `json:"shared_at"`
}
// CreateCollectionInput represents the collection creation request.
type CreateCollectionInput struct {
ParentID string `json:"parent_id,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
EncryptedCollectionKey string `json:"encrypted_collection_key"`
Nonce string `json:"nonce"`
}
// UpdateCollectionInput represents the collection update request.
type UpdateCollectionInput struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
}
// MoveCollectionInput represents the collection move request.
type MoveCollectionInput struct {
NewParentID string `json:"new_parent_id"`
}
// ShareCollectionInput represents the collection sharing request.
type ShareCollectionInput struct {
Email string `json:"email"`
PermissionLevel string `json:"permission_level"` // "read_only", "read_write", "admin"
EncryptedCollectionKey string `json:"encrypted_collection_key"`
Nonce string `json:"nonce"`
}
// CollectionFilter represents filters for listing collections.
type CollectionFilter struct {
State string `json:"state,omitempty"` // "active", "archived", "trashed"
ParentID string `json:"parent_id,omitempty"`
}
// SyncInput represents the sync request.
type SyncInput struct {
Cursor string `json:"cursor,omitempty"`
Limit int64 `json:"limit,omitempty"`
}
// CollectionSyncResponse represents the collection sync response.
type CollectionSyncResponse struct {
Collections []*Collection `json:"collections"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
// -----------------------------------------------------------------------------
// File Types
// -----------------------------------------------------------------------------
// EncryptedFileKeyData represents the encrypted file key structure returned by the API.
type EncryptedFileKeyData struct {
Ciphertext string `json:"ciphertext"`
Nonce string `json:"nonce"`
}
// File represents a file in a collection.
type File struct {
ID string `json:"id"`
CollectionID string `json:"collection_id"`
UserID string `json:"user_id"`
EncryptedFileKey EncryptedFileKeyData `json:"encrypted_file_key"`
FileKeyNonce string `json:"file_key_nonce"`
EncryptedMetadata string `json:"encrypted_metadata"`
MetadataNonce string `json:"metadata_nonce"`
FileNonce string `json:"file_nonce"`
EncryptedSizeInBytes int64 `json:"encrypted_file_size_in_bytes"`
DecryptedSizeInBytes int64 `json:"decrypted_size_in_bytes,omitempty"`
State string `json:"state"`
StorageMode string `json:"storage_mode"`
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
ModifiedAt time.Time `json:"modified_at"`
ThumbnailURL string `json:"thumbnail_url,omitempty"`
Tags []*EmbeddedTag `json:"tags,omitempty"`
}
// PendingFile represents a file in pending state (awaiting upload).
type PendingFile struct {
ID string `json:"id"`
CollectionID string `json:"collection_id"`
State string `json:"state"`
}
// CreateFileInput represents the file creation request.
type CreateFileInput struct {
CollectionID string `json:"collection_id"`
EncryptedFileKey string `json:"encrypted_file_key"`
FileKeyNonce string `json:"file_key_nonce"`
EncryptedMetadata string `json:"encrypted_metadata"`
MetadataNonce string `json:"metadata_nonce"`
FileNonce string `json:"file_nonce"`
EncryptedSizeInBytes int64 `json:"encrypted_size_in_bytes"`
}
// UpdateFileInput represents the file update request.
type UpdateFileInput struct {
EncryptedMetadata string `json:"encrypted_metadata,omitempty"`
MetadataNonce string `json:"metadata_nonce,omitempty"`
}
// CompleteUploadInput represents the file upload completion request.
type CompleteUploadInput struct {
ActualFileSizeInBytes int64 `json:"actual_file_size_in_bytes"`
UploadConfirmed bool `json:"upload_confirmed"`
}
// PresignedURL represents a presigned upload URL response.
type PresignedURL struct {
URL string `json:"url"`
ExpiresAt string `json:"expires_at"`
}
// PresignedDownloadResponse represents a presigned download URL response.
type PresignedDownloadResponse struct {
FileURL string `json:"file_url"`
ThumbnailURL string `json:"thumbnail_url,omitempty"`
ExpiresAt string `json:"expires_at"`
}
// DeleteMultipleFilesInput represents the multiple files deletion request.
type DeleteMultipleFilesInput struct {
FileIDs []string `json:"file_ids"`
}
// FileSyncResponse represents the file sync response.
type FileSyncResponse struct {
Files []*File `json:"files"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
// ListFilesResponse represents the list files response.
type ListFilesResponse struct {
Files []*File `json:"files"`
Count int `json:"count"`
}
// -----------------------------------------------------------------------------
// Tag Types
// -----------------------------------------------------------------------------
// Tag represents a user-defined label with color that can be assigned to collections or files.
// All sensitive data (name, color) is encrypted end-to-end.
type Tag struct {
ID string `json:"id"`
UserID string `json:"user_id"`
EncryptedName string `json:"encrypted_name"`
EncryptedColor string `json:"encrypted_color"`
EncryptedTagKey *EncryptedTagKey `json:"encrypted_tag_key"`
CreatedAt time.Time `json:"created_at"`
ModifiedAt time.Time `json:"modified_at"`
Version uint64 `json:"version"`
State string `json:"state"`
}
// EncryptedTagKey represents the encrypted tag key data
type EncryptedTagKey struct {
Ciphertext string `json:"ciphertext"` // Base64 encoded
Nonce string `json:"nonce"` // Base64 encoded
KeyVersion int `json:"key_version,omitempty"`
}
// EmbeddedTag represents tag data that is embedded in collections and files
// This eliminates the need for frontend API lookups to get tag colors
type EmbeddedTag struct {
ID string `json:"id"`
EncryptedName string `json:"encrypted_name"`
EncryptedColor string `json:"encrypted_color"`
EncryptedTagKey *EncryptedTagKey `json:"encrypted_tag_key"`
ModifiedAt time.Time `json:"modified_at"`
}
// CreateTagInput represents the tag creation request
type CreateTagInput struct {
ID string `json:"id"`
EncryptedName string `json:"encrypted_name"`
EncryptedColor string `json:"encrypted_color"`
EncryptedTagKey *EncryptedTagKey `json:"encrypted_tag_key"`
CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"`
Version uint64 `json:"version"`
State string `json:"state"`
}
// UpdateTagInput represents the tag update request
type UpdateTagInput struct {
EncryptedName string `json:"encrypted_name,omitempty"`
EncryptedColor string `json:"encrypted_color,omitempty"`
EncryptedTagKey *EncryptedTagKey `json:"encrypted_tag_key"`
CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"`
Version uint64 `json:"version"`
State string `json:"state"`
}
// ListTagsResponse represents the list tags response
type ListTagsResponse struct {
Tags []*Tag `json:"tags"`
}
// TagAssignment represents the assignment of a tag to a collection or file
type TagAssignment struct {
ID string `json:"id"`
UserID string `json:"user_id"`
TagID string `json:"tag_id"`
EntityID string `json:"entity_id"`
EntityType string `json:"entity_type"` // "collection" or "file"
CreatedAt time.Time `json:"created_at"`
}
// CreateTagAssignmentInput represents the tag assignment request
type CreateTagAssignmentInput struct {
TagID string `json:"tag_id"`
EntityID string `json:"entity_id"`
EntityType string `json:"entity_type"`
}
// ListTagAssignmentsResponse represents the list tag assignments response
type ListTagAssignmentsResponse struct {
TagAssignments []*TagAssignment `json:"tag_assignments"`
}
// SearchByTagsResponse represents the unified search by tags response
type SearchByTagsResponse struct {
Collections []*Collection `json:"collections"`
Files []*File `json:"files"`
TagCount int `json:"tag_count"`
CollectionCount int `json:"collection_count"`
FileCount int `json:"file_count"`
}

View file

@ -0,0 +1,84 @@
// Package client provides a Go SDK for interacting with the MapleFile API.
package client
import (
"context"
"fmt"
"net/url"
)
// GetMe returns the current authenticated user's profile.
func (c *Client) GetMe(ctx context.Context) (*User, error) {
var resp User
if err := c.doRequest(ctx, "GET", "/api/v1/me", nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// UpdateMe updates the current user's profile.
func (c *Client) UpdateMe(ctx context.Context, input *UpdateUserInput) (*User, error) {
var resp User
if err := c.doRequest(ctx, "PUT", "/api/v1/me", input, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// DeleteMe deletes the current user's account.
func (c *Client) DeleteMe(ctx context.Context, password string) error {
input := DeleteUserInput{Password: password}
return c.doRequest(ctx, "DELETE", "/api/v1/me", input, nil, true)
}
// PublicUserLookup looks up a user by email (returns public info only).
// This endpoint does not require authentication.
func (c *Client) PublicUserLookup(ctx context.Context, email string) (*PublicUser, error) {
path := fmt.Sprintf("/iam/api/v1/users/lookup?email=%s", url.QueryEscape(email))
var resp PublicUser
if err := c.doRequest(ctx, "GET", path, nil, &resp, false); err != nil {
return nil, err
}
return &resp, nil
}
// CreateBlockedEmail adds an email to the blocked list.
func (c *Client) CreateBlockedEmail(ctx context.Context, email, reason string) (*BlockedEmail, error) {
input := CreateBlockedEmailInput{
Email: email,
Reason: reason,
}
var resp BlockedEmail
if err := c.doRequest(ctx, "POST", "/api/v1/me/blocked-emails", input, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// ListBlockedEmails returns all blocked emails for the current user.
func (c *Client) ListBlockedEmails(ctx context.Context) (*ListBlockedEmailsResponse, error) {
var resp ListBlockedEmailsResponse
if err := c.doRequest(ctx, "GET", "/api/v1/me/blocked-emails", nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// DeleteBlockedEmail removes an email from the blocked list.
func (c *Client) DeleteBlockedEmail(ctx context.Context, email string) (*DeleteBlockedEmailResponse, error) {
path := fmt.Sprintf("/api/v1/me/blocked-emails/%s", url.PathEscape(email))
var resp DeleteBlockedEmailResponse
if err := c.doRequest(ctx, "DELETE", path, nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}
// GetDashboard returns the user's dashboard data.
func (c *Client) GetDashboard(ctx context.Context) (*DashboardResponse, error) {
var resp DashboardResponse
if err := c.doRequest(ctx, "GET", "/api/v1/dashboard", nil, &resp, true); err != nil {
return nil, err
}
return &resp, nil
}