17 KiB
17 KiB
Milestone 7: CDN & Storage
Status: Decided Goal: Scalable storage for app packages and assets with global distribution.
Decision
Local Synology filesystem as primary storage, with optional Cloudflare R2 for CDN:
Primary: Synology volume (/volume1/mosis/)
CDN: Cloudflare R2 (optional, for global distribution)
Serving: Go binary serves files directly (local)
Backup: Synology Hyper Backup / rsync
Rationale
- Self-hosted - All data stays on premises
- Zero cost - No cloud storage fees
- Simple - Go binary serves files directly via http.FileServer
- Fast local - NAS has gigabit+ internal network
- Optional CDN - Can sync to R2 if global distribution needed
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Synology NAS │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ mosis-portal container │ │
│ │ │ │
│ │ Go binary ──serves──► /packages/, /assets/ │ │
│ │ │ │ │
│ │ └── SQLite (/data/portal.db) │ │
│ └──────────────────────────┬─────────────────────────────────┘ │
│ │ │
│ /volume1/mosis/ │ bind mount │
│ ├── data/portal.db │ │
│ ├── packages/ ◄───────────┘ │
│ │ └── {dev_id}/{app_id}/{version}/package.mosis │
│ ├── assets/ │
│ │ └── {app_id}/icon-{size}.png │
│ └── backups/ │
│ └── litestream replicas │
└─────────────────────────────────────────────────────────────────┘
│
(optional sync)
▼
┌──────────────────┐
│ Cloudflare R2 │
│ (CDN for global │
│ distribution) │
└──────────────────┘
Overview
Storage handles app packages, icons, screenshots, and serves downloads to devices. For self-hosted Synology NAS deployment, local filesystem is the primary storage.
Requirements
Functional
- Store app packages (.mosis files)
- Store icons (multiple sizes)
- Store screenshots (optional)
- Serve downloads globally
- Support presigned upload URLs
- Version retention (keep old versions)
Non-Functional
| Requirement | Target |
|---|---|
| Upload speed | < 30s for 50MB |
| Download latency | < 100ms (p50) |
| Availability | 99.9% |
| Durability | 99.999999999% (11 nines) |
| Cost | Minimize egress fees |
Storage Structure
/packages/
/{developer_id}/
/{app_id}/
/{version_code}/
package.mosis
manifest.json (extracted for quick access)
/assets/
/{app_id}/
icon-32.png
icon-64.png
icon-128.png
screenshots/
1.png
2.png
3.png
/temp/
/{upload_id}/
package.mosis (pending validation)
Options Analysis
Option A: Cloudflare R2
Storage: Object storage (S3-compatible)
CDN: Cloudflare network (automatic)
Egress: FREE (zero egress fees)
Pricing: $0.015/GB storage, $4.50/million requests
| Pros | Cons |
|---|---|
| No egress fees | Newer service |
| Global CDN included | Fewer regions than S3 |
| S3-compatible API | Less tooling |
| Workers integration |
Cost Estimate (10K apps, 100GB)
| Component | Monthly Cost |
|---|---|
| Storage (100GB) | $1.50 |
| Requests (1M) | $4.50 |
| Egress | $0 |
| Total | ~$6 |
Option B: AWS S3 + CloudFront
Storage: S3 Standard
CDN: CloudFront
Egress: $0.085-0.12/GB (varies by region)
Pricing: $0.023/GB storage
| Pros | Cons |
|---|---|
| Most mature | Egress costs add up |
| Best tooling | Complex pricing |
| All regions | Need CloudFront config |
| IAM integration |
Cost Estimate (10K apps, 100GB, 1TB egress)
| Component | Monthly Cost |
|---|---|
| Storage (100GB) | $2.30 |
| CloudFront (1TB) | $85 |
| Requests | ~$5 |
| Total | ~$92 |
Option C: Backblaze B2 + Cloudflare
Storage: Backblaze B2
CDN: Cloudflare (free egress via Bandwidth Alliance)
Egress: FREE (through Cloudflare)
Pricing: $0.005/GB storage
| Pros | Cons |
|---|---|
| Cheapest storage | Two services to manage |
| Free egress via CF | B2 API slightly different |
| Good reliability | Need CF proxy setup |
Cost Estimate (10K apps, 100GB)
| Component | Monthly Cost |
|---|---|
| Storage (100GB) | $0.50 |
| Egress (via CF) | $0 |
| Requests | ~$0.40 |
| Total | ~$1 |
Option D: Self-hosted MinIO
Storage: MinIO on VPS
CDN: Cloudflare proxy
Egress: VPS bandwidth
Pricing: VPS cost only
| Pros | Cons |
|---|---|
| Full control | Ops overhead |
| S3-compatible | Need to manage |
| Predictable cost | Scaling complexity |
Cost Estimate
| Component | Monthly Cost |
|---|---|
| VPS (500GB SSD) | $20-50 |
| Cloudflare | $0 (free tier) |
| Total | ~$20-50 |
Recommendation
Primary: Local Synology Filesystem
- Zero recurring costs
- All data on premises
- Simple Go file serving
- Synology's built-in backup tools
Optional: Cloudflare R2 for CDN
- If global distribution needed
- Sync packages via cron/background job
- Zero egress fees
- S3-compatible API
Upload Flow
Local Filesystem Approach
┌────────┐ ┌─────────┐ ┌─────────────────┐
│ Client │───►│ API │───►│ Local Filesystem│
└────────┘ └─────────┘ └─────────────────┘
│ │ │
│ 1. Upload package (multipart) │
│─────────────►│ │
│ │ │
│ │ 2. Save to temp │
│ │─────────────────►│
│ │ │
│ │ 3. Validate │
│ │ 4. Move to final│
│ │─────────────────►│
│ │ │
│ 5. Confirm │ │
│◄─────────────┤ │
Go Implementation (Local Storage)
// Upload handler - direct file upload
func (h *VersionHandler) Upload(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appID")
devID := r.Context().Value("developer_id").(string)
// Parse multipart form (max 50MB)
r.ParseMultipartForm(50 << 20)
file, header, err := r.FormFile("package")
if err != nil {
respondError(w, http.StatusBadRequest, "INVALID_FILE", "No package file")
return
}
defer file.Close()
// Create version record
version := &domain.Version{
ID: uuid.New().String(),
AppID: appID,
VersionCode: parseInt(r.FormValue("version_code")),
VersionName: r.FormValue("version_name"),
Status: domain.VersionStatusUploading,
}
// Save to temp directory
tempPath := filepath.Join(h.storagePath, "temp", version.ID, "package.mosis")
os.MkdirAll(filepath.Dir(tempPath), 0755)
dst, err := os.Create(tempPath)
if err != nil {
respondError(w, http.StatusInternalServerError, "STORAGE_ERROR", err.Error())
return
}
defer dst.Close()
size, err := io.Copy(dst, file)
version.PackageSize = size
// Validate package (signature, manifest, etc.)
if err := h.validator.Validate(tempPath); err != nil {
os.Remove(tempPath)
respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return
}
// Move to final location
finalPath := filepath.Join(h.storagePath, "packages",
devID, appID, fmt.Sprintf("%d", version.VersionCode), "package.mosis")
os.MkdirAll(filepath.Dir(finalPath), 0755)
os.Rename(tempPath, finalPath)
version.PackageURL = finalPath
version.Status = domain.VersionStatusDraft
h.repo.Save(version)
respondJSON(w, http.StatusCreated, version)
}
Optional: Presigned URL for R2 CDN
If global distribution is needed, sync to R2 and generate presigned download URLs:
// Sync to R2 after publishing (background job)
func (s *SyncService) SyncToR2(version *domain.Version) error {
localPath := version.PackageURL
r2Key := fmt.Sprintf("packages/%s/%s/%d/package.mosis",
version.DeveloperID, version.AppID, version.VersionCode)
file, _ := os.Open(localPath)
defer file.Close()
_, err := s.r2.PutObject(r2Key, file)
return err
}
Download Flow
Local File Serving
// Serve package downloads directly from filesystem
func (h *StoreHandler) Download(w http.ResponseWriter, r *http.Request) {
packageID := chi.URLParam(r, "packageID")
// Get latest published version
version, err := h.repo.GetLatestPublished(packageID)
if err != nil {
respondError(w, http.StatusNotFound, "NOT_FOUND", "App not found")
return
}
// Serve file directly
filePath := version.PackageURL
file, err := os.Open(filePath)
if err != nil {
respondError(w, http.StatusNotFound, "FILE_NOT_FOUND", "Package not available")
return
}
defer file.Close()
stat, _ := file.Stat()
// Set headers
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s-%s.mosis",
packageID, version.VersionName))
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
w.Header().Set("X-Mosis-Version", version.VersionName)
w.Header().Set("X-Mosis-Signature", version.Signature)
// Stream file
io.Copy(w, file)
}
// Alternative: Return download info + serve via static file handler
func (h *StoreHandler) GetDownloadInfo(w http.ResponseWriter, r *http.Request) {
packageID := chi.URLParam(r, "packageID")
version, _ := h.repo.GetLatestPublished(packageID)
respondJSON(w, http.StatusOK, map[string]interface{}{
"download_url": fmt.Sprintf("/downloads/%s/%s/%d/package.mosis",
version.DeveloperID, version.AppID, version.VersionCode),
"version": version.VersionName,
"size": version.PackageSize,
"signature": version.Signature,
})
}
Static File Server
// In router setup - serve packages directory
r.Handle("/downloads/*", http.StripPrefix("/downloads/",
http.FileServer(http.Dir("/volume1/mosis/packages"))))
Caching (via Nginx or Cloudflare Tunnel)
Cache-Control: public, max-age=86400
- Packages are immutable (version code = unique)
- Put Nginx in front for caching if needed
- Or use Cloudflare Tunnel for edge caching
Icon/Screenshot Handling
Upload (Go)
// Icons uploaded with app creation/update
func (h *AppHandler) UploadIcon(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appID")
r.ParseMultipartForm(10 << 20) // 10MB max
file, _, err := r.FormFile("icon")
if err != nil {
respondError(w, http.StatusBadRequest, "INVALID_FILE", "No icon file")
return
}
defer file.Close()
// Decode and validate
img, _, err := image.Decode(file)
if err != nil {
respondError(w, http.StatusBadRequest, "INVALID_IMAGE", "Cannot decode image")
return
}
bounds := img.Bounds()
if bounds.Dx() != bounds.Dy() {
respondError(w, http.StatusBadRequest, "INVALID_DIMENSIONS", "Icon must be square")
return
}
// Generate multiple sizes
sizes := []int{32, 64, 128}
assetsDir := filepath.Join(h.storagePath, "assets", appID)
os.MkdirAll(assetsDir, 0755)
for _, size := range sizes {
resized := resize.Resize(uint(size), uint(size), img, resize.Lanczos3)
outPath := filepath.Join(assetsDir, fmt.Sprintf("icon-%d.png", size))
out, _ := os.Create(outPath)
png.Encode(out, resized)
out.Close()
}
respondJSON(w, http.StatusOK, map[string]string{"status": "uploaded"})
}
Serving
// Serve assets via static file handler
r.Handle("/assets/*", http.StripPrefix("/assets/",
http.FileServer(http.Dir("/volume1/mosis/assets"))))
URLs:
https://portal.mosis.local/assets/{app_id}/icon-64.png
- Public read access
- Long cache TTL (icons rarely change)
- Nginx caching if needed
Retention Policy
Packages
| Status | Retention |
|---|---|
| Published (current) | Forever |
| Published (old) | 1 year |
| Draft | 30 days |
| Rejected | 7 days |
| Failed validation | 24 hours |
Temp Uploads
- Delete after 1 hour if not completed
- Lifecycle rule on
/temp/prefix
Implementation
# R2 Lifecycle Rules
rules:
- prefix: "temp/"
expiration_days: 1
- prefix: "packages/"
tags:
status: draft
expiration_days: 30
Backup Strategy
Synology Built-in Options
- Hyper Backup - Backup to external drive, another NAS, or cloud
- Snapshot Replication - Point-in-time snapshots (Btrfs)
- rsync - Script-based backup to remote location
Recommended Setup
# Cron job: Daily backup of packages to external drive
0 3 * * * rsync -av /volume1/mosis/packages/ /volumeUSB1/mosis-backup/packages/
# Or use Synology Hyper Backup with versioning
Metadata Backup
- Package metadata in SQLite (portal.db)
- Litestream handles continuous replication
- Can regenerate file paths from DB
Recovery
# Restore from backup
rsync -av /volumeUSB1/mosis-backup/packages/ /volume1/mosis/packages/
# Restore database (via Litestream)
litestream restore -o /data/portal.db /backups/portal/
Monitoring
Metrics to Track
| Metric | Source |
|---|---|
| Upload success rate | API logs |
| Download latency | Cloudflare analytics |
| Storage usage | R2 dashboard |
| Bandwidth | R2 dashboard |
| Error rate | API logs |
Alerts
- Upload failure rate > 5%
- Download error rate > 1%
- Storage > 80% of budget
Security
Access Control
Packages:
- Write: API only (presigned URLs)
- Read: Public (presigned URLs)
Assets:
- Write: API only
- Read: Public (direct CDN)
Temp:
- Write: Presigned URLs (15 min)
- Read: None (API internal only)
Signed URLs
// Presigned URL with expiration
url := r2.PresignPut(key, 15*time.Minute, PutOptions{
ContentType: "application/octet-stream",
ContentLength: maxSize,
})
Deliverables
- Storage approach decided (local Synology filesystem)
- Upload flow implementation (multipart to local)
- Download serving (http.FileServer)
- Icon/screenshot upload and resize
- Temp file cleanup (cron job)
- Backup setup (Hyper Backup or rsync)
- (Optional) R2 sync for CDN
Open Questions
Multi-region storage for lower latency?→ Use R2 sync if neededPackage compression (gzip/brotli)?→ Defer, .mosis is already ZIP- Delta updates storage structure? → Consider for v1.1
Screenshot requirements (dimensions, count)?→ Max 5, 1280x720 or 720x1280