Admin-stored API key with env fallback; scan drive/mail/IMAP uploads. Fail-open if VT down, 422 on malware; migration for virus_scan_status.
669 lines
19 KiB
Go
669 lines
19 KiB
Go
package drive
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
|
"github.com/ultisuite/ulti-backend/internal/automation"
|
|
"github.com/ultisuite/ulti-backend/internal/filescan"
|
|
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
|
"github.com/ultisuite/ulti-backend/internal/publicshare"
|
|
"github.com/ultisuite/ulti-backend/internal/realtime"
|
|
)
|
|
|
|
var (
|
|
ErrNotFound = errors.New("not found")
|
|
ErrConflict = errors.New("conflict")
|
|
ErrForbidden = errors.New("forbidden")
|
|
ErrQuotaExceeded = errors.New("quota exceeded")
|
|
ErrInvalid = errors.New("invalid request")
|
|
ErrMalware = errors.New("malware detected")
|
|
)
|
|
|
|
type Service struct {
|
|
nc *nextcloud.Client
|
|
hub *realtime.Hub
|
|
db *pgxpool.Pool
|
|
automation driveAutomation
|
|
scanner *filescan.Scanner
|
|
maxUploadBytes int64
|
|
quotaReserveByte int64
|
|
}
|
|
|
|
type driveAutomation interface {
|
|
OnDriveEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload automation.DrivePayload)
|
|
}
|
|
|
|
func NewService(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Service {
|
|
return &Service{
|
|
nc: nc,
|
|
hub: hub,
|
|
db: db,
|
|
maxUploadBytes: envInt64("ULTID_DRIVE_MAX_UPLOAD_BYTES", 0),
|
|
quotaReserveByte: envInt64("ULTID_DRIVE_QUOTA_RESERVED_BYTES", 0),
|
|
}
|
|
}
|
|
|
|
func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) {
|
|
if s.nc == nil {
|
|
return "", errors.New("nextcloud unavailable")
|
|
}
|
|
return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name)
|
|
}
|
|
|
|
func (s *Service) notifyFileChanged(platformUserID, path string) {
|
|
if s.hub == nil || platformUserID == "" {
|
|
return
|
|
}
|
|
s.hub.Broadcast(platformUserID, realtime.NewDriveFileChangedEvent(path))
|
|
}
|
|
|
|
// PublishFileChanged notifies connected clients that a drive path changed.
|
|
func (s *Service) PublishFileChanged(platformUserID, filePath string) {
|
|
s.notifyFileChanged(platformUserID, nextcloud.NormalizeClientPath(filePath))
|
|
}
|
|
|
|
func (s *Service) notifyShareUpdated(platformUserID, path string) {
|
|
if s.hub == nil || platformUserID == "" {
|
|
return
|
|
}
|
|
s.hub.Broadcast(platformUserID, realtime.NewDriveShareUpdatedEvent(path))
|
|
}
|
|
|
|
type FilesList struct {
|
|
Files []nextcloud.FileInfo `json:"files"`
|
|
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
|
}
|
|
|
|
func (s *Service) ListFilterCorpus(ctx context.Context, userID, path string) (FilesList, error) {
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
files, err := s.nc.ListFilesRecursive(ctx, userID, path, 0)
|
|
if err != nil {
|
|
return FilesList{}, mapDriveError(err)
|
|
}
|
|
total := int64(len(files))
|
|
return FilesList{
|
|
Files: files,
|
|
Pagination: query.ListParams{Page: 1, PageSize: len(files)}.Meta(&total),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) ListFiles(ctx context.Context, userID, path string, params query.ListParams) (FilesList, error) {
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
files, err := s.nc.ListFiles(ctx, userID, path)
|
|
if err != nil {
|
|
return FilesList{}, mapDriveError(err)
|
|
}
|
|
filtered := filterFiles(files, params.Q)
|
|
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
|
return FilesList{
|
|
Files: page,
|
|
Pagination: params.Meta(&total),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) ListTrash(ctx context.Context, userID string, params query.ListParams) (FilesList, error) {
|
|
files, err := s.nc.ListTrash(ctx, userID)
|
|
if err != nil {
|
|
return FilesList{}, mapDriveError(err)
|
|
}
|
|
filtered := filterFiles(files, params.Q)
|
|
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
|
return FilesList{
|
|
Files: page,
|
|
Pagination: params.Meta(&total),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) ListRecent(ctx context.Context, userID string, params query.ListParams) (FilesList, error) {
|
|
files, err := s.nc.ListRecent(ctx, userID, params.Limit())
|
|
if err != nil {
|
|
return FilesList{}, mapDriveError(err)
|
|
}
|
|
filtered := filterFiles(files, params.Q)
|
|
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
|
return FilesList{
|
|
Files: page,
|
|
Pagination: params.Meta(&total),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) ListSharedWithMe(ctx context.Context, userID string, params query.ListParams) (FilesList, error) {
|
|
files, err := s.nc.ListSharedWithMe(ctx, userID)
|
|
if err != nil {
|
|
return FilesList{}, mapDriveError(err)
|
|
}
|
|
filtered := filterFiles(files, params.Q)
|
|
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
|
return FilesList{
|
|
Files: page,
|
|
Pagination: params.Meta(&total),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) ListStarred(ctx context.Context, userID, basePath string, params query.ListParams) (FilesList, error) {
|
|
if basePath == "" {
|
|
basePath = "/"
|
|
}
|
|
limit := params.Limit()
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
collect := limit + params.Offset()
|
|
if collect <= 0 || collect > 500 {
|
|
collect = 500
|
|
}
|
|
starred, err := s.nc.ListFavorites(ctx, userID, basePath, int(collect))
|
|
if err != nil {
|
|
return FilesList{}, mapDriveError(err)
|
|
}
|
|
filtered := filterFiles(starred, params.Q)
|
|
page, total := paginate.Slice(filtered, params.Offset(), limit)
|
|
return FilesList{
|
|
Files: page,
|
|
Pagination: params.Meta(&total),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) Upload(ctx context.Context, userID, path string, body io.Reader, contentType string, contentLength int64) error {
|
|
if err := s.ensureQuota(ctx, userID, contentLength); err != nil {
|
|
return err
|
|
}
|
|
reader := body
|
|
if s.scanner != nil {
|
|
data, _, err := s.scanner.ScanReader(ctx, path, body, contentLength)
|
|
if err != nil {
|
|
if errors.Is(err, filescan.ErrMalicious) {
|
|
return ErrMalware
|
|
}
|
|
return err
|
|
}
|
|
reader = bytes.NewReader(data)
|
|
}
|
|
return mapDriveError(s.nc.Upload(ctx, userID, path, reader, contentType))
|
|
}
|
|
|
|
func (s *Service) UploadChunk(ctx context.Context, userID, uploadID, targetPath string, chunk ChunkUpload, body io.Reader, contentType string) error {
|
|
if err := mapDriveError(s.nc.UploadChunk(ctx, userID, uploadID, chunkName(chunk.Index), body, contentType)); err != nil {
|
|
return err
|
|
}
|
|
if !chunk.Complete {
|
|
return nil
|
|
}
|
|
|
|
if err := s.ensureQuota(ctx, userID, chunk.TotalSize); err != nil {
|
|
_ = s.nc.AbortChunkUpload(ctx, userID, uploadID)
|
|
return err
|
|
}
|
|
if err := mapDriveError(s.nc.AssembleChunks(ctx, userID, uploadID, targetPath, chunk.TotalSize)); err != nil {
|
|
return err
|
|
}
|
|
if s.scanner != nil {
|
|
if err := s.scanAssembledUpload(ctx, userID, targetPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) scanAssembledUpload(ctx context.Context, userID, targetPath string) error {
|
|
body, _, err := s.nc.Download(ctx, userID, targetPath)
|
|
if err != nil {
|
|
return mapDriveError(err)
|
|
}
|
|
defer body.Close()
|
|
|
|
data, scanResult, err := s.scanner.ScanReader(ctx, targetPath, body, -1)
|
|
if err != nil {
|
|
if errors.Is(err, filescan.ErrMalicious) {
|
|
_ = s.nc.Delete(ctx, userID, targetPath)
|
|
return ErrMalware
|
|
}
|
|
return err
|
|
}
|
|
_ = data
|
|
_ = scanResult
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) Download(ctx context.Context, userID, path string) (io.ReadCloser, string, error) {
|
|
body, contentType, err := s.nc.Download(ctx, userID, path)
|
|
if err != nil {
|
|
return nil, "", mapDriveError(err)
|
|
}
|
|
return body, contentType, nil
|
|
}
|
|
|
|
func (s *Service) Preview(ctx context.Context, userID, filePath string, width, height int) (io.ReadCloser, string, error) {
|
|
filePath = nextcloud.NormalizeClientFilePath(userID, filePath)
|
|
fileID, err := s.nc.FileID(ctx, userID, filePath)
|
|
if err != nil {
|
|
return nil, "", mapDriveError(err)
|
|
}
|
|
body, contentType, err := s.nc.Preview(ctx, userID, fileID, width, height)
|
|
if err != nil {
|
|
return nil, "", mapDriveError(err)
|
|
}
|
|
return body, contentType, nil
|
|
}
|
|
|
|
func (s *Service) Delete(ctx context.Context, userID, path string) error {
|
|
return mapDriveError(s.nc.Delete(ctx, userID, path))
|
|
}
|
|
|
|
func (s *Service) CreateFolder(ctx context.Context, userID, path string) error {
|
|
return mapDriveError(s.nc.CreateFolder(ctx, userID, path))
|
|
}
|
|
|
|
func (s *Service) Move(ctx context.Context, userID, source, destination string) error {
|
|
source = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(source))
|
|
destination = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(destination))
|
|
return mapDriveError(s.nc.Move(ctx, userID, source, destination))
|
|
}
|
|
|
|
func (s *Service) Copy(ctx context.Context, userID, source, destination string) error {
|
|
source = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(source))
|
|
destination = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(destination))
|
|
return mapDriveError(s.nc.Copy(ctx, userID, source, destination))
|
|
}
|
|
|
|
func (s *Service) Rename(ctx context.Context, userID, filePath, newName string) error {
|
|
if strings.Contains(newName, "/") {
|
|
return ErrInvalid
|
|
}
|
|
filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath))
|
|
dir := path.Dir("/" + strings.TrimPrefix(filePath, "/"))
|
|
destination := path.Join(dir, newName)
|
|
return mapDriveError(s.nc.Move(ctx, userID, filePath, destination))
|
|
}
|
|
|
|
func (s *Service) CreateShare(ctx context.Context, userID, filePath string, req createShareRequest, permissions int) (*nextcloud.ShareInfo, error) {
|
|
filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath))
|
|
opts, err := s.buildCreateShareOptions(ctx, req, permissions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
share, err := s.nc.CreateShare(ctx, userID, filePath, opts)
|
|
if err != nil {
|
|
return nil, mapDriveError(err)
|
|
}
|
|
s.rewriteShareURL(share)
|
|
if shouldSendShareEmail(opts, req) {
|
|
if sendErr := s.nc.SendShareEmail(ctx, userID, share.ID, ""); sendErr != nil {
|
|
return share, nil
|
|
}
|
|
}
|
|
return share, nil
|
|
}
|
|
|
|
func (s *Service) UserExists(ctx context.Context, email string) (bool, error) {
|
|
userID := nextcloud.UserIDFromClaims(strings.TrimSpace(email), "")
|
|
if userID == "" {
|
|
return false, nil
|
|
}
|
|
return s.nc.UserExists(ctx, userID)
|
|
}
|
|
|
|
func (s *Service) SendShareEmail(ctx context.Context, userID, shareID, password string) error {
|
|
return mapDriveError(s.nc.SendShareEmail(ctx, userID, shareID, password))
|
|
}
|
|
|
|
func shouldSendShareEmail(opts nextcloud.CreateShareOptions, req createShareRequest) bool {
|
|
// Fallback when NC did not send the invitation email on create (common for mail shares).
|
|
if opts.ShareType != 4 {
|
|
return false
|
|
}
|
|
return req.SendMail == nil || *req.SendMail
|
|
}
|
|
|
|
func (s *Service) buildCreateShareOptions(ctx context.Context, req createShareRequest, permissions int) (nextcloud.CreateShareOptions, error) {
|
|
opts := nextcloud.CreateShareOptions{Permissions: permissions}
|
|
mode := strings.TrimSpace(strings.ToLower(req.Mode))
|
|
sendMail := req.SendMail == nil || *req.SendMail
|
|
|
|
switch mode {
|
|
case "internal":
|
|
opts.ShareType = 3
|
|
opts.Label = "internal"
|
|
opts.AccessMode = "internal"
|
|
case "contact":
|
|
email := strings.TrimSpace(strings.ToLower(req.ShareWith))
|
|
if email == "" {
|
|
return opts, ErrInvalid
|
|
}
|
|
registered, err := s.UserExists(ctx, email)
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.Note = strings.TrimSpace(req.Note)
|
|
opts.SendMail = sendMail
|
|
if registered {
|
|
opts.ShareType = 0
|
|
opts.ShareWith = nextcloud.UserIDFromClaims(email, "")
|
|
opts.AccessMode = "user"
|
|
} else {
|
|
opts.ShareType = 4
|
|
opts.ShareWith = email
|
|
opts.AccessMode = "email"
|
|
}
|
|
case "public":
|
|
opts.ShareType = 3
|
|
opts.AccessMode = "public"
|
|
default:
|
|
opts.ShareType = req.ShareType
|
|
if opts.ShareType == 0 {
|
|
opts.ShareType = 3
|
|
}
|
|
if opts.ShareType == 3 {
|
|
opts.AccessMode = "public"
|
|
}
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
func (s *Service) GetQuota(ctx context.Context, userID string) (nextcloud.UserQuota, error) {
|
|
quota, err := s.nc.GetQuota(ctx, userID)
|
|
if err != nil {
|
|
return nextcloud.UserQuota{}, mapDriveError(err)
|
|
}
|
|
return quota, nil
|
|
}
|
|
|
|
func (s *Service) ListShares(ctx context.Context, userID, filePath string) ([]nextcloud.ShareInfo, error) {
|
|
filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath))
|
|
shares, err := s.nc.ListShares(ctx, userID, filePath)
|
|
if err != nil {
|
|
return nil, mapDriveError(err)
|
|
}
|
|
s.rewriteShareURLs(shares)
|
|
return shares, nil
|
|
}
|
|
|
|
func (s *Service) GetPublicShare(ctx context.Context, token, path, password string) (*nextcloud.PublicShareView, error) {
|
|
view, err := s.nc.GetPublicShare(ctx, token, path, password)
|
|
if err != nil {
|
|
return nil, mapPublicShareError(err)
|
|
}
|
|
s.recordPublicShareAccess(ctx, token)
|
|
return view, nil
|
|
}
|
|
|
|
func (s *Service) DownloadPublicShare(ctx context.Context, token, filePath, password string) (io.ReadCloser, string, error) {
|
|
body, contentType, err := s.nc.DownloadPublicShare(ctx, token, filePath, password)
|
|
if err != nil {
|
|
return nil, "", mapPublicShareError(err)
|
|
}
|
|
s.recordPublicShareAccess(ctx, token)
|
|
return body, contentType, nil
|
|
}
|
|
|
|
func (s *Service) PreviewPublicShare(ctx context.Context, token, filePath, password string, width, height int) (io.ReadCloser, string, error) {
|
|
body, contentType, err := s.nc.PreviewPublicShare(ctx, token, filePath, password, width, height)
|
|
if err != nil {
|
|
return nil, "", mapPublicShareError(err)
|
|
}
|
|
s.recordPublicShareAccess(ctx, token)
|
|
return body, contentType, nil
|
|
}
|
|
|
|
func (s *Service) recordPublicShareAccess(ctx context.Context, token string) {
|
|
publicshare.RecordAccess(ctx, s.db, token)
|
|
}
|
|
|
|
func (s *Service) rewriteShareURL(share *nextcloud.ShareInfo) {
|
|
if share == nil || s.nc == nil {
|
|
return
|
|
}
|
|
if share.ShareType == 3 && strings.TrimSpace(share.Token) != "" {
|
|
if u := s.nc.PublicShareURL(share.Token); u != "" {
|
|
share.URL = u
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) rewriteShareURLs(shares []nextcloud.ShareInfo) {
|
|
for i := range shares {
|
|
s.rewriteShareURL(&shares[i])
|
|
}
|
|
}
|
|
|
|
func mapPublicShareError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if errors.Is(err, nextcloud.ErrPublicSharePasswordRequired) {
|
|
return ErrForbidden
|
|
}
|
|
if errors.Is(err, nextcloud.ErrInvalidPublicShare) {
|
|
return ErrInvalid
|
|
}
|
|
return mapDriveError(err)
|
|
}
|
|
|
|
func (s *Service) UpdateShare(ctx context.Context, userID, shareID string, permissions int, expireDate, password string) (*nextcloud.ShareInfo, error) {
|
|
share, err := s.nc.UpdateShare(ctx, userID, shareID, permissions, expireDate, password)
|
|
if err != nil {
|
|
return nil, mapDriveError(err)
|
|
}
|
|
return share, nil
|
|
}
|
|
|
|
func (s *Service) DeleteShare(ctx context.Context, userID, shareID string) error {
|
|
return mapDriveError(s.nc.DeleteShare(ctx, userID, shareID))
|
|
}
|
|
|
|
func (s *Service) RestoreTrash(ctx context.Context, userID, trashName string) error {
|
|
return mapDriveError(s.nc.RestoreFromTrash(ctx, userID, trashName))
|
|
}
|
|
|
|
func (s *Service) DeleteTrash(ctx context.Context, userID, trashName string) error {
|
|
return mapDriveError(s.nc.DeleteFromTrash(ctx, userID, trashName))
|
|
}
|
|
|
|
func (s *Service) EmptyTrash(ctx context.Context, userID string) error {
|
|
return mapDriveError(s.nc.EmptyTrash(ctx, userID))
|
|
}
|
|
|
|
func (s *Service) SetFavorite(ctx context.Context, userID, filePath string, favorite bool) error {
|
|
filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath))
|
|
return mapDriveError(s.nc.SetFavorite(ctx, userID, filePath, favorite))
|
|
}
|
|
|
|
func (s *Service) Search(ctx context.Context, userID string, opts SearchOptions, params query.ListParams) (FilesList, error) {
|
|
q := strings.TrimSpace(params.Q)
|
|
if q == "" {
|
|
return FilesList{
|
|
Files: []nextcloud.FileInfo{},
|
|
Pagination: params.Meta(ptrInt64(0)),
|
|
}, nil
|
|
}
|
|
|
|
limit := params.Limit()
|
|
if limit <= 0 {
|
|
if opts.Suggest {
|
|
limit = 8
|
|
} else {
|
|
limit = 100
|
|
}
|
|
}
|
|
|
|
scope := nextcloud.SearchScope(opts.Scope)
|
|
if scope == "" {
|
|
scope = nextcloud.SearchScopeAll
|
|
}
|
|
|
|
files, err := s.nc.SearchFiles(ctx, userID, nextcloud.SearchOptions{
|
|
Query: q,
|
|
Scope: scope,
|
|
BasePath: opts.BasePath,
|
|
Suggest: opts.Suggest,
|
|
Limit: limit + params.Offset(),
|
|
})
|
|
if err != nil {
|
|
return FilesList{}, mapDriveError(err)
|
|
}
|
|
|
|
page, total := paginate.Slice(files, params.Offset(), limit)
|
|
return FilesList{
|
|
Files: page,
|
|
Pagination: params.Meta(&total),
|
|
}, nil
|
|
}
|
|
|
|
type SearchOptions struct {
|
|
Scope string
|
|
BasePath string
|
|
Suggest bool
|
|
}
|
|
|
|
func ptrInt64(v int64) *int64 {
|
|
return &v
|
|
}
|
|
|
|
type NewFileKind string
|
|
|
|
const (
|
|
NewFileDocument NewFileKind = "document"
|
|
NewFileSpreadsheet NewFileKind = "spreadsheet"
|
|
NewFilePresentation NewFileKind = "presentation"
|
|
)
|
|
|
|
func (s *Service) CreateNewFile(ctx context.Context, userID, parentPath, name string, kind NewFileKind) (string, error) {
|
|
if strings.TrimSpace(name) == "" {
|
|
return "", ErrInvalid
|
|
}
|
|
content, contentType := blankOfficeFile(kind)
|
|
if content == nil {
|
|
return "", ErrInvalid
|
|
}
|
|
target := path.Join(strings.TrimSuffix(parentPath, "/"), name)
|
|
if err := mapDriveError(s.nc.Upload(ctx, userID, target, bytes.NewReader(content), contentType)); err != nil {
|
|
return "", err
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
func filterFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo {
|
|
q = strings.ToLower(strings.TrimSpace(q))
|
|
if q == "" {
|
|
return files
|
|
}
|
|
out := make([]nextcloud.FileInfo, 0, len(files))
|
|
for _, f := range files {
|
|
if strings.Contains(strings.ToLower(f.Name), q) ||
|
|
strings.Contains(strings.ToLower(f.Path), q) {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type ChunkUpload struct {
|
|
Index int
|
|
Total int
|
|
TotalSize int64
|
|
Complete bool
|
|
}
|
|
|
|
func chunkName(index int) string {
|
|
return strconv.FormatInt(int64(index), 10)
|
|
}
|
|
|
|
func (s *Service) ensureQuota(ctx context.Context, userID string, incomingBytes int64) error {
|
|
if s.maxUploadBytes > 0 && incomingBytes > s.maxUploadBytes {
|
|
return ErrQuotaExceeded
|
|
}
|
|
quota, err := s.nc.GetQuota(ctx, userID)
|
|
if err != nil {
|
|
return mapDriveError(err)
|
|
}
|
|
if !quotaAllowsUpload(quota.Free, incomingBytes, s.quotaReserveByte) {
|
|
return ErrQuotaExceeded
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// quotaAllowsUpload mirrors Nextcloud quota semantics: negative free means unknown or unlimited.
|
|
func quotaAllowsUpload(free, incomingBytes, reserve int64) bool {
|
|
if incomingBytes <= 0 {
|
|
return true
|
|
}
|
|
if free < 0 {
|
|
return true
|
|
}
|
|
if free == 0 {
|
|
return false
|
|
}
|
|
return incomingBytes+reserve <= free
|
|
}
|
|
|
|
func mapDriveError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if errors.Is(err, nextcloud.ErrDAVCredentialsMissing) {
|
|
return ErrForbidden
|
|
}
|
|
var statusErr *nextcloud.HTTPStatusError
|
|
if !errors.As(err, &statusErr) {
|
|
return err
|
|
}
|
|
switch statusErr.StatusCode {
|
|
case http.StatusNotFound:
|
|
return ErrNotFound
|
|
case http.StatusConflict, http.StatusPreconditionFailed:
|
|
return ErrConflict
|
|
case http.StatusForbidden, http.StatusUnauthorized:
|
|
return ErrForbidden
|
|
case http.StatusInsufficientStorage, http.StatusRequestEntityTooLarge:
|
|
return ErrQuotaExceeded
|
|
case http.StatusBadRequest:
|
|
return ErrInvalid
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
|
|
func envInt64(key string, fallback int64) int64 {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
n, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|
|
|
|
func parseModTime(value string) time.Time {
|
|
if value == "" {
|
|
return time.Time{}
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
|
return t
|
|
}
|
|
if t, err := time.Parse(time.RFC1123, value); err == nil {
|
|
return t
|
|
}
|
|
return time.Time{}
|
|
}
|