diff --git a/go.mod b/go.mod index 3b95c7c..f1a9d9c 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-co-op/gocron/v2 v2.19.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.8.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -42,6 +43,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -52,6 +54,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect diff --git a/go.sum b/go.sum index 96f8ad7..8211d46 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-co-op/gocron/v2 v2.19.1 h1:B4iLeA0NB/2iO3EKQ7NfKn5KsQgZfjb2fkvoZJU3yBI= +github.com/go-co-op/gocron/v2 v2.19.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= @@ -67,6 +69,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -105,6 +109,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/pkg/storage/scheduled_jobs.go b/pkg/storage/scheduled_jobs.go new file mode 100644 index 0000000..7315e3f --- /dev/null +++ b/pkg/storage/scheduled_jobs.go @@ -0,0 +1,111 @@ +package storage + +import ( + "database/sql" + "time" +) + +// ScheduledJob represents a cron-based scheduled scan job. +type ScheduledJob struct { + ID int64 + Name string + CronExpr string + ScanCommand string + NotifyTelegram bool + Enabled bool + LastRun *time.Time + NextRun *time.Time + CreatedAt time.Time +} + +// SaveScheduledJob inserts a new scheduled job. Returns the new row ID. +func (db *DB) SaveScheduledJob(j ScheduledJob) (int64, error) { + res, err := db.sql.Exec( + `INSERT INTO scheduled_jobs (name, cron_expr, scan_command, notify_telegram, enabled) + VALUES (?, ?, ?, ?, ?)`, + j.Name, j.CronExpr, j.ScanCommand, j.NotifyTelegram, j.Enabled, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// ListScheduledJobs returns all scheduled jobs. +func (db *DB) ListScheduledJobs() ([]ScheduledJob, error) { + rows, err := db.sql.Query( + `SELECT id, name, cron_expr, scan_command, notify_telegram, enabled, last_run, next_run, created_at + FROM scheduled_jobs ORDER BY id`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var jobs []ScheduledJob + for rows.Next() { + var j ScheduledJob + var lastRun, nextRun sql.NullTime + if err := rows.Scan(&j.ID, &j.Name, &j.CronExpr, &j.ScanCommand, + &j.NotifyTelegram, &j.Enabled, &lastRun, &nextRun, &j.CreatedAt); err != nil { + return nil, err + } + if lastRun.Valid { + j.LastRun = &lastRun.Time + } + if nextRun.Valid { + j.NextRun = &nextRun.Time + } + jobs = append(jobs, j) + } + return jobs, rows.Err() +} + +// GetScheduledJob returns a single scheduled job by name. +func (db *DB) GetScheduledJob(name string) (*ScheduledJob, error) { + var j ScheduledJob + var lastRun, nextRun sql.NullTime + err := db.sql.QueryRow( + `SELECT id, name, cron_expr, scan_command, notify_telegram, enabled, last_run, next_run, created_at + FROM scheduled_jobs WHERE name = ?`, name, + ).Scan(&j.ID, &j.Name, &j.CronExpr, &j.ScanCommand, + &j.NotifyTelegram, &j.Enabled, &lastRun, &nextRun, &j.CreatedAt) + if err != nil { + return nil, err + } + if lastRun.Valid { + j.LastRun = &lastRun.Time + } + if nextRun.Valid { + j.NextRun = &nextRun.Time + } + return &j, nil +} + +// DeleteScheduledJob removes a scheduled job by name. Returns rows affected. +func (db *DB) DeleteScheduledJob(name string) (int64, error) { + res, err := db.sql.Exec(`DELETE FROM scheduled_jobs WHERE name = ?`, name) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// UpdateJobLastRun updates the last_run and next_run timestamps for a job. +func (db *DB) UpdateJobLastRun(name string, lastRun time.Time, nextRun *time.Time) error { + var nr sql.NullTime + if nextRun != nil { + nr = sql.NullTime{Time: *nextRun, Valid: true} + } + _, err := db.sql.Exec( + `UPDATE scheduled_jobs SET last_run = ?, next_run = ? WHERE name = ?`, + lastRun, nr, name, + ) + return err +} + +// SetJobEnabled updates the enabled flag for a scheduled job. +func (db *DB) SetJobEnabled(name string, enabled bool) error { + _, err := db.sql.Exec(`UPDATE scheduled_jobs SET enabled = ? WHERE name = ?`, enabled, name) + return err +} diff --git a/pkg/storage/schema.sql b/pkg/storage/schema.sql index 964dad9..84e91d9 100644 --- a/pkg/storage/schema.sql +++ b/pkg/storage/schema.sql @@ -55,3 +55,23 @@ CREATE TABLE IF NOT EXISTS custom_dorks ( CREATE INDEX IF NOT EXISTS idx_custom_dorks_source ON custom_dorks(source); CREATE INDEX IF NOT EXISTS idx_custom_dorks_category ON custom_dorks(category); + +-- Phase 17: Telegram bot subscribers for auto-notifications. +CREATE TABLE IF NOT EXISTS subscribers ( + chat_id INTEGER PRIMARY KEY, + username TEXT, + subscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Phase 17: Cron-based scheduled scan jobs. +CREATE TABLE IF NOT EXISTS scheduled_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + cron_expr TEXT NOT NULL, + scan_command TEXT NOT NULL, + notify_telegram BOOLEAN DEFAULT FALSE, + enabled BOOLEAN DEFAULT TRUE, + last_run DATETIME, + next_run DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); diff --git a/pkg/storage/subscribers.go b/pkg/storage/subscribers.go new file mode 100644 index 0000000..fa6fd3b --- /dev/null +++ b/pkg/storage/subscribers.go @@ -0,0 +1,54 @@ +package storage + +import "time" + +// Subscriber represents a Telegram chat subscribed to scan notifications. +type Subscriber struct { + ChatID int64 + Username string + SubscribedAt time.Time +} + +// AddSubscriber inserts or replaces a subscriber in the database. +func (db *DB) AddSubscriber(chatID int64, username string) error { + _, err := db.sql.Exec( + `INSERT OR REPLACE INTO subscribers (chat_id, username, subscribed_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, + chatID, username, + ) + return err +} + +// RemoveSubscriber deletes a subscriber by chat ID. Returns rows affected. +func (db *DB) RemoveSubscriber(chatID int64) (int64, error) { + res, err := db.sql.Exec(`DELETE FROM subscribers WHERE chat_id = ?`, chatID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// ListSubscribers returns all subscribers ordered by subscription time. +func (db *DB) ListSubscribers() ([]Subscriber, error) { + rows, err := db.sql.Query(`SELECT chat_id, username, subscribed_at FROM subscribers ORDER BY subscribed_at`) + if err != nil { + return nil, err + } + defer rows.Close() + + var subs []Subscriber + for rows.Next() { + var s Subscriber + if err := rows.Scan(&s.ChatID, &s.Username, &s.SubscribedAt); err != nil { + return nil, err + } + subs = append(subs, s) + } + return subs, rows.Err() +} + +// IsSubscribed returns true if the given chat ID is subscribed. +func (db *DB) IsSubscribed(chatID int64) (bool, error) { + var count int + err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscribers WHERE chat_id = ?`, chatID).Scan(&count) + return count > 0, err +}