package webapp import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "sync" ) // The file backend: each repository is one JSON file, cached in memory and // rewritten atomically (temp + rename) on every change — the exact on-disk // format and discipline the registries used before the store abstraction, so a // running hub upgrades with no migration. // writeFileAtomic writes data to path via a temp file + rename. Files land at // 0600 (os.CreateTemp's default); dirMode controls the parent directory. func writeFileAtomic(path string, data []byte, dirMode os.FileMode) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, dirMode); err != nil { return err } tmp, err := os.CreateTemp(dir, ".bdrive-tmp-*") if err != nil { return err } defer os.Remove(tmp.Name()) if _, err := tmp.Write(data); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Chmod(tmp.Name(), 0o600); err != nil { return err } return os.Rename(tmp.Name(), path) } func readJSONFile(path string, into any) (found bool, err error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return false, nil } return false, err } if err := json.Unmarshal(data, into); err != nil { return false, fmt.Errorf("parse %s: %w", path, err) } return true, nil } // fileMetaStore bundles the five file repositories rooted in one directory, // keeping the historical filenames so an existing hub's data loads unchanged. type fileMetaStore struct { accounts *fileAccountRepo projects *fileProjectRepo orgs *fileOrgRepo shares *fileShareRepo devices *fileDeviceRepo reads *fileReadRepo } // OpenFileStore builds the file backend over dir, using the historical // filenames (auth.json, projects.json, orgs.json, shares.json, devices.json). func OpenFileStore(dir string) (MetaStore, error) { return &fileMetaStore{ accounts: newFileAccountRepo(filepath.Join(dir, "auth.json")), projects: newFileProjectRepo(filepath.Join(dir, "projects.json")), orgs: newFileOrgRepo(filepath.Join(dir, "orgs.json")), shares: newFileShareRepo(filepath.Join(dir, "shares.json")), devices: newFileDeviceRepo(filepath.Join(dir, "devices.json")), reads: newFileReadRepo(filepath.Join(dir, "reads.json")), }, nil } func (s *fileMetaStore) Accounts() AccountRepo { return s.accounts } func (s *fileMetaStore) Projects() ProjectRepo { return s.projects } func (s *fileMetaStore) Orgs() OrgRepo { return s.orgs } func (s *fileMetaStore) Shares() ShareRepo { return s.shares } func (s *fileMetaStore) Devices() DeviceRepo { return s.devices } func (s *fileMetaStore) Reads() ReadRepo { return s.reads } func (s *fileMetaStore) Close() error { return nil } // ---- accounts (auth.json: users + tokens + policy) ---- type authFileShape struct { Users []*authUser `json:"users"` Tokens []authToken `json:"tokens"` Policy *authPolicy `json:"policy,omitempty"` } type fileAccountRepo struct { path string mu sync.Mutex users map[string]*authUser tokens map[string]authToken policy *authPolicy } func newFileAccountRepo(path string) *fileAccountRepo { return &fileAccountRepo{path: path, users: map[string]*authUser{}, tokens: map[string]authToken{}} } func (r *fileAccountRepo) Load() ([]*authUser, []authToken, *authPolicy, error) { r.mu.Lock() defer r.mu.Unlock() var f authFileShape if _, err := readJSONFile(r.path, &f); err != nil { return nil, nil, nil, err } r.users = map[string]*authUser{} r.tokens = map[string]authToken{} for _, u := range f.Users { r.users[u.ID] = u } for _, t := range f.Tokens { r.tokens[t.Hash] = t } r.policy = f.Policy return f.Users, f.Tokens, f.Policy, nil } // write persists users, tokens, and policy. Callers hold mu. func (r *fileAccountRepo) write() error { var f authFileShape for _, u := range r.users { f.Users = append(f.Users, u) } for _, t := range r.tokens { f.Tokens = append(f.Tokens, t) } f.Policy = r.policy data, err := json.MarshalIndent(f, "", " ") if err != nil { return err } return writeFileAtomic(r.path, append(data, '\n'), 0o700) // holds password hashes } func (r *fileAccountRepo) PutAccount(u *authUser) error { r.mu.Lock() defer r.mu.Unlock() r.users[u.ID] = u return r.write() } func (r *fileAccountRepo) DeleteAccount(id string) error { r.mu.Lock() defer r.mu.Unlock() delete(r.users, id) return r.write() } func (r *fileAccountRepo) PutToken(t authToken) error { r.mu.Lock() defer r.mu.Unlock() r.tokens[t.Hash] = t return r.write() } func (r *fileAccountRepo) DeleteToken(hash string) error { r.mu.Lock() defer r.mu.Unlock() delete(r.tokens, hash) return r.write() } func (r *fileAccountRepo) PutPolicy(p authPolicy) error { r.mu.Lock() defer r.mu.Unlock() r.policy = &p return r.write() } // ---- projects (projects.json) ---- type fileProjectRepo struct { path string mu sync.Mutex byID map[string]Project } func newFileProjectRepo(path string) *fileProjectRepo { return &fileProjectRepo{path: path, byID: map[string]Project{}} } func (r *fileProjectRepo) Load() ([]Project, error) { r.mu.Lock() defer r.mu.Unlock() var f struct { Projects []Project `json:"projects"` } if _, err := readJSONFile(r.path, &f); err != nil { return nil, err } r.byID = map[string]Project{} for _, p := range f.Projects { r.byID[p.ID] = p } return f.Projects, nil } func (r *fileProjectRepo) write() error { list := make([]Project, 0, len(r.byID)) for _, p := range r.byID { list = append(list, p) } sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name }) data, err := json.MarshalIndent(struct { Projects []Project `json:"projects"` }{list}, "", " ") if err != nil { return err } return writeFileAtomic(r.path, append(data, '\n'), 0o755) } func (r *fileProjectRepo) Put(p Project) error { r.mu.Lock() defer r.mu.Unlock() r.byID[p.ID] = p return r.write() } func (r *fileProjectRepo) Delete(id string) error { r.mu.Lock() defer r.mu.Unlock() delete(r.byID, id) return r.write() } // ---- orgs (orgs.json: orgs + invites) ---- type fileOrgRepo struct { path string mu sync.Mutex byID map[string]Org invites map[string]OrgInvite } func newFileOrgRepo(path string) *fileOrgRepo { return &fileOrgRepo{path: path, byID: map[string]Org{}, invites: map[string]OrgInvite{}} } func (r *fileOrgRepo) Load() ([]Org, []OrgInvite, error) { r.mu.Lock() defer r.mu.Unlock() var f struct { Orgs []Org `json:"orgs"` Invites []OrgInvite `json:"invites"` } if _, err := readJSONFile(r.path, &f); err != nil { return nil, nil, err } r.byID = map[string]Org{} r.invites = map[string]OrgInvite{} for _, o := range f.Orgs { r.byID[o.ID] = o } for _, i := range f.Invites { r.invites[i.Token] = i } return f.Orgs, f.Invites, nil } func (r *fileOrgRepo) write() error { var f struct { Orgs []Org `json:"orgs"` Invites []OrgInvite `json:"invites"` } for _, o := range r.byID { f.Orgs = append(f.Orgs, o) } sort.Slice(f.Orgs, func(i, j int) bool { return f.Orgs[i].ID < f.Orgs[j].ID }) for _, i := range r.invites { if !i.expired() { f.Invites = append(f.Invites, i) } } sort.Slice(f.Invites, func(i, j int) bool { return f.Invites[i].Token < f.Invites[j].Token }) data, err := json.MarshalIndent(f, "", " ") if err != nil { return err } return writeFileAtomic(r.path, append(data, '\n'), 0o755) } func (r *fileOrgRepo) PutOrg(o Org) error { r.mu.Lock() defer r.mu.Unlock() r.byID[o.ID] = o return r.write() } func (r *fileOrgRepo) DeleteOrg(id string) error { r.mu.Lock() defer r.mu.Unlock() delete(r.byID, id) return r.write() } func (r *fileOrgRepo) PutInvite(i OrgInvite) error { r.mu.Lock() defer r.mu.Unlock() r.invites[i.Token] = i return r.write() } func (r *fileOrgRepo) DeleteInvite(token string) error { r.mu.Lock() defer r.mu.Unlock() delete(r.invites, token) return r.write() } // ---- shares (shares.json) ---- type fileShareRepo struct { path string mu sync.Mutex byToken map[string]Share } func newFileShareRepo(path string) *fileShareRepo { return &fileShareRepo{path: path, byToken: map[string]Share{}} } func (r *fileShareRepo) Load() ([]Share, error) { r.mu.Lock() defer r.mu.Unlock() var f struct { Shares []Share `json:"shares"` } if _, err := readJSONFile(r.path, &f); err != nil { return nil, err } r.byToken = map[string]Share{} for _, s := range f.Shares { r.byToken[s.Token] = s } return f.Shares, nil } func (r *fileShareRepo) write() error { var f struct { Shares []Share `json:"shares"` } for _, s := range r.byToken { f.Shares = append(f.Shares, s) } data, err := json.MarshalIndent(f, "", " ") if err != nil { return err } return writeFileAtomic(r.path, append(data, '\n'), 0o755) } func (r *fileShareRepo) Put(s Share) error { r.mu.Lock() defer r.mu.Unlock() r.byToken[s.Token] = s return r.write() } func (r *fileShareRepo) Delete(token string) error { r.mu.Lock() defer r.mu.Unlock() delete(r.byToken, token) return r.write() } // ---- devices (devices.json) ---- type fileDeviceRepo struct { path string mu sync.Mutex byID map[string]DeviceInfo } func newFileDeviceRepo(path string) *fileDeviceRepo { return &fileDeviceRepo{path: path, byID: map[string]DeviceInfo{}} } func (r *fileDeviceRepo) Load() ([]DeviceInfo, error) { r.mu.Lock() defer r.mu.Unlock() var f struct { Devices []DeviceInfo `json:"devices"` } if _, err := readJSONFile(r.path, &f); err != nil { return nil, err } r.byID = map[string]DeviceInfo{} for _, d := range f.Devices { r.byID[d.ID] = d } return f.Devices, nil } func (r *fileDeviceRepo) write() error { var f struct { Devices []DeviceInfo `json:"devices"` } for _, d := range r.byID { f.Devices = append(f.Devices, d) } data, err := json.MarshalIndent(f, "", " ") if err != nil { return err } return writeFileAtomic(r.path, append(data, '\n'), 0o755) } func (r *fileDeviceRepo) Put(d DeviceInfo) error { r.mu.Lock() defer r.mu.Unlock() r.byID[d.ID] = d return r.write() } // ---- reads (reads.json) ---- type fileReadRepo struct { path string mu sync.Mutex byKey map[ReadStatKey]ReadStat } func newFileReadRepo(path string) *fileReadRepo { return &fileReadRepo{path: path, byKey: map[ReadStatKey]ReadStat{}} } func (r *fileReadRepo) Load() ([]ReadStat, error) { r.mu.Lock() defer r.mu.Unlock() var f struct { Reads []ReadStat `json:"reads"` } if _, err := readJSONFile(r.path, &f); err != nil { return nil, err } r.byKey = map[ReadStatKey]ReadStat{} for _, st := range f.Reads { r.byKey[st.key()] = st } return f.Reads, nil } func (r *fileReadRepo) write() error { var f struct { Reads []ReadStat `json:"reads"` } f.Reads = make([]ReadStat, 0, len(r.byKey)) for _, st := range r.byKey { f.Reads = append(f.Reads, st) } sort.Slice(f.Reads, func(i, j int) bool { a, b := f.Reads[i], f.Reads[j] if a.Path != b.Path { return a.Path < b.Path } return a.Day < b.Day }) data, err := json.Marshal(f) // telemetry: compact beats pretty if err != nil { return err } // 0700 dir: buckets carry actor emails, like auth.json carries accounts. return writeFileAtomic(r.path, append(data, '\n'), 0o700) } func (r *fileReadRepo) PutBatch(stats []ReadStat) error { r.mu.Lock() defer r.mu.Unlock() for _, st := range stats { r.byKey[st.key()] = st } return r.write() } func (r *fileReadRepo) DeleteBatch(keys []ReadStatKey) error { r.mu.Lock() defer r.mu.Unlock() for _, k := range keys { delete(r.byKey, k) } return r.write() }