finalize M06-M12 with Go/SQLite/Synology NAS implementation decisions
This commit is contained in:
@@ -1,13 +1,64 @@
|
||||
# Milestone 7: CDN & Storage
|
||||
|
||||
**Status**: Planning
|
||||
**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
|
||||
|
||||
1. **Self-hosted** - All data stays on premises
|
||||
2. **Zero cost** - No cloud storage fees
|
||||
3. **Simple** - Go binary serves files directly via http.FileServer
|
||||
4. **Fast local** - NAS has gigabit+ internal network
|
||||
5. **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 worldwide. Must be cost-effective, fast, and reliable.
|
||||
Storage handles app packages, icons, screenshots, and serves downloads to devices. For self-hosted Synology NAS deployment, local filesystem is the primary storage.
|
||||
|
||||
---
|
||||
|
||||
@@ -170,84 +221,120 @@ Pricing: VPS cost only
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Primary: Cloudflare R2**
|
||||
- Zero egress fees (biggest cost saver)
|
||||
- S3-compatible (easy migration)
|
||||
- Built-in global CDN
|
||||
- Good enough tooling
|
||||
**Primary: Local Synology Filesystem**
|
||||
- Zero recurring costs
|
||||
- All data on premises
|
||||
- Simple Go file serving
|
||||
- Synology's built-in backup tools
|
||||
|
||||
**Fallback: Backblaze B2 + Cloudflare**
|
||||
- If R2 has issues
|
||||
- Even cheaper storage
|
||||
- Slightly more setup
|
||||
**Optional: Cloudflare R2 for CDN**
|
||||
- If global distribution needed
|
||||
- Sync packages via cron/background job
|
||||
- Zero egress fees
|
||||
- S3-compatible API
|
||||
|
||||
---
|
||||
|
||||
## Upload Flow
|
||||
|
||||
### Presigned URL Approach
|
||||
### Local Filesystem Approach
|
||||
|
||||
```
|
||||
┌────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Client │───►│ API │───►│ R2 │
|
||||
└────────┘ └─────────┘ └─────────┘
|
||||
│ │ │
|
||||
│ 1. Request upload URL │
|
||||
│◄─────────────┤ │
|
||||
│ (presigned URL) │
|
||||
│ │
|
||||
│ 2. Upload directly │
|
||||
│────────────────────────────►│
|
||||
│ │
|
||||
│ 3. Notify complete │
|
||||
│─────────────►│ │
|
||||
│ │ 4. Validate │
|
||||
│ │─────────────►│
|
||||
│ │ │
|
||||
│ 5. Confirm │ │
|
||||
│◄─────────────┤ │
|
||||
┌────────┐ ┌─────────┐ ┌─────────────────┐
|
||||
│ Client │───►│ API │───►│ Local Filesystem│
|
||||
└────────┘ └─────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
│ 1. Upload package (multipart) │
|
||||
│─────────────►│ │
|
||||
│ │ │
|
||||
│ │ 2. Save to temp │
|
||||
│ │─────────────────►│
|
||||
│ │ │
|
||||
│ │ 3. Validate │
|
||||
│ │ 4. Move to final│
|
||||
│ │─────────────────►│
|
||||
│ │ │
|
||||
│ 5. Confirm │ │
|
||||
│◄─────────────┤ │
|
||||
```
|
||||
|
||||
### API Implementation
|
||||
### Go Implementation (Local Storage)
|
||||
|
||||
```go
|
||||
// 1. Request upload URL
|
||||
func CreateVersion(c *gin.Context) {
|
||||
// Create version record
|
||||
version := Version{
|
||||
AppID: appID,
|
||||
VersionCode: req.VersionCode,
|
||||
Status: "uploading",
|
||||
// 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
|
||||
}
|
||||
db.Create(&version)
|
||||
defer file.Close()
|
||||
|
||||
// Generate presigned URL
|
||||
key := fmt.Sprintf("temp/%s/package.mosis", version.ID)
|
||||
url, err := r2.PresignPut(key, 15*time.Minute)
|
||||
// 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,
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"version": version,
|
||||
"upload_url": url,
|
||||
"expires": time.Now().Add(15 * time.Minute),
|
||||
})
|
||||
// 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)
|
||||
}
|
||||
```
|
||||
|
||||
// 3. Upload complete notification
|
||||
func UploadComplete(c *gin.Context) {
|
||||
// Move from temp to final location
|
||||
tempKey := fmt.Sprintf("temp/%s/package.mosis", versionID)
|
||||
finalKey := fmt.Sprintf("packages/%s/%s/%d/package.mosis",
|
||||
developerID, appID, versionCode)
|
||||
### Optional: Presigned URL for R2 CDN
|
||||
|
||||
r2.Copy(tempKey, finalKey)
|
||||
r2.Delete(tempKey)
|
||||
If global distribution is needed, sync to R2 and generate presigned download URLs:
|
||||
|
||||
// Update version status
|
||||
version.Status = "validating"
|
||||
version.PackageURL = finalKey
|
||||
```go
|
||||
// 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)
|
||||
|
||||
// Trigger async validation
|
||||
queue.Publish("validate-package", version.ID)
|
||||
file, _ := os.Open(localPath)
|
||||
defer file.Close()
|
||||
|
||||
_, err := s.r2.PutObject(r2Key, file)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
@@ -255,71 +342,142 @@ func UploadComplete(c *gin.Context) {
|
||||
|
||||
## Download Flow
|
||||
|
||||
### Public Downloads
|
||||
### Local File Serving
|
||||
|
||||
```go
|
||||
// Get download URL (short-lived)
|
||||
func GetDownloadURL(c *gin.Context) {
|
||||
version := getLatestPublishedVersion(packageID)
|
||||
// Serve package downloads directly from filesystem
|
||||
func (h *StoreHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
packageID := chi.URLParam(r, "packageID")
|
||||
|
||||
// Generate presigned download URL (1 hour)
|
||||
url, _ := r2.PresignGet(version.PackageURL, 1*time.Hour)
|
||||
// Get latest published version
|
||||
version, err := h.repo.GetLatestPublished(packageID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "NOT_FOUND", "App not found")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"download_url": url,
|
||||
"version": version.VersionName,
|
||||
"size": version.PackageSize,
|
||||
"signature": version.Signature,
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### CDN Caching
|
||||
### Static File Server
|
||||
|
||||
```go
|
||||
// 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)
|
||||
- Cache aggressively at edge
|
||||
- Invalidate only if package is pulled
|
||||
- Put Nginx in front for caching if needed
|
||||
- Or use Cloudflare Tunnel for edge caching
|
||||
|
||||
---
|
||||
|
||||
## Icon/Screenshot Handling
|
||||
|
||||
### Upload
|
||||
### Upload (Go)
|
||||
|
||||
```go
|
||||
// Icons uploaded with app creation/update
|
||||
func UploadIcon(c *gin.Context) {
|
||||
file, _ := c.FormFile("icon")
|
||||
func (h *AppHandler) UploadIcon(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appID")
|
||||
|
||||
// Validate dimensions
|
||||
img, _ := png.Decode(file)
|
||||
if img.Bounds().Dx() != img.Bounds().Dy() {
|
||||
return error("Icon must be square")
|
||||
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(img, size, size)
|
||||
key := fmt.Sprintf("assets/%s/icon-%d.png", appID, size)
|
||||
r2.Put(key, resized)
|
||||
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
|
||||
|
||||
```go
|
||||
// Serve assets via static file handler
|
||||
r.Handle("/assets/*", http.StripPrefix("/assets/",
|
||||
http.FileServer(http.Dir("/volume1/mosis/assets"))))
|
||||
```
|
||||
https://cdn.mosis.dev/assets/{app_id}/icon-64.png
|
||||
|
||||
URLs:
|
||||
```
|
||||
https://portal.mosis.local/assets/{app_id}/icon-64.png
|
||||
```
|
||||
|
||||
- Public read access
|
||||
- Long cache TTL (icons rarely change)
|
||||
- Cloudflare image optimization (optional)
|
||||
- Nginx caching if needed
|
||||
|
||||
---
|
||||
|
||||
@@ -358,17 +516,36 @@ rules:
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
### R2 Built-in
|
||||
### Synology Built-in Options
|
||||
|
||||
- 11 nines durability
|
||||
- Automatic replication within region
|
||||
- No additional backup needed for packages
|
||||
- **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
|
||||
|
||||
```bash
|
||||
# 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 PostgreSQL
|
||||
- PostgreSQL backups cover this
|
||||
- Can regenerate URLs from DB
|
||||
- Package metadata in SQLite (portal.db)
|
||||
- Litestream handles continuous replication
|
||||
- Can regenerate file paths from DB
|
||||
|
||||
### Recovery
|
||||
|
||||
```bash
|
||||
# Restore from backup
|
||||
rsync -av /volumeUSB1/mosis-backup/packages/ /volume1/mosis/packages/
|
||||
|
||||
# Restore database (via Litestream)
|
||||
litestream restore -o /data/portal.db /backups/portal/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -424,23 +601,22 @@ url := r2.PresignPut(key, 15*time.Minute, PutOptions{
|
||||
|
||||
## Deliverables
|
||||
|
||||
- [ ] R2 bucket setup
|
||||
- [ ] Presigned URL generation
|
||||
- [ ] Upload flow implementation
|
||||
- [ ] Download URL generation
|
||||
- [ ] Icon/screenshot upload
|
||||
- [ ] Lifecycle rules for cleanup
|
||||
- [ ] Monitoring dashboard
|
||||
- [ ] Cost tracking
|
||||
- [x] 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
|
||||
|
||||
1. Multi-region storage for lower latency?
|
||||
2. Package compression (gzip/brotli)?
|
||||
3. Delta updates storage structure?
|
||||
4. Screenshot requirements (dimensions, count)?
|
||||
1. ~~Multi-region storage for lower latency?~~ → Use R2 sync if needed
|
||||
2. ~~Package compression (gzip/brotli)?~~ → Defer, .mosis is already ZIP
|
||||
3. Delta updates storage structure? → Consider for v1.1
|
||||
4. ~~Screenshot requirements (dimensions, count)?~~ → Max 5, 1280x720 or 720x1280
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user