mirror of https://github.com/usememos/memos
refactor: migrate binary file serving from gRPC to dedicated HTTP fileserver
Migrates attachment and avatar binary serving from gRPC endpoints to a new dedicated HTTP fileserver package, fixing Safari video playback issues and improving architectural separation. Key changes: - Created server/router/fileserver package for all binary file serving - Removed GetAttachmentBinary and GetUserAvatar gRPC endpoints from proto - Implemented native HTTP handlers with full range request support - Added authentication support (session cookies + JWT) to fileserver - New avatar endpoint supports lookup by user ID or username - Eliminated duplicate auth constants (imports from api/v1) HTTP endpoints: - Attachments: /file/attachments/:uid/:filename (unchanged URL) - Avatars: /file/users/:identifier/avatar (new URL format) This fixes Safari video/audio playback by using http.ServeContent() which properly handles HTTP 206 Partial Content responses and range request headers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>pull/5332/head
parent
9ea27ee61f
commit
1cf047707b
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,309 @@
|
||||
# Fileserver Package
|
||||
|
||||
## Overview
|
||||
|
||||
The `fileserver` package handles all binary file serving for Memos using native HTTP handlers. It was created to replace gRPC-based binary serving, which had limitations with HTTP range requests (required for Safari video/audio playback).
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Serve attachment binary files (images, videos, audio, documents)
|
||||
- Serve user avatar images
|
||||
- Handle HTTP range requests for video/audio streaming
|
||||
- Authenticate requests using session cookies or JWT tokens
|
||||
- Check permissions for private content
|
||||
- Generate and serve image thumbnails
|
||||
- Prevent XSS attacks on uploaded content
|
||||
- Support S3 external storage
|
||||
|
||||
## Architecture
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Separation of Concerns**: Binary files via HTTP, metadata via gRPC
|
||||
2. **DRY**: Imports auth constants from `api/v1` package (single source of truth)
|
||||
3. **Security First**: Authentication, authorization, and XSS prevention
|
||||
4. **Performance**: Native HTTP streaming with proper caching headers
|
||||
|
||||
### Package Structure
|
||||
|
||||
```
|
||||
fileserver/
|
||||
├── fileserver.go # Main service and HTTP handlers
|
||||
├── README.md # This file
|
||||
└── fileserver_test.go # Tests (to be added)
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. Attachment Binary
|
||||
```
|
||||
GET /file/attachments/:uid/:filename[?thumbnail=true]
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `uid` - Attachment unique identifier
|
||||
- `filename` - Original filename
|
||||
- `thumbnail` (optional) - Return thumbnail for images
|
||||
|
||||
**Authentication:** Required for non-public memos
|
||||
|
||||
**Response:**
|
||||
- `200 OK` - File content with proper Content-Type
|
||||
- `206 Partial Content` - For range requests (video/audio)
|
||||
- `401 Unauthorized` - Authentication required
|
||||
- `403 Forbidden` - User not authorized
|
||||
- `404 Not Found` - Attachment not found
|
||||
|
||||
**Headers:**
|
||||
- `Content-Type` - MIME type of the file
|
||||
- `Cache-Control: public, max-age=3600`
|
||||
- `Accept-Ranges: bytes` - For video/audio
|
||||
- `Content-Range` - For partial responses (206)
|
||||
|
||||
### 2. User Avatar
|
||||
```
|
||||
GET /file/users/:identifier/avatar
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `identifier` - User ID (e.g., `1`) or username (e.g., `steven`)
|
||||
|
||||
**Authentication:** Not required (avatars are public)
|
||||
|
||||
**Response:**
|
||||
- `200 OK` - Avatar image (PNG/JPEG)
|
||||
- `404 Not Found` - User not found or no avatar set
|
||||
|
||||
**Headers:**
|
||||
- `Content-Type` - image/png or image/jpeg
|
||||
- `Cache-Control: public, max-age=3600`
|
||||
|
||||
## Authentication
|
||||
|
||||
### Supported Methods
|
||||
|
||||
The fileserver supports two authentication methods, checked in order:
|
||||
|
||||
1. **Session Cookie** (`user_session`)
|
||||
- Cookie format: `{userID}-{sessionID}`
|
||||
- Validates session exists and hasn't expired (14-day sliding window)
|
||||
- Updates last accessed time on success
|
||||
|
||||
2. **JWT Bearer Token** (`Authorization: Bearer {token}`)
|
||||
- Validates JWT signature using server secret
|
||||
- Checks token exists in user's access tokens (for revocation)
|
||||
- Extracts user ID from token claims
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
```
|
||||
Request → getCurrentUser()
|
||||
├─→ Try Session Cookie
|
||||
│ ├─→ Parse cookie value
|
||||
│ ├─→ Get user from DB
|
||||
│ ├─→ Validate session
|
||||
│ └─→ Return user (if valid)
|
||||
│
|
||||
└─→ Try JWT Token
|
||||
├─→ Parse Authorization header
|
||||
├─→ Verify JWT signature
|
||||
├─→ Get user from DB
|
||||
├─→ Validate token in access tokens list
|
||||
└─→ Return user (if valid)
|
||||
```
|
||||
|
||||
### Permission Model
|
||||
|
||||
**Attachments:**
|
||||
- Unlinked: Public (no auth required)
|
||||
- Public memo: Public (no auth required)
|
||||
- Protected memo: Requires authentication
|
||||
- Private memo: Creator only
|
||||
|
||||
**Avatars:**
|
||||
- Always public (no auth required)
|
||||
|
||||
## Key Functions
|
||||
|
||||
### HTTP Handlers
|
||||
|
||||
#### `serveAttachmentFile(c echo.Context) error`
|
||||
Main handler for attachment binary serving.
|
||||
|
||||
**Flow:**
|
||||
1. Extract UID from URL parameter
|
||||
2. Fetch attachment from database
|
||||
3. Check permissions (memo visibility)
|
||||
4. Get binary blob (local file, S3, or database)
|
||||
5. Handle thumbnail request (if applicable)
|
||||
6. Set security headers (XSS prevention)
|
||||
7. Serve with range request support (video/audio)
|
||||
|
||||
#### `serveUserAvatar(c echo.Context) error`
|
||||
Main handler for user avatar serving.
|
||||
|
||||
**Flow:**
|
||||
1. Extract identifier (ID or username) from URL
|
||||
2. Lookup user in database
|
||||
3. Check if avatar exists
|
||||
4. Decode base64 data URI
|
||||
5. Serve with proper content type and caching
|
||||
|
||||
### Authentication
|
||||
|
||||
#### `getCurrentUser(ctx, c) (*store.User, error)`
|
||||
Authenticates request using session cookie or JWT token.
|
||||
|
||||
#### `authenticateBySession(ctx, cookie) (*store.User, error)`
|
||||
Validates session cookie and returns authenticated user.
|
||||
|
||||
#### `authenticateByJWT(ctx, token) (*store.User, error)`
|
||||
Validates JWT access token and returns authenticated user.
|
||||
|
||||
### Permission Checks
|
||||
|
||||
#### `checkAttachmentPermission(ctx, c, attachment) error`
|
||||
Validates user has permission to access attachment based on memo visibility.
|
||||
|
||||
### File Operations
|
||||
|
||||
#### `getAttachmentBlob(attachment) ([]byte, error)`
|
||||
Retrieves binary content from local storage, S3, or database.
|
||||
|
||||
#### `getOrGenerateThumbnail(ctx, attachment) ([]byte, error)`
|
||||
Returns cached thumbnail or generates new one (with semaphore limiting).
|
||||
|
||||
### Utilities
|
||||
|
||||
#### `getUserByIdentifier(ctx, identifier) (*store.User, error)`
|
||||
Finds user by ID (int) or username (string).
|
||||
|
||||
#### `extractImageInfo(dataURI) (type, base64, error)`
|
||||
Parses data URI to extract MIME type and base64 data.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### External Packages
|
||||
- `github.com/labstack/echo/v4` - HTTP router and middleware
|
||||
- `github.com/golang-jwt/jwt/v5` - JWT parsing and validation
|
||||
- `github.com/disintegration/imaging` - Image thumbnail generation
|
||||
- `golang.org/x/sync/semaphore` - Concurrency control for thumbnails
|
||||
|
||||
### Internal Packages
|
||||
- `server/router/api/v1` - Auth constants (SessionCookieName, ClaimsMessage, etc.)
|
||||
- `store` - Database operations
|
||||
- `internal/profile` - Server configuration
|
||||
- `plugin/storage/s3` - S3 storage client
|
||||
|
||||
## Configuration
|
||||
|
||||
### Constants
|
||||
|
||||
All auth-related constants are imported from `server/router/api/v1/auth.go`:
|
||||
- `apiv1.SessionCookieName` - "user_session"
|
||||
- `apiv1.SessionSlidingDuration` - 14 days
|
||||
- `apiv1.KeyID` - "v1" (JWT key identifier)
|
||||
- `apiv1.ClaimsMessage` - JWT claims struct
|
||||
|
||||
Package-specific constants:
|
||||
- `ThumbnailCacheFolder` - ".thumbnail_cache"
|
||||
- `thumbnailMaxSize` - 600px
|
||||
- `SupportedThumbnailMimeTypes` - ["image/png", "image/jpeg"]
|
||||
|
||||
## Error Handling
|
||||
|
||||
All handlers return Echo HTTP errors with appropriate status codes:
|
||||
|
||||
```go
|
||||
// Bad request
|
||||
echo.NewHTTPError(http.StatusBadRequest, "message")
|
||||
|
||||
// Unauthorized (no auth)
|
||||
echo.NewHTTPError(http.StatusUnauthorized, "message")
|
||||
|
||||
// Forbidden (auth but no permission)
|
||||
echo.NewHTTPError(http.StatusForbidden, "message")
|
||||
|
||||
// Not found
|
||||
echo.NewHTTPError(http.StatusNotFound, "message")
|
||||
|
||||
// Internal error
|
||||
echo.NewHTTPError(http.StatusInternalServerError, "message").SetInternal(err)
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. XSS Prevention
|
||||
SVG and HTML files are served as `application/octet-stream` to prevent script execution:
|
||||
|
||||
```go
|
||||
if contentType == "image/svg+xml" ||
|
||||
contentType == "text/html" ||
|
||||
contentType == "application/xhtml+xml" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Authentication
|
||||
Private content requires valid session or JWT token.
|
||||
|
||||
### 3. Authorization
|
||||
Memo visibility rules enforced before serving attachments.
|
||||
|
||||
### 4. Input Validation
|
||||
- Attachment UID validated from database
|
||||
- User identifier validated (ID or username)
|
||||
- Range requests validated before processing
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### 1. Thumbnail Caching
|
||||
Thumbnails cached on disk to avoid regeneration:
|
||||
- Cache location: `{data_dir}/.thumbnail_cache/`
|
||||
- Filename: `{attachment_id}{extension}`
|
||||
- Semaphore limits concurrent generation (max 3)
|
||||
|
||||
### 2. HTTP Range Requests
|
||||
Video/audio files use `http.ServeContent()` for efficient streaming:
|
||||
- Automatic range parsing
|
||||
- Efficient memory usage (streaming, not loading full file)
|
||||
- Safari-compatible partial content responses
|
||||
|
||||
### 3. Caching Headers
|
||||
All responses include cache headers:
|
||||
```
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
|
||||
### 4. S3 External Links
|
||||
S3 files served via presigned URLs (no server download).
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests (To Add)
|
||||
See SAFARI_FIX.md for recommended test coverage.
|
||||
|
||||
### Manual Testing
|
||||
```bash
|
||||
# Test attachment
|
||||
curl "http://localhost:8081/file/attachments/{uid}/file.jpg"
|
||||
|
||||
# Test avatar by ID
|
||||
curl "http://localhost:8081/file/users/1/avatar"
|
||||
|
||||
# Test avatar by username
|
||||
curl "http://localhost:8081/file/users/steven/avatar"
|
||||
|
||||
# Test range request
|
||||
curl -H "Range: bytes=0-999" "http://localhost:8081/file/attachments/{uid}/video.mp4"
|
||||
```
|
||||
|
||||
## Future Improvements
|
||||
|
||||
See SAFARI_FIX.md section "Future Improvements" for planned enhancements.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [SAFARI_FIX.md](../../../SAFARI_FIX.md) - Full migration guide
|
||||
- [server/router/api/v1/auth.go](../api/v1/auth.go) - Auth constants source of truth
|
||||
- [RFC 7233](https://tools.ietf.org/html/rfc7233) - HTTP Range Requests spec
|
||||
@ -0,0 +1,569 @@
|
||||
package fileserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/sync/semaphore"
|
||||
|
||||
"github.com/usememos/memos/internal/profile"
|
||||
"github.com/usememos/memos/internal/util"
|
||||
"github.com/usememos/memos/plugin/storage/s3"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
apiv1 "github.com/usememos/memos/server/router/api/v1"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// ThumbnailCacheFolder is the folder name where the thumbnail images are stored.
|
||||
ThumbnailCacheFolder = ".thumbnail_cache"
|
||||
// thumbnailMaxSize is the maximum size in pixels for the largest dimension of the thumbnail image.
|
||||
thumbnailMaxSize = 600
|
||||
)
|
||||
|
||||
var SupportedThumbnailMimeTypes = []string{
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
}
|
||||
|
||||
// FileServerService handles HTTP file serving with proper range request support.
|
||||
// This service bypasses gRPC-Gateway to use native HTTP serving via http.ServeContent(),
|
||||
// which is required for Safari video/audio playback.
|
||||
type FileServerService struct {
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
Secret string
|
||||
|
||||
// thumbnailSemaphore limits concurrent thumbnail generation to prevent memory exhaustion
|
||||
thumbnailSemaphore *semaphore.Weighted
|
||||
}
|
||||
|
||||
// NewFileServerService creates a new file server service.
|
||||
func NewFileServerService(profile *profile.Profile, store *store.Store, secret string) *FileServerService {
|
||||
return &FileServerService{
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
Secret: secret,
|
||||
thumbnailSemaphore: semaphore.NewWeighted(3), // Limit to 3 concurrent thumbnail generations
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers HTTP file serving routes.
|
||||
func (s *FileServerService) RegisterRoutes(echoServer *echo.Echo) {
|
||||
fileGroup := echoServer.Group("/file")
|
||||
|
||||
// Serve attachment binary files
|
||||
fileGroup.GET("/attachments/:uid/:filename", s.serveAttachmentFile)
|
||||
|
||||
// Serve user avatar images
|
||||
fileGroup.GET("/users/:identifier/avatar", s.serveUserAvatar)
|
||||
}
|
||||
|
||||
// serveAttachmentFile serves attachment binary content using native HTTP.
|
||||
// This properly handles range requests required by Safari for video/audio playback.
|
||||
func (s *FileServerService) serveAttachmentFile(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
uid := c.Param("uid")
|
||||
thumbnail := c.QueryParam("thumbnail") == "true"
|
||||
|
||||
// Get attachment from database
|
||||
attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{
|
||||
UID: &uid,
|
||||
GetBlob: true,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get attachment").SetInternal(err)
|
||||
}
|
||||
if attachment == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "attachment not found")
|
||||
}
|
||||
|
||||
// Check permissions - verify memo visibility if attachment belongs to a memo
|
||||
if err := s.checkAttachmentPermission(ctx, c, attachment); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the binary content
|
||||
blob, err := s.getAttachmentBlob(attachment)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get attachment blob").SetInternal(err)
|
||||
}
|
||||
|
||||
// Handle thumbnail requests for images
|
||||
if thumbnail && s.isImageType(attachment.Type) {
|
||||
thumbnailBlob, err := s.getOrGenerateThumbnail(ctx, attachment)
|
||||
if err != nil {
|
||||
// Log warning but fall back to original image
|
||||
c.Logger().Warnf("failed to get thumbnail: %v", err)
|
||||
} else {
|
||||
blob = thumbnailBlob
|
||||
}
|
||||
}
|
||||
|
||||
// Determine content type
|
||||
contentType := attachment.Type
|
||||
if strings.HasPrefix(contentType, "text/") {
|
||||
contentType += "; charset=utf-8"
|
||||
}
|
||||
// Prevent XSS attacks by serving potentially unsafe files as octet-stream
|
||||
if strings.EqualFold(contentType, "image/svg+xml") ||
|
||||
strings.EqualFold(contentType, "text/html") ||
|
||||
strings.EqualFold(contentType, "application/xhtml+xml") {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
// Set common headers
|
||||
c.Response().Header().Set("Content-Type", contentType)
|
||||
c.Response().Header().Set("Cache-Control", "public, max-age=3600")
|
||||
|
||||
// For video/audio: Use http.ServeContent for automatic range request support
|
||||
// This is critical for Safari which REQUIRES range request support
|
||||
if strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") {
|
||||
// ServeContent automatically handles:
|
||||
// - Range request parsing
|
||||
// - HTTP 206 Partial Content responses
|
||||
// - Content-Range headers
|
||||
// - Accept-Ranges: bytes header
|
||||
modTime := time.Unix(attachment.UpdatedTs, 0)
|
||||
http.ServeContent(c.Response(), c.Request(), attachment.Filename, modTime, bytes.NewReader(blob))
|
||||
return nil
|
||||
}
|
||||
|
||||
// For other files: Simple blob response
|
||||
return c.Blob(http.StatusOK, contentType, blob)
|
||||
}
|
||||
|
||||
// serveUserAvatar serves user avatar images.
|
||||
// Supports both user ID and username as identifier.
|
||||
func (s *FileServerService) serveUserAvatar(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
identifier := c.Param("identifier")
|
||||
|
||||
// Try to find user by ID or username
|
||||
user, err := s.getUserByIdentifier(ctx, identifier)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get user").SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "user not found")
|
||||
}
|
||||
if user.AvatarURL == "" {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "avatar not found")
|
||||
}
|
||||
|
||||
// Extract image info from data URI
|
||||
imageType, base64Data, err := s.extractImageInfo(user.AvatarURL)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to extract image info").SetInternal(err)
|
||||
}
|
||||
|
||||
// Decode base64 data
|
||||
imageData, err := base64.StdEncoding.DecodeString(base64Data)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to decode image data").SetInternal(err)
|
||||
}
|
||||
|
||||
// Set cache headers for avatars
|
||||
c.Response().Header().Set("Content-Type", imageType)
|
||||
c.Response().Header().Set("Cache-Control", "public, max-age=3600")
|
||||
|
||||
return c.Blob(http.StatusOK, imageType, imageData)
|
||||
}
|
||||
|
||||
// getUserByIdentifier finds a user by either ID or username.
|
||||
func (s *FileServerService) getUserByIdentifier(ctx context.Context, identifier string) (*store.User, error) {
|
||||
// Try to parse as ID first
|
||||
if userID, err := util.ConvertStringToInt32(identifier); err == nil {
|
||||
return s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
|
||||
}
|
||||
|
||||
// Otherwise, treat as username
|
||||
return s.Store.GetUser(ctx, &store.FindUser{Username: &identifier})
|
||||
}
|
||||
|
||||
// extractImageInfo extracts image type and base64 data from a data URI.
|
||||
// Data URI format: data:image/png;base64,iVBORw0KGgo...
|
||||
func (*FileServerService) extractImageInfo(dataURI string) (string, string, error) {
|
||||
dataURIRegex := regexp.MustCompile(`^data:(?P<type>.+);base64,(?P<base64>.+)`)
|
||||
matches := dataURIRegex.FindStringSubmatch(dataURI)
|
||||
if len(matches) != 3 {
|
||||
return "", "", errors.New("invalid data URI format")
|
||||
}
|
||||
imageType := matches[1]
|
||||
base64Data := matches[2]
|
||||
return imageType, base64Data, nil
|
||||
}
|
||||
|
||||
// checkAttachmentPermission verifies the user has permission to access the attachment.
|
||||
func (s *FileServerService) checkAttachmentPermission(ctx context.Context, c echo.Context, attachment *store.Attachment) error {
|
||||
// If attachment is not linked to a memo, allow access
|
||||
if attachment.MemoID == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check memo visibility
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: attachment.MemoID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to find memo").SetInternal(err)
|
||||
}
|
||||
if memo == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "memo not found")
|
||||
}
|
||||
|
||||
// Public memos are accessible to everyone
|
||||
if memo.Visibility == store.Public {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For non-public memos, check authentication
|
||||
user, err := s.getCurrentUser(ctx, c)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get current user").SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized access")
|
||||
}
|
||||
|
||||
// Private memos can only be accessed by the creator
|
||||
if memo.Visibility == store.Private && user.ID != attachment.CreatorID {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "forbidden access")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentUser retrieves the current authenticated user from the Echo context.
|
||||
// It checks both session cookies and Bearer tokens for authentication.
|
||||
func (s *FileServerService) getCurrentUser(ctx context.Context, c echo.Context) (*store.User, error) {
|
||||
// Try session cookie authentication first
|
||||
if cookie, err := c.Cookie(apiv1.SessionCookieName); err == nil && cookie.Value != "" {
|
||||
user, err := s.authenticateBySession(ctx, cookie.Value)
|
||||
if err == nil && user != nil {
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try JWT Bearer token authentication
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader != "" {
|
||||
parts := strings.Fields(authHeader)
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||
user, err := s.authenticateByJWT(ctx, parts[1])
|
||||
if err == nil && user != nil {
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No valid authentication found
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// authenticateBySession authenticates a user using session ID from cookie.
|
||||
func (s *FileServerService) authenticateBySession(ctx context.Context, sessionCookieValue string) (*store.User, error) {
|
||||
if sessionCookieValue == "" {
|
||||
return nil, errors.New("session cookie value not found")
|
||||
}
|
||||
|
||||
// Parse the cookie value to extract userID and sessionID
|
||||
userID, sessionID, err := s.parseSessionCookieValue(sessionCookieValue)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "invalid session cookie format")
|
||||
}
|
||||
|
||||
// Get the user
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get user")
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
if user.RowStatus == store.Archived {
|
||||
return nil, errors.New("user is archived")
|
||||
}
|
||||
|
||||
// Get user sessions and validate the sessionID
|
||||
sessions, err := s.Store.GetUserSessions(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get user sessions")
|
||||
}
|
||||
|
||||
if !s.validateUserSession(sessionID, sessions) {
|
||||
return nil, errors.New("invalid or expired session")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// authenticateByJWT authenticates a user using JWT access token from Authorization header.
|
||||
func (s *FileServerService) authenticateByJWT(ctx context.Context, accessToken string) (*store.User, error) {
|
||||
if accessToken == "" {
|
||||
return nil, errors.New("access token not found")
|
||||
}
|
||||
|
||||
claims := &apiv1.ClaimsMessage{}
|
||||
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
}
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if kid == apiv1.KeyID {
|
||||
return []byte(s.Secret), nil
|
||||
}
|
||||
}
|
||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Invalid or expired access token")
|
||||
}
|
||||
|
||||
// Get user from JWT claims
|
||||
userID, err := util.ConvertStringToInt32(claims.Subject)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "malformed ID in the token")
|
||||
}
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get user")
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.Errorf("user %q not exists", userID)
|
||||
}
|
||||
if user.RowStatus == store.Archived {
|
||||
return nil, errors.Errorf("user %q is archived", userID)
|
||||
}
|
||||
|
||||
// Validate that this access token exists in the user's access tokens
|
||||
accessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to get user access tokens")
|
||||
}
|
||||
if !s.validateAccessToken(accessToken, accessTokens) {
|
||||
return nil, errors.New("invalid access token")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// parseSessionCookieValue parses the session cookie value to extract userID and sessionID.
|
||||
func (*FileServerService) parseSessionCookieValue(cookieValue string) (int32, string, error) {
|
||||
parts := strings.SplitN(cookieValue, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, "", errors.New("invalid session cookie format")
|
||||
}
|
||||
|
||||
userID, err := util.ConvertStringToInt32(parts[0])
|
||||
if err != nil {
|
||||
return 0, "", errors.Errorf("invalid user ID in session cookie: %v", err)
|
||||
}
|
||||
|
||||
return userID, parts[1], nil
|
||||
}
|
||||
|
||||
// validateUserSession checks if a session exists and is still valid using sliding expiration.
|
||||
func (*FileServerService) validateUserSession(sessionID string, userSessions []*storepb.SessionsUserSetting_Session) bool {
|
||||
for _, session := range userSessions {
|
||||
if sessionID == session.SessionId {
|
||||
// Use sliding expiration: check if last_accessed_time + 14 days > current_time
|
||||
if session.LastAccessedTime != nil {
|
||||
expirationTime := session.LastAccessedTime.AsTime().Add(apiv1.SessionSlidingDuration)
|
||||
if expirationTime.Before(time.Now()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateAccessToken checks if the provided JWT token exists in the user's access tokens list.
|
||||
func (*FileServerService) validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||
for _, userAccessToken := range userAccessTokens {
|
||||
if accessTokenString == userAccessToken.AccessToken {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isImageType checks if the mime type is an image that supports thumbnails.
|
||||
func (*FileServerService) isImageType(mimeType string) bool {
|
||||
return mimeType == "image/png" || mimeType == "image/jpeg"
|
||||
}
|
||||
|
||||
// getAttachmentBlob retrieves the binary content of an attachment from storage.
|
||||
func (s *FileServerService) getAttachmentBlob(attachment *store.Attachment) ([]byte, error) {
|
||||
// For local storage, read the file from the local disk.
|
||||
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
|
||||
attachmentPath := filepath.FromSlash(attachment.Reference)
|
||||
if !filepath.IsAbs(attachmentPath) {
|
||||
attachmentPath = filepath.Join(s.Profile.Data, attachmentPath)
|
||||
}
|
||||
|
||||
file, err := os.Open(attachmentPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, errors.Wrap(err, "file not found")
|
||||
}
|
||||
return nil, errors.Wrap(err, "failed to open the file")
|
||||
}
|
||||
defer file.Close()
|
||||
blob, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to read the file")
|
||||
}
|
||||
return blob, nil
|
||||
}
|
||||
// For S3 storage, download the file from S3.
|
||||
if attachment.StorageType == storepb.AttachmentStorageType_S3 {
|
||||
if attachment.Payload == nil {
|
||||
return nil, errors.New("attachment payload is missing")
|
||||
}
|
||||
s3Object := attachment.Payload.GetS3Object()
|
||||
if s3Object == nil {
|
||||
return nil, errors.New("S3 object payload is missing")
|
||||
}
|
||||
if s3Object.S3Config == nil {
|
||||
return nil, errors.New("S3 config is missing")
|
||||
}
|
||||
if s3Object.Key == "" {
|
||||
return nil, errors.New("S3 object key is missing")
|
||||
}
|
||||
|
||||
s3Client, err := s3.NewClient(context.Background(), s3Object.S3Config)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create S3 client")
|
||||
}
|
||||
|
||||
blob, err := s3Client.GetObject(context.Background(), s3Object.Key)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get object from S3")
|
||||
}
|
||||
return blob, nil
|
||||
}
|
||||
// For database storage, return the blob from the database.
|
||||
return attachment.Blob, nil
|
||||
}
|
||||
|
||||
// getOrGenerateThumbnail returns the thumbnail image of the attachment.
|
||||
// Uses semaphore to limit concurrent thumbnail generation and prevent memory exhaustion.
|
||||
func (s *FileServerService) getOrGenerateThumbnail(ctx context.Context, attachment *store.Attachment) ([]byte, error) {
|
||||
thumbnailCacheFolder := filepath.Join(s.Profile.Data, ThumbnailCacheFolder)
|
||||
if err := os.MkdirAll(thumbnailCacheFolder, os.ModePerm); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create thumbnail cache folder")
|
||||
}
|
||||
filePath := filepath.Join(thumbnailCacheFolder, fmt.Sprintf("%d%s", attachment.ID, filepath.Ext(attachment.Filename)))
|
||||
|
||||
// Check if thumbnail already exists
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
// Thumbnail exists, read and return it
|
||||
thumbnailFile, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to open thumbnail file")
|
||||
}
|
||||
defer thumbnailFile.Close()
|
||||
blob, err := io.ReadAll(thumbnailFile)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to read thumbnail file")
|
||||
}
|
||||
return blob, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, errors.Wrap(err, "failed to check thumbnail image stat")
|
||||
}
|
||||
|
||||
// Thumbnail doesn't exist, acquire semaphore to limit concurrent generation
|
||||
if err := s.thumbnailSemaphore.Acquire(ctx, 1); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to acquire thumbnail generation semaphore")
|
||||
}
|
||||
defer s.thumbnailSemaphore.Release(1)
|
||||
|
||||
// Double-check if thumbnail was created while waiting for semaphore
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
thumbnailFile, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to open thumbnail file")
|
||||
}
|
||||
defer thumbnailFile.Close()
|
||||
blob, err := io.ReadAll(thumbnailFile)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to read thumbnail file")
|
||||
}
|
||||
return blob, nil
|
||||
}
|
||||
|
||||
// Generate the thumbnail
|
||||
blob, err := s.getAttachmentBlob(attachment)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get attachment blob")
|
||||
}
|
||||
|
||||
// Decode image - this is memory intensive
|
||||
img, err := imaging.Decode(bytes.NewReader(blob), imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to decode thumbnail image")
|
||||
}
|
||||
|
||||
// The largest dimension is set to thumbnailMaxSize and the smaller dimension is scaled proportionally.
|
||||
// Small images are not enlarged.
|
||||
width := img.Bounds().Dx()
|
||||
height := img.Bounds().Dy()
|
||||
var thumbnailWidth, thumbnailHeight int
|
||||
|
||||
// Only resize if the image is larger than thumbnailMaxSize
|
||||
if max(width, height) > thumbnailMaxSize {
|
||||
if width >= height {
|
||||
// Landscape or square - constrain width, maintain aspect ratio for height
|
||||
thumbnailWidth = thumbnailMaxSize
|
||||
thumbnailHeight = 0
|
||||
} else {
|
||||
// Portrait - constrain height, maintain aspect ratio for width
|
||||
thumbnailWidth = 0
|
||||
thumbnailHeight = thumbnailMaxSize
|
||||
}
|
||||
} else {
|
||||
// Keep original dimensions for small images
|
||||
thumbnailWidth = width
|
||||
thumbnailHeight = height
|
||||
}
|
||||
|
||||
// Resize the image to the calculated dimensions.
|
||||
thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos)
|
||||
|
||||
// Save thumbnail to disk
|
||||
if err := imaging.Save(thumbnailImage, filePath); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to save thumbnail file")
|
||||
}
|
||||
|
||||
// Read the saved thumbnail and return it
|
||||
thumbnailFile, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to open thumbnail file")
|
||||
}
|
||||
defer thumbnailFile.Close()
|
||||
thumbnailBlob, err := io.ReadAll(thumbnailFile)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to read thumbnail file")
|
||||
}
|
||||
return thumbnailBlob, nil
|
||||
}
|
||||
@ -1,152 +0,0 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.6.1
|
||||
// protoc unknown
|
||||
// source: google/api/httpbody.proto
|
||||
|
||||
/* eslint-disable */
|
||||
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
|
||||
import { Any } from "../protobuf/any";
|
||||
|
||||
export const protobufPackage = "google.api";
|
||||
|
||||
/**
|
||||
* Message that represents an arbitrary HTTP body. It should only be used for
|
||||
* payload formats that can't be represented as JSON, such as raw binary or
|
||||
* an HTML page.
|
||||
*
|
||||
* This message can be used both in streaming and non-streaming API methods in
|
||||
* the request as well as the response.
|
||||
*
|
||||
* It can be used as a top-level request field, which is convenient if one
|
||||
* wants to extract parameters from either the URL or HTTP template into the
|
||||
* request fields and also want access to the raw HTTP body.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* message GetResourceRequest {
|
||||
* // A unique request id.
|
||||
* string request_id = 1;
|
||||
*
|
||||
* // The raw HTTP body is bound to this field.
|
||||
* google.api.HttpBody http_body = 2;
|
||||
*
|
||||
* }
|
||||
*
|
||||
* service ResourceService {
|
||||
* rpc GetResource(GetResourceRequest)
|
||||
* returns (google.api.HttpBody);
|
||||
* rpc UpdateResource(google.api.HttpBody)
|
||||
* returns (google.protobuf.Empty);
|
||||
*
|
||||
* }
|
||||
*
|
||||
* Example with streaming methods:
|
||||
*
|
||||
* service CaldavService {
|
||||
* rpc GetCalendar(stream google.api.HttpBody)
|
||||
* returns (stream google.api.HttpBody);
|
||||
* rpc UpdateCalendar(stream google.api.HttpBody)
|
||||
* returns (stream google.api.HttpBody);
|
||||
*
|
||||
* }
|
||||
*
|
||||
* Use of this type only changes how the request and response bodies are
|
||||
* handled, all other features will continue to work unchanged.
|
||||
*/
|
||||
export interface HttpBody {
|
||||
/** The HTTP Content-Type header value specifying the content type of the body. */
|
||||
contentType: string;
|
||||
/** The HTTP request/response body as raw binary. */
|
||||
data: Uint8Array;
|
||||
/**
|
||||
* Application specific response metadata. Must be set in the first response
|
||||
* for streaming APIs.
|
||||
*/
|
||||
extensions: Any[];
|
||||
}
|
||||
|
||||
function createBaseHttpBody(): HttpBody {
|
||||
return { contentType: "", data: new Uint8Array(0), extensions: [] };
|
||||
}
|
||||
|
||||
export const HttpBody: MessageFns<HttpBody> = {
|
||||
encode(message: HttpBody, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.contentType !== "") {
|
||||
writer.uint32(10).string(message.contentType);
|
||||
}
|
||||
if (message.data.length !== 0) {
|
||||
writer.uint32(18).bytes(message.data);
|
||||
}
|
||||
for (const v of message.extensions) {
|
||||
Any.encode(v!, writer.uint32(26).fork()).join();
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): HttpBody {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
let end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseHttpBody();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.contentType = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.data = reader.bytes();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.extensions.push(Any.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
create(base?: DeepPartial<HttpBody>): HttpBody {
|
||||
return HttpBody.fromPartial(base ?? {});
|
||||
},
|
||||
fromPartial(object: DeepPartial<HttpBody>): HttpBody {
|
||||
const message = createBaseHttpBody();
|
||||
message.contentType = object.contentType ?? "";
|
||||
message.data = object.data ?? new Uint8Array(0);
|
||||
message.extensions = object.extensions?.map((e) => Any.fromPartial(e)) || [];
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
|
||||
|
||||
export type DeepPartial<T> = T extends Builtin ? T
|
||||
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
|
||||
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: Partial<T>;
|
||||
|
||||
export interface MessageFns<T> {
|
||||
encode(message: T, writer?: BinaryWriter): BinaryWriter;
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): T;
|
||||
create(base?: DeepPartial<T>): T;
|
||||
fromPartial(object: DeepPartial<T>): T;
|
||||
}
|
||||
@ -1,206 +0,0 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.6.1
|
||||
// protoc unknown
|
||||
// source: google/protobuf/any.proto
|
||||
|
||||
/* eslint-disable */
|
||||
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
|
||||
|
||||
export const protobufPackage = "google.protobuf";
|
||||
|
||||
/**
|
||||
* `Any` contains an arbitrary serialized protocol buffer message along with a
|
||||
* URL that describes the type of the serialized message.
|
||||
*
|
||||
* Protobuf library provides support to pack/unpack Any values in the form
|
||||
* of utility functions or additional generated methods of the Any type.
|
||||
*
|
||||
* Example 1: Pack and unpack a message in C++.
|
||||
*
|
||||
* Foo foo = ...;
|
||||
* Any any;
|
||||
* any.PackFrom(foo);
|
||||
* ...
|
||||
* if (any.UnpackTo(&foo)) {
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* Example 2: Pack and unpack a message in Java.
|
||||
*
|
||||
* Foo foo = ...;
|
||||
* Any any = Any.pack(foo);
|
||||
* ...
|
||||
* if (any.is(Foo.class)) {
|
||||
* foo = any.unpack(Foo.class);
|
||||
* }
|
||||
* // or ...
|
||||
* if (any.isSameTypeAs(Foo.getDefaultInstance())) {
|
||||
* foo = any.unpack(Foo.getDefaultInstance());
|
||||
* }
|
||||
*
|
||||
* Example 3: Pack and unpack a message in Python.
|
||||
*
|
||||
* foo = Foo(...)
|
||||
* any = Any()
|
||||
* any.Pack(foo)
|
||||
* ...
|
||||
* if any.Is(Foo.DESCRIPTOR):
|
||||
* any.Unpack(foo)
|
||||
* ...
|
||||
*
|
||||
* Example 4: Pack and unpack a message in Go
|
||||
*
|
||||
* foo := &pb.Foo{...}
|
||||
* any, err := anypb.New(foo)
|
||||
* if err != nil {
|
||||
* ...
|
||||
* }
|
||||
* ...
|
||||
* foo := &pb.Foo{}
|
||||
* if err := any.UnmarshalTo(foo); err != nil {
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* The pack methods provided by protobuf library will by default use
|
||||
* 'type.googleapis.com/full.type.name' as the type URL and the unpack
|
||||
* methods only use the fully qualified type name after the last '/'
|
||||
* in the type URL, for example "foo.bar.com/x/y.z" will yield type
|
||||
* name "y.z".
|
||||
*
|
||||
* JSON
|
||||
* ====
|
||||
* The JSON representation of an `Any` value uses the regular
|
||||
* representation of the deserialized, embedded message, with an
|
||||
* additional field `@type` which contains the type URL. Example:
|
||||
*
|
||||
* package google.profile;
|
||||
* message Person {
|
||||
* string first_name = 1;
|
||||
* string last_name = 2;
|
||||
* }
|
||||
*
|
||||
* {
|
||||
* "@type": "type.googleapis.com/google.profile.Person",
|
||||
* "firstName": <string>,
|
||||
* "lastName": <string>
|
||||
* }
|
||||
*
|
||||
* If the embedded message type is well-known and has a custom JSON
|
||||
* representation, that representation will be embedded adding a field
|
||||
* `value` which holds the custom JSON in addition to the `@type`
|
||||
* field. Example (for message [google.protobuf.Duration][]):
|
||||
*
|
||||
* {
|
||||
* "@type": "type.googleapis.com/google.protobuf.Duration",
|
||||
* "value": "1.212s"
|
||||
* }
|
||||
*/
|
||||
export interface Any {
|
||||
/**
|
||||
* A URL/resource name that uniquely identifies the type of the serialized
|
||||
* protocol buffer message. This string must contain at least
|
||||
* one "/" character. The last segment of the URL's path must represent
|
||||
* the fully qualified name of the type (as in
|
||||
* `path/google.protobuf.Duration`). The name should be in a canonical form
|
||||
* (e.g., leading "." is not accepted).
|
||||
*
|
||||
* In practice, teams usually precompile into the binary all types that they
|
||||
* expect it to use in the context of Any. However, for URLs which use the
|
||||
* scheme `http`, `https`, or no scheme, one can optionally set up a type
|
||||
* server that maps type URLs to message definitions as follows:
|
||||
*
|
||||
* * If no scheme is provided, `https` is assumed.
|
||||
* * An HTTP GET on the URL must yield a [google.protobuf.Type][]
|
||||
* value in binary format, or produce an error.
|
||||
* * Applications are allowed to cache lookup results based on the
|
||||
* URL, or have them precompiled into a binary to avoid any
|
||||
* lookup. Therefore, binary compatibility needs to be preserved
|
||||
* on changes to types. (Use versioned type names to manage
|
||||
* breaking changes.)
|
||||
*
|
||||
* Note: this functionality is not currently available in the official
|
||||
* protobuf release, and it is not used for type URLs beginning with
|
||||
* type.googleapis.com. As of May 2023, there are no widely used type server
|
||||
* implementations and no plans to implement one.
|
||||
*
|
||||
* Schemes other than `http`, `https` (or the empty scheme) might be
|
||||
* used with implementation specific semantics.
|
||||
*/
|
||||
typeUrl: string;
|
||||
/** Must be a valid serialized protocol buffer of the above specified type. */
|
||||
value: Uint8Array;
|
||||
}
|
||||
|
||||
function createBaseAny(): Any {
|
||||
return { typeUrl: "", value: new Uint8Array(0) };
|
||||
}
|
||||
|
||||
export const Any: MessageFns<Any> = {
|
||||
encode(message: Any, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.typeUrl !== "") {
|
||||
writer.uint32(10).string(message.typeUrl);
|
||||
}
|
||||
if (message.value.length !== 0) {
|
||||
writer.uint32(18).bytes(message.value);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): Any {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
let end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseAny();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.typeUrl = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.value = reader.bytes();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
create(base?: DeepPartial<Any>): Any {
|
||||
return Any.fromPartial(base ?? {});
|
||||
},
|
||||
fromPartial(object: DeepPartial<Any>): Any {
|
||||
const message = createBaseAny();
|
||||
message.typeUrl = object.typeUrl ?? "";
|
||||
message.value = object.value ?? new Uint8Array(0);
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
|
||||
|
||||
export type DeepPartial<T> = T extends Builtin ? T
|
||||
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
|
||||
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: Partial<T>;
|
||||
|
||||
export interface MessageFns<T> {
|
||||
encode(message: T, writer?: BinaryWriter): BinaryWriter;
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): T;
|
||||
create(base?: DeepPartial<T>): T;
|
||||
fromPartial(object: DeepPartial<T>): T;
|
||||
}
|
||||
Loading…
Reference in New Issue