mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-06-03 23:12:56 +00:00
b17a06fbba
A clean, customizable homelab dashboard inspired by CasaOS. Features: - Empty-first dashboard (no demo data) - 3 themes: Light, Dark, CasaOS glassmorphism - Widgets: Clock (multi-timezone), Pi-hole, Memos, Immich, Image - Drag & drop app organization - Grid + list view for apps - Groups with collapse/expand - Proper widget refresh handling - Visual timezone picker - Square app cards with hover effects Stack: Go + Gin + PostgreSQL + Next.js 15 + React 19 + Tailwind CSS + shadcn/ui
117 lines
2.6 KiB
Go
117 lines
2.6 KiB
Go
package assets
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"dash/backend/internal/store"
|
|
)
|
|
|
|
var allowedMIMEs = map[string]string{
|
|
"image/png": ".png",
|
|
"image/jpeg": ".jpg",
|
|
"image/webp": ".webp",
|
|
"image/svg+xml": ".svg",
|
|
}
|
|
|
|
type Service struct {
|
|
dir string
|
|
baseURL string
|
|
maxBytes int64
|
|
store *store.Store
|
|
}
|
|
|
|
func New(dir, publicBaseURL string, maxBytes int64, st *store.Store) *Service {
|
|
return &Service{
|
|
dir: dir,
|
|
baseURL: strings.TrimRight(publicBaseURL, "/"),
|
|
maxBytes: maxBytes,
|
|
store: st,
|
|
}
|
|
}
|
|
|
|
func (s *Service) SaveIcon(r *http.Request, header *multipart.FileHeader) (store.AssetFile, error) {
|
|
if header.Size > s.maxBytes {
|
|
return store.AssetFile{}, ErrTooLarge
|
|
}
|
|
if err := os.MkdirAll(s.dir, 0o755); err != nil {
|
|
return store.AssetFile{}, err
|
|
}
|
|
|
|
file, err := header.Open()
|
|
if err != nil {
|
|
return store.AssetFile{}, err
|
|
}
|
|
defer file.Close()
|
|
|
|
limited := io.LimitReader(file, s.maxBytes+1)
|
|
sniff := make([]byte, 512)
|
|
n, err := limited.Read(sniff)
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return store.AssetFile{}, err
|
|
}
|
|
mimeType := http.DetectContentType(sniff[:n])
|
|
if strings.EqualFold(filepath.Ext(header.Filename), ".svg") {
|
|
if !looksLikeSVG(sniff[:n]) {
|
|
return store.AssetFile{}, ErrUnsupportedMedia
|
|
}
|
|
mimeType = "image/svg+xml"
|
|
}
|
|
ext, ok := allowedMIMEs[mimeType]
|
|
if !ok {
|
|
return store.AssetFile{}, ErrUnsupportedMedia
|
|
}
|
|
|
|
storedName := uuid.NewString() + ext
|
|
target := filepath.Join(s.dir, storedName)
|
|
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
|
if err != nil {
|
|
return store.AssetFile{}, err
|
|
}
|
|
defer out.Close()
|
|
|
|
if _, err := out.Write(sniff[:n]); err != nil {
|
|
return store.AssetFile{}, err
|
|
}
|
|
written, err := io.Copy(out, limited)
|
|
if err != nil {
|
|
return store.AssetFile{}, err
|
|
}
|
|
size := int64(n) + written
|
|
if size > s.maxBytes {
|
|
_ = os.Remove(target)
|
|
return store.AssetFile{}, ErrTooLarge
|
|
}
|
|
|
|
return s.store.CreateAsset(r.Context(), store.AssetFile{
|
|
OriginalName: filepath.Base(header.Filename),
|
|
StoredName: storedName,
|
|
MimeType: mimeType,
|
|
SizeBytes: size,
|
|
PublicPath: fmt.Sprintf("/uploads/icons/%s", storedName),
|
|
})
|
|
}
|
|
|
|
func looksLikeSVG(prefix []byte) bool {
|
|
trimmed := bytes.TrimSpace(prefix)
|
|
return bytes.HasPrefix(trimmed, []byte("<svg")) || bytes.HasPrefix(trimmed, []byte("<?xml"))
|
|
}
|
|
|
|
type assetError string
|
|
|
|
func (e assetError) Error() string { return string(e) }
|
|
|
|
const (
|
|
ErrTooLarge assetError = "upload too large"
|
|
ErrUnsupportedMedia assetError = "unsupported media type"
|
|
)
|