package store import ( "context" "encoding/json" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/store/db" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) // ErrEmailTaken is returned by RegisterUser when the email is already // registered — a UNIQUE constraint violation (pgerrcode 23505) on // users.email. var ErrEmailTaken = errors.New("store: email already registered") // Account/Template/Domain are provider-neutral domain structs returned by the // thin wrappers below, so callers (internal/api) never need to import // internal/store/db directly. type Account struct { ID uuid.UUID ProjectID uuid.UUID Provider string SecretEnc string Comment string } func accountFromDB(a db.ProviderAccount) Account { return Account{ID: a.ID, ProjectID: a.ProjectID, Provider: a.Provider, SecretEnc: a.SecretEnc, Comment: a.Comment} } func (s *Store) CreateAccount(ctx context.Context, projectID uuid.UUID, provider, secretEnc, comment string) (Account, error) { a, err := s.q.CreateAccount(ctx, db.CreateAccountParams{ ID: uuid.New(), ProjectID: projectID, Provider: provider, SecretEnc: secretEnc, Comment: comment, }) if err != nil { return Account{}, err } return accountFromDB(a), nil } func (s *Store) ListAccounts(ctx context.Context, projectID uuid.UUID) ([]Account, error) { rows, err := s.q.ListAccounts(ctx, projectID) if err != nil { return nil, err } out := make([]Account, 0, len(rows)) for _, r := range rows { out = append(out, accountFromDB(r)) } return out, nil } func (s *Store) GetAccount(ctx context.Context, id, projectID uuid.UUID) (Account, error) { a, err := s.q.GetAccount(ctx, db.GetAccountParams{ID: id, ProjectID: projectID}) if err != nil { return Account{}, err } return accountFromDB(a), nil } func (s *Store) DeleteAccount(ctx context.Context, id, projectID uuid.UUID) error { return s.q.DeleteAccount(ctx, db.DeleteAccountParams{ID: id, ProjectID: projectID}) } type Template struct { ID uuid.UUID ProjectID uuid.UUID Name string Doc dto.TemplateDoc Version int32 } func templateFromDB(t db.Template) Template { var doc dto.TemplateDoc if t.Doc != nil { doc = *t.Doc } return Template{ID: t.ID, ProjectID: t.ProjectID, Name: t.Name, Doc: doc, Version: t.Version} } func (s *Store) CreateTemplate(ctx context.Context, projectID uuid.UUID, name string, doc dto.TemplateDoc) (Template, error) { d := doc t, err := s.q.CreateTemplate(ctx, db.CreateTemplateParams{ID: uuid.New(), ProjectID: projectID, Name: name, Doc: &d}) if err != nil { return Template{}, err } return templateFromDB(t), nil } func (s *Store) ListTemplates(ctx context.Context, projectID uuid.UUID) ([]Template, error) { rows, err := s.q.ListTemplates(ctx, projectID) if err != nil { return nil, err } out := make([]Template, 0, len(rows)) for _, r := range rows { out = append(out, templateFromDB(r)) } return out, nil } func (s *Store) UpdateTemplate(ctx context.Context, id, projectID uuid.UUID, name string, doc dto.TemplateDoc) (Template, error) { d := doc t, err := s.q.UpdateTemplate(ctx, db.UpdateTemplateParams{ID: id, ProjectID: projectID, Name: name, Doc: &d}) if err != nil { return Template{}, err } return templateFromDB(t), nil } func (s *Store) DeleteTemplate(ctx context.Context, id, projectID uuid.UUID) error { return s.q.DeleteTemplate(ctx, db.DeleteTemplateParams{ID: id, ProjectID: projectID}) } // GetTemplate is a scoped lookup used to verify a template belongs to // projectID before it is referenced elsewhere (e.g. CreateDomain). func (s *Store) GetTemplate(ctx context.Context, id, projectID uuid.UUID) (Template, error) { t, err := s.q.GetTemplate(ctx, db.GetTemplateParams{ID: id, ProjectID: projectID}) if err != nil { return Template{}, err } return templateFromDB(t), nil } type Domain struct { ID uuid.UUID ProjectID uuid.UUID ProviderAccountID uuid.UUID ZoneName string ZoneID string TemplateID *uuid.UUID LastCheckStatus string } func domainFromDB(d db.Domain) Domain { return Domain{ ID: d.ID, ProjectID: d.ProjectID, ProviderAccountID: d.ProviderAccountID, ZoneName: d.ZoneName, ZoneID: d.ZoneID, TemplateID: d.TemplateID, LastCheckStatus: d.LastCheckStatus, } } func (s *Store) CreateDomain(ctx context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (Domain, error) { d, err := s.q.CreateDomain(ctx, db.CreateDomainParams{ ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: zoneName, ZoneID: zoneID, TemplateID: templateID, }) if err != nil { return Domain{}, err } return domainFromDB(d), nil } func (s *Store) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domain, error) { rows, err := s.q.ListDomains(ctx, projectID) if err != nil { return nil, err } out := make([]Domain, 0, len(rows)) for _, r := range rows { out = append(out, domainFromDB(r)) } return out, nil } func (s *Store) DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error { return s.q.DeleteDomain(ctx, db.DeleteDomainParams{ID: id, ProjectID: projectID}) } // GetDomain is a scoped lookup used to verify a domain belongs to projectID // before it's referenced elsewhere (e.g. history — check_runs isn't itself // scoped by project, so callers must confirm domain ownership first). func (s *Store) GetDomain(ctx context.Context, id, projectID uuid.UUID) (Domain, error) { d, err := s.q.GetDomain(ctx, db.GetDomainParams{ID: id, ProjectID: projectID}) if err != nil { return Domain{}, err } return domainFromDB(d), nil } // ImportDomains creates one domain per zone inside a single transaction: if // any zone fails to be created, the whole batch is rolled back so callers // never observe a partially-imported set of domains. // // Import is idempotent: zones that already have a domain for this project // (enforced by the domains_project_zone_uniq constraint) are silently // skipped via ON CONFLICT DO NOTHING rather than erroring or duplicating — // so a repeated POST .../import never creates duplicate domains. Only the // zones that were actually newly created are returned. func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]Domain, error) { tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) // no-op once Commit has succeeded q := s.q.WithTx(tx) out := make([]Domain, 0, len(zones)) for _, z := range zones { d, err := q.ImportDomain(ctx, db.ImportDomainParams{ ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: z.Name, ZoneID: z.ID, TemplateID: nil, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { // ON CONFLICT DO NOTHING: this zone was already imported // for this project — skip it rather than fail the batch. continue } return nil, err } out = append(out, domainFromDB(d)) } if err := tx.Commit(ctx); err != nil { return nil, err } return out, nil } // SetDomainTemplate attaches (or clears, when templateID is nil) the DNS // template used to check/apply a domain. When templateID is non-nil it must // belong to the same project — verified via scoped GetTemplate — otherwise // a caller could bind a domain to another tenant's template. func (s *Store) SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (Domain, error) { if templateID != nil { if _, err := s.GetTemplate(ctx, *templateID, projectID); err != nil { return Domain{}, err } } d, err := s.q.UpdateDomainTemplate(ctx, db.UpdateDomainTemplateParams{ ID: domainID, ProjectID: projectID, TemplateID: templateID, }) if err != nil { return Domain{}, err } return domainFromDB(d), nil } // GetDomainStatus returns the last known check status for a domain (Фаза 3 // scheduler/checker). Callers scope access to the domain themselves (e.g. // via a prior GetDomain) — this lookup is by primary key alone. func (s *Store) GetDomainStatus(ctx context.Context, domainID uuid.UUID) (string, error) { return s.q.GetDomainStatus(ctx, domainID) } // SetDomainStatus records the outcome of the most recent check/apply run for // a domain (e.g. "ok", "drift", "error"). Scoped by projectID — a domain ID // belonging to another tenant's project is left untouched (matches zero // rows) rather than being overwritten, closing an IDOR-on-write where a // caller's own valid pid + a foreign did could otherwise flip a stranger's // domain status. func (s *Store) SetDomainStatus(ctx context.Context, domainID, projectID uuid.UUID, status string) error { return s.q.SetDomainStatus(ctx, db.SetDomainStatusParams{ID: domainID, LastCheckStatus: status, ProjectID: projectID}) } // CountDriftDomains returns the current number of domains system-wide whose // last check status is "drift". This is a global count (not per-project) — // it backs the dns_ar_drift_domains gauge, which is a system-level metric. func (s *Store) CountDriftDomains(ctx context.Context) (int, error) { n, err := s.q.CountDriftDomains(ctx) return int(n), err } // User and Project are provider-neutral domain structs for the auth/tenant // layer (Фаза 2), mirroring the Account/Template/Domain wrappers above so // callers never need to import internal/store/db directly. type User struct { ID uuid.UUID Email string PasswordHash string } type Project struct { ID uuid.UUID UserID uuid.UUID Name string } // ptr is a small helper for passing a Go string into a nullable text column // (password_hash) via sqlc's generated *string param type. func ptr(s string) *string { return &s } // strFromPtr converts a nullable text column back into a plain string; a // nil password_hash never happens on the real registration flow (an argon2 // hash is always supplied), but is handled defensively here. func strFromPtr(p *string) string { if p == nil { return "" } return *p } func toUser(u db.User) User { return User{ID: u.ID, Email: u.Email, PasswordHash: strFromPtr(u.PasswordHash)} } func toProject(p db.Project) Project { return Project{ID: p.ID, UserID: p.UserID, Name: p.Name} } func (s *Store) CreateUser(ctx context.Context, email, passwordHash string) (User, error) { u, err := s.q.CreateUser(ctx, db.CreateUserParams{ID: uuid.New(), Email: email, PasswordHash: ptr(passwordHash)}) if err != nil { return User{}, err } return toUser(u), nil } func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) { u, err := s.q.GetUserByEmail(ctx, email) if err != nil { return User{}, err } return toUser(u), nil } // GetUserByID looks up a user by primary key — used by handleMe (Task 3 // hardening) to return the authenticated caller's real email instead of // leaving it blank. func (s *Store) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) { u, err := s.q.GetUserByID(ctx, id) if err != nil { return User{}, err } return toUser(u), nil } func (s *Store) CreateProjectForUser(ctx context.Context, userID uuid.UUID, name string) (Project, error) { p, err := s.q.CreateProject(ctx, db.CreateProjectParams{ID: uuid.New(), UserID: userID, Name: name}) if err != nil { return Project{}, err } return toProject(p), nil } func (s *Store) GetProjectOwned(ctx context.Context, projectID, userID uuid.UUID) (Project, error) { p, err := s.q.GetProjectOwned(ctx, db.GetProjectOwnedParams{ID: projectID, UserID: userID}) if err != nil { return Project{}, err } return toProject(p), nil } func (s *Store) GetUserProject(ctx context.Context, userID uuid.UUID) (Project, error) { p, err := s.q.GetUserProject(ctx, userID) if err != nil { return Project{}, err } return toProject(p), nil } func (s *Store) CreateSession(ctx context.Context, userID uuid.UUID, tokenHash string, expiresAt time.Time) error { return s.q.CreateSession(ctx, db.CreateSessionParams{ ID: uuid.New(), UserID: userID, TokenHash: tokenHash, ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true}, }) } // GetSessionUser returns the owning user ID for a non-expired session token; // expired sessions are excluded by the query itself (expires_at > now()). func (s *Store) GetSessionUser(ctx context.Context, tokenHash string) (uuid.UUID, error) { return s.q.GetSessionUser(ctx, tokenHash) } func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error { return s.q.DeleteSession(ctx, tokenHash) } // RegisterUser creates a user and their default project in one transaction, // mirroring the ImportDomains pattern above: if project creation fails, the // user insert is rolled back too, so a caller never observes a user without // a default project. func (s *Store) RegisterUser(ctx context.Context, email, passwordHash string) (User, Project, error) { tx, err := s.pool.Begin(ctx) if err != nil { return User{}, Project{}, err } defer tx.Rollback(ctx) // no-op once Commit has succeeded q := s.q.WithTx(tx) uid := uuid.New() dbu, err := q.CreateUser(ctx, db.CreateUserParams{ID: uid, Email: email, PasswordHash: ptr(passwordHash)}) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return User{}, Project{}, ErrEmailTaken } return User{}, Project{}, err } dbp, err := q.CreateProject(ctx, db.CreateProjectParams{ID: uuid.New(), UserID: uid, Name: "default"}) if err != nil { return User{}, Project{}, err } if err := tx.Commit(ctx); err != nil { return User{}, Project{}, err } return toUser(dbu), toProject(dbp), nil } // Schedule and Channel are provider-neutral domain structs for the // scheduler/notifications layer (Фаза 3), mirroring the wrappers above so // callers never need to import internal/store/db or pgtype directly. type Schedule struct { ID uuid.UUID ProjectID uuid.UUID IntervalSeconds int32 Enabled bool LastRunAt *time.Time } // timeFromTimestamptz converts a nullable pgtype.Timestamptz (schedules.last_run_at) // into a *time.Time, nil when the column is NULL (schedule never ran). func timeFromTimestamptz(t pgtype.Timestamptz) *time.Time { if !t.Valid { return nil } tt := t.Time return &tt } // timestamptzFromTime is the inverse of timeFromTimestamptz, used to pass a // Go time.Time (or nil) into a nullable timestamptz query parameter. func timestamptzFromTime(t *time.Time) pgtype.Timestamptz { if t == nil { return pgtype.Timestamptz{} } return pgtype.Timestamptz{Time: *t, Valid: true} } func scheduleFromDB(s db.Schedule) Schedule { return Schedule{ ID: s.ID, ProjectID: s.ProjectID, IntervalSeconds: s.IntervalSeconds, Enabled: s.Enabled, LastRunAt: timeFromTimestamptz(s.LastRunAt), } } // GetSchedule looks up the schedule row for projectID. When no schedule has // ever been created for the project it returns pgx.ErrNoRows unwrapped — // the API layer (Task 5) is expected to treat that as the default schedule // {interval: 3600, enabled: false} rather than an error. func (s *Store) GetSchedule(ctx context.Context, projectID uuid.UUID) (Schedule, error) { sc, err := s.q.GetSchedule(ctx, projectID) if err != nil { return Schedule{}, err } return scheduleFromDB(sc), nil } // UpsertSchedule creates or updates the (single, UNIQUE) schedule row for a // project: an existing row has its interval/enabled flag updated in place // rather than a second row being inserted. func (s *Store) UpsertSchedule(ctx context.Context, projectID uuid.UUID, interval int32, enabled bool) (Schedule, error) { sc, err := s.q.UpsertSchedule(ctx, db.UpsertScheduleParams{ ID: uuid.New(), ProjectID: projectID, IntervalSeconds: interval, Enabled: enabled, }) if err != nil { return Schedule{}, err } return scheduleFromDB(sc), nil } // ListDueSchedules returns every enabled schedule that is due to run at // `now`: either it has never run (last_run_at IS NULL) or its interval has // elapsed since the last run. func (s *Store) ListDueSchedules(ctx context.Context, now time.Time) ([]Schedule, error) { rows, err := s.q.ListDueSchedules(ctx, pgtype.Timestamptz{Time: now, Valid: true}) if err != nil { return nil, err } out := make([]Schedule, 0, len(rows)) for _, r := range rows { out = append(out, scheduleFromDB(r)) } return out, nil } // TouchScheduleRun records that a project's schedule ran at `at`, so the // next ListDueSchedules call excludes it until the interval elapses again. func (s *Store) TouchScheduleRun(ctx context.Context, projectID uuid.UUID, at time.Time) error { return s.q.TouchScheduleRun(ctx, db.TouchScheduleRunParams{ ProjectID: projectID, LastRunAt: timestamptzFromTime(&at), }) } type Channel struct { ID uuid.UUID ProjectID uuid.UUID Type string Config json.RawMessage SecretEnc string Enabled bool } // channelFromDB never logs SecretEnc — callers must not either (secrets are // encrypted at rest, but the plaintext blob should still stay out of logs). func channelFromDB(c db.NotificationChannel) Channel { return Channel{ ID: c.ID, ProjectID: c.ProjectID, Type: c.Type, Config: json.RawMessage(c.Config), SecretEnc: c.SecretEnc, Enabled: c.Enabled, } } func (s *Store) CreateChannel(ctx context.Context, projectID uuid.UUID, ctype string, config json.RawMessage, secretEnc string) (Channel, error) { c, err := s.q.CreateChannel(ctx, db.CreateChannelParams{ ID: uuid.New(), ProjectID: projectID, Type: ctype, Config: []byte(config), SecretEnc: secretEnc, }) if err != nil { return Channel{}, err } return channelFromDB(c), nil } func (s *Store) ListChannels(ctx context.Context, projectID uuid.UUID) ([]Channel, error) { rows, err := s.q.ListChannels(ctx, projectID) if err != nil { return nil, err } out := make([]Channel, 0, len(rows)) for _, r := range rows { out = append(out, channelFromDB(r)) } return out, nil } // ListEnabledChannels returns only the channels a project has enabled — used // by the notification dispatcher (Фаза 3) so disabled channels are silently // skipped rather than filtered by every caller. func (s *Store) ListEnabledChannels(ctx context.Context, projectID uuid.UUID) ([]Channel, error) { rows, err := s.q.ListEnabledChannels(ctx, projectID) if err != nil { return nil, err } out := make([]Channel, 0, len(rows)) for _, r := range rows { out = append(out, channelFromDB(r)) } return out, nil } // GetChannel is scoped by projectID: a channel ID belonging to another // tenant's project returns pgx.ErrNoRows rather than the foreign channel. func (s *Store) GetChannel(ctx context.Context, id, projectID uuid.UUID) (Channel, error) { c, err := s.q.GetChannel(ctx, db.GetChannelParams{ID: id, ProjectID: projectID}) if err != nil { return Channel{}, err } return channelFromDB(c), nil } func (s *Store) DeleteChannel(ctx context.Context, id, projectID uuid.UUID) error { return s.q.DeleteChannel(ctx, db.DeleteChannelParams{ID: id, ProjectID: projectID}) }