feat(accounts): implement user accounts, sessions, and dashboards
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
Introduce Stage 4 features to support multi-user accounts, cookie-based web sessions, and personal dashboards. Changes include: - Adding `/register` to bootstrap the first admin account and `/login`/`/logout` for session management. - Creating a personal dashboard (`/app`) to display owned boxes, storage usage, and upload history. - Implementing admin user management (`/admin/users`) for generating invite links and managing user states. - Updating the bbolt database schema to store users, sessions, invites, and collections. - Adding `golang.org/x/crypto` for password hashing and introducing unit tests for account handlers.
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,10 +38,16 @@ type UploadOptions struct {
|
||||
MaxDownloads int
|
||||
Password string
|
||||
ObfuscateMetadata bool
|
||||
OwnerID string
|
||||
CollectionID string
|
||||
SkipSizeLimit bool
|
||||
}
|
||||
|
||||
type Box struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"ownerId,omitempty"`
|
||||
CollectionID string `json:"collectionId,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
MaxDownloads int `json:"maxDownloads"`
|
||||
@@ -93,6 +100,7 @@ type AdminStats struct {
|
||||
|
||||
type AdminBox struct {
|
||||
ID string
|
||||
OwnerID string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
FileCount int
|
||||
@@ -104,6 +112,12 @@ type AdminBox struct {
|
||||
Expired bool
|
||||
}
|
||||
|
||||
type UserBox struct {
|
||||
Box Box
|
||||
CollectionName string
|
||||
TotalSizeLabel string
|
||||
}
|
||||
|
||||
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
|
||||
filesDir := filepath.Join(dataDir, "files")
|
||||
dbDir := filepath.Join(dataDir, "db")
|
||||
@@ -141,6 +155,10 @@ func (s *UploadService) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *UploadService) DB() *bbolt.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
func (s *UploadService) MaxUploadSize() int64 {
|
||||
return s.maxUploadSize
|
||||
}
|
||||
@@ -166,6 +184,8 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
|
||||
box := Box{
|
||||
ID: randomID(10),
|
||||
OwnerID: strings.TrimSpace(opts.OwnerID),
|
||||
CollectionID: strings.TrimSpace(opts.CollectionID),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
|
||||
MaxDownloads: opts.MaxDownloads,
|
||||
@@ -186,8 +206,15 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
}
|
||||
|
||||
for _, header := range files {
|
||||
if err := s.ValidateSize(header.Size); err != nil {
|
||||
return UploadResult{}, err
|
||||
if !opts.SkipSizeLimit {
|
||||
if err := s.ValidateSize(header.Size); err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
maxSize := s.maxUploadSize
|
||||
if opts.SkipSizeLimit {
|
||||
maxSize = 0
|
||||
}
|
||||
|
||||
file, err := header.Open()
|
||||
@@ -203,7 +230,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
if err := writeUploadedFile(storedPath, file, s.maxUploadSize); err != nil {
|
||||
if err := writeUploadedFile(storedPath, file, maxSize); err != nil {
|
||||
file.Close()
|
||||
return UploadResult{}, err
|
||||
}
|
||||
@@ -314,6 +341,7 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
|
||||
}
|
||||
rows = append(rows, AdminBox{
|
||||
ID: box.ID,
|
||||
OwnerID: box.OwnerID,
|
||||
CreatedAt: box.CreatedAt,
|
||||
ExpiresAt: box.ExpiresAt,
|
||||
FileCount: len(box.Files),
|
||||
@@ -328,6 +356,85 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *UploadService) UserBoxes(userID string, collectionNames map[string]string) ([]UserBox, error) {
|
||||
boxes, err := s.ListBoxes(0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([]UserBox, 0)
|
||||
for _, box := range boxes {
|
||||
if box.OwnerID != userID {
|
||||
continue
|
||||
}
|
||||
var size int64
|
||||
for _, file := range box.Files {
|
||||
size += file.Size
|
||||
}
|
||||
rows = append(rows, UserBox{
|
||||
Box: box,
|
||||
CollectionName: collectionNames[box.CollectionID],
|
||||
TotalSizeLabel: helpers.FormatBytes(size),
|
||||
})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
return rows[i].Box.CreatedAt.After(rows[j].Box.CreatedAt)
|
||||
})
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *UploadService) UserStorageUsed(userID string) (int64, error) {
|
||||
boxes, err := s.ListBoxes(0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
for _, box := range boxes {
|
||||
if box.OwnerID != userID {
|
||||
continue
|
||||
}
|
||||
for _, file := range box.Files {
|
||||
total += file.Size
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *UploadService) RenameOwnedBox(boxID, userID, title string) error {
|
||||
box, err := s.GetBox(boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if box.OwnerID != userID {
|
||||
return os.ErrPermission
|
||||
}
|
||||
box.Title = strings.TrimSpace(title)
|
||||
return s.SaveBox(box)
|
||||
}
|
||||
|
||||
func (s *UploadService) MoveOwnedBox(boxID, userID, collectionID string) error {
|
||||
box, err := s.GetBox(boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if box.OwnerID != userID {
|
||||
return os.ErrPermission
|
||||
}
|
||||
box.CollectionID = strings.TrimSpace(collectionID)
|
||||
return s.SaveBox(box)
|
||||
}
|
||||
|
||||
func (s *UploadService) DeleteOwnedBox(boxID, userID string) error {
|
||||
box, err := s.GetBox(boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if box.OwnerID != userID {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return s.DeleteBoxWithSource(boxID, "user-delete")
|
||||
}
|
||||
|
||||
func (s *UploadService) DeleteBox(boxID string) error {
|
||||
return s.DeleteBoxWithSource(boxID, "admin")
|
||||
}
|
||||
@@ -518,12 +625,17 @@ func writeUploadedFile(path string, source multipart.File, maxSize int64) error
|
||||
}
|
||||
defer target.Close()
|
||||
|
||||
written, err := io.Copy(target, io.LimitReader(source, maxSize+1))
|
||||
var written int64
|
||||
if maxSize <= 0 {
|
||||
written, err = io.Copy(target, source)
|
||||
} else {
|
||||
written, err = io.Copy(target, io.LimitReader(source, maxSize+1))
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(path)
|
||||
return err
|
||||
}
|
||||
if written > maxSize {
|
||||
if maxSize > 0 && written > maxSize {
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("file exceeds max upload size")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user