package agent import ( "context" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "net/url" "os" "strconv" "strings" "time" _ "modernc.org/sqlite" "github.com/x-grk/longpolling-remote-access/internal/proto" ) // SQLite databases are read and changed here, through SQLite itself, so a // database that is in use keeps its locks and journal: a value is set with an // UPDATE, not by writing the file over. // dbCell is one value as the panel shows and sends it: t is "i" (integer), // "r" (real), "s" (text), "b" (blob, v its size, not editable) or "n" (NULL). type dbCell struct { T string `json:"t"` V string `json:"v,omitempty"` } func cellOf(v any) dbCell { switch x := v.(type) { case nil: return dbCell{T: "n"} case int64: return dbCell{T: "i", V: strconv.FormatInt(x, 10)} case float64: return dbCell{T: "r", V: strconv.FormatFloat(x, 'g', -1, 64)} case string: return dbCell{T: "s", V: x} case []byte: return dbCell{T: "b", V: strconv.Itoa(len(x))} case bool: if x { return dbCell{T: "i", V: "1"} } return dbCell{T: "i", V: "0"} case time.Time: return dbCell{T: "s", V: x.Format(time.RFC3339Nano)} } return dbCell{T: "s", V: fmt.Sprint(v)} } // value is what a cell becomes for SQLite. func (c dbCell) value() (any, error) { switch c.T { case "n": return nil, nil case "i": n, err := strconv.ParseInt(strings.TrimSpace(c.V), 10, 64) if err != nil { return nil, fmt.Errorf("%q - не целое число", c.V) } return n, nil case "r": f, err := strconv.ParseFloat(strings.TrimSpace(c.V), 64) if err != nil { return nil, fmt.Errorf("%q - не число", c.V) } return f, nil case "x": b, err := hex.DecodeString(strings.ReplaceAll(c.V, " ", "")) if err != nil { return nil, errors.New("blob - нужны шестнадцатеричные байты") } return b, nil } return c.V, nil } type dbArgs struct { Path string `json:"path"` Table string `json:"table,omitempty"` Offset int `json:"offset,omitempty"` Limit int `json:"limit,omitempty"` Key json.RawMessage `json:"key,omitempty"` Keys []json.RawMessage `json:"keys,omitempty"` Column string `json:"column,omitempty"` Value *dbCell `json:"value,omitempty"` Values map[string]dbCell `json:"values,omitempty"` SQL string `json:"sql,omitempty"` } func dbOpen(path string) (*sql.DB, error) { info, err := os.Stat(path) if err != nil { return nil, err } if info.IsDir() { return nil, errors.New("это каталог") } u := url.URL{Scheme: "file", Path: path} db, err := sql.Open("sqlite", u.String()+"?mode=rw&_pragma=busy_timeout(5000)") if err != nil { return nil, err } db.SetMaxOpenConns(1) return db, nil } func qid(name string) string { return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` } // dbTable is a table's shape: its columns, and how a row is found again - // by rowid, by its primary key (a WITHOUT ROWID table), or not at all (a // view: shown, not changed). type dbTable struct { Name string `json:"name"` Type string `json:"type"` Columns []dbColumn `json:"columns"` Key string `json:"key"` // "rowid", "pk" or "" // Alias is the INTEGER PRIMARY KEY column: another name for the rowid. Alias string `json:"alias,omitempty"` pk []string } type dbColumn struct { Name string `json:"name"` Type string `json:"type"` NotNull bool `json:"notnull,omitempty"` PK bool `json:"pk,omitempty"` Default string `json:"default,omitempty"` } func dbTableInfo(ctx context.Context, db *sql.DB, name string) (*dbTable, error) { t := &dbTable{Name: name} if err := db.QueryRowContext(ctx, `SELECT type FROM sqlite_master WHERE name = ? AND type IN ('table','view')`, name).Scan(&t.Type); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("нет таблицы %s", name) } return nil, err } rows, err := db.QueryContext(ctx, `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)`, name) if err != nil { return nil, err } defer rows.Close() type pkc struct { n int col string } var pks []pkc for rows.Next() { var c dbColumn var notnull, pk int var dflt sql.NullString if err := rows.Scan(&c.Name, &c.Type, ¬null, &dflt, &pk); err != nil { return nil, err } c.NotNull, c.PK, c.Default = notnull == 1, pk > 0, dflt.String if pk > 0 { pks = append(pks, pkc{pk, c.Name}) } t.Columns = append(t.Columns, c) } if err := rows.Err(); err != nil { return nil, err } for i := 1; i <= len(pks); i++ { for _, p := range pks { if p.n == i { t.pk = append(t.pk, p.col) } } } if t.Type == "table" { if r, err := db.QueryContext(ctx, `SELECT rowid FROM `+qid(name)+` LIMIT 0`); err == nil { r.Close() t.Key = "rowid" if len(t.pk) == 1 { for _, c := range t.Columns { if c.Name == t.pk[0] && strings.EqualFold(strings.TrimSpace(c.Type), "INTEGER") { t.Alias = c.Name } } } } else if len(t.pk) > 0 { t.Key = "pk" } } return t, nil } // where is the condition that finds the row by its key: a rowid, or the // primary key's cells in order. func (t *dbTable) where(key json.RawMessage) (string, []any, error) { switch t.Key { case "rowid": var id int64 if err := json.Unmarshal(key, &id); err != nil { var s string if json.Unmarshal(key, &s) != nil { return "", nil, errors.New("плохой ключ строки") } if id, err = strconv.ParseInt(s, 10, 64); err != nil { return "", nil, errors.New("плохой ключ строки") } } return "rowid = ?", []any{id}, nil case "pk": var cells []dbCell if err := json.Unmarshal(key, &cells); err != nil || len(cells) != len(t.pk) { return "", nil, errors.New("плохой ключ строки") } parts, args := make([]string, len(t.pk)), make([]any, len(t.pk)) for i, col := range t.pk { v, err := cells[i].value() if err != nil { return "", nil, err } parts[i], args[i] = qid(col)+" IS ?", v } return strings.Join(parts, " AND "), args, nil } return "", nil, errors.New("строки этой таблицы нельзя менять: нет ни rowid, ни первичного ключа") } func (t *dbTable) column(name string) bool { for _, c := range t.Columns { if c.Name == name { return true } } return false } // dbTask runs one database task. func dbTask(env proto.TaskEnvelope) (any, error) { var a dbArgs if err := json.Unmarshal(env.Args, &a); err != nil { return nil, err } db, err := dbOpen(a.Path) if err != nil { return nil, err } defer db.Close() ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) defer cancel() if env.Type == proto.TaskDBTables { rows, err := db.QueryContext(ctx, `SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY type, name`) if err != nil { return nil, dbErr(err) } defer rows.Close() type item struct { Name string `json:"name"` Type string `json:"type"` } out := []item{} for rows.Next() { var it item if err := rows.Scan(&it.Name, &it.Type); err != nil { return nil, err } out = append(out, it) } return map[string]any{"tables": out}, rows.Err() } if env.Type == proto.TaskDBSQL { return dbSQL(ctx, db, a.SQL) } t, err := dbTableInfo(ctx, db, a.Table) if err != nil { return nil, dbErr(err) } switch env.Type { case proto.TaskDBRows: return dbRows(ctx, db, t, a.Offset, a.Limit) case proto.TaskDBSet: if !t.column(a.Column) || a.Value == nil { return nil, errors.New("нет такого столбца") } cond, args, err := t.where(a.Key) if err != nil { return nil, err } v, err := a.Value.value() if err != nil { return nil, err } res, err := db.ExecContext(ctx, `UPDATE `+qid(t.Name)+` SET `+qid(a.Column)+` = ? WHERE `+cond, append([]any{v}, args...)...) if err != nil { return nil, dbErr(err) } if n, _ := res.RowsAffected(); n != 1 { return nil, fmt.Errorf("изменено строк: %d - строку могли удалить или поменять", n) } // A key column changed: the row is found by its new key now. out := map[string]any{} switch { case t.Key == "rowid" && a.Column == t.Alias: args = []any{v} out["key"] = a.Value.V case t.Key == "pk": var cells []dbCell _ = json.Unmarshal(a.Key, &cells) for i, col := range t.pk { if col == a.Column { args[i], cells[i] = v, *a.Value out["key"] = cells } } } // What SQLite stored, after its type affinity. var got any _ = db.QueryRowContext(ctx, `SELECT `+qid(a.Column)+` FROM `+qid(t.Name)+` WHERE `+cond, args...).Scan(&got) out["value"] = cellOf(got) if k, ok := out["key"].([]dbCell); ok { for i, col := range t.pk { if col == a.Column { k[i] = cellOf(got) } } } return out, nil case proto.TaskDBAdd: if t.Type != "table" { return nil, errors.New("в представление строки не добавляются") } var cols, marks []string var vals []any for name, c := range a.Values { if !t.column(name) { return nil, fmt.Errorf("нет столбца %s", name) } v, err := c.value() if err != nil { return nil, err } cols, marks, vals = append(cols, qid(name)), append(marks, "?"), append(vals, v) } q := `INSERT INTO ` + qid(t.Name) + ` DEFAULT VALUES` if len(cols) > 0 { q = `INSERT INTO ` + qid(t.Name) + ` (` + strings.Join(cols, ", ") + `) VALUES (` + strings.Join(marks, ", ") + `)` } res, err := db.ExecContext(ctx, q, vals...) if err != nil { return nil, dbErr(err) } id, _ := res.LastInsertId() return map[string]any{"rowid": id}, nil case proto.TaskDBDel: tx, err := db.BeginTx(ctx, nil) if err != nil { return nil, dbErr(err) } defer tx.Rollback() n := int64(0) for _, k := range a.Keys { cond, args, err := t.where(k) if err != nil { return nil, err } res, err := tx.ExecContext(ctx, `DELETE FROM `+qid(t.Name)+` WHERE `+cond, args...) if err != nil { return nil, dbErr(err) } c, _ := res.RowsAffected() n += c } if err := tx.Commit(); err != nil { return nil, dbErr(err) } return map[string]any{"deleted": n}, nil } return nil, errors.New("неизвестная операция") } // dbRows is a page of a table's rows, with each row's key. func dbRows(ctx context.Context, db *sql.DB, t *dbTable, offset, limit int) (any, error) { if limit <= 0 || limit > 1000 { limit = 200 } var total int64 _ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+qid(t.Name)).Scan(&total) sel := make([]string, 0, len(t.Columns)+1) if t.Key == "rowid" { sel = append(sel, "rowid") } for _, c := range t.Columns { sel = append(sel, qid(c.Name)) } order := "" switch t.Key { case "rowid": order = " ORDER BY rowid" case "pk": parts := make([]string, len(t.pk)) for i, c := range t.pk { parts[i] = qid(c) } order = " ORDER BY " + strings.Join(parts, ", ") } rows, err := db.QueryContext(ctx, `SELECT `+strings.Join(sel, ", ")+` FROM `+qid(t.Name)+order+` LIMIT ? OFFSET ?`, limit, offset) if err != nil { return nil, dbErr(err) } defer rows.Close() type row struct { K any `json:"k,omitempty"` V []dbCell `json:"v"` } out := []row{} n := len(sel) for rows.Next() { vals := make([]any, n) ptrs := make([]any, n) for i := range vals { ptrs[i] = &vals[i] } if err := rows.Scan(ptrs...); err != nil { return nil, err } r := row{} if t.Key == "rowid" { r.K = cellOf(vals[0]).V vals = vals[1:] } for _, v := range vals { r.V = append(r.V, cellOf(v)) } if t.Key == "pk" { key := make([]dbCell, len(t.pk)) for i, pk := range t.pk { for j, c := range t.Columns { if c.Name == pk { key[i] = r.V[j] } } } r.K = key } out = append(out, r) } return map[string]any{"table": t, "total": total, "offset": offset, "rows": out}, rows.Err() } // dbSQL runs what was typed: a query gives up to 500 rows, anything else // how many rows it changed. func dbSQL(ctx context.Context, db *sql.DB, q string) (any, error) { q = strings.TrimSpace(q) if q == "" { return nil, errors.New("пустой запрос") } head := strings.ToLower(strings.Fields(q)[0]) if head == "select" || head == "pragma" || head == "with" || head == "explain" || head == "values" { rows, err := db.QueryContext(ctx, q) if err != nil { return nil, dbErr(err) } defer rows.Close() cols, _ := rows.Columns() out := [][]dbCell{} more := false for rows.Next() { if len(out) == 500 { more = true break } vals := make([]any, len(cols)) ptrs := make([]any, len(cols)) for i := range vals { ptrs[i] = &vals[i] } if err := rows.Scan(ptrs...); err != nil { return nil, err } r := make([]dbCell, len(cols)) for i, v := range vals { r[i] = cellOf(v) } out = append(out, r) } return map[string]any{"columns": cols, "rows": out, "more": more}, dbErr(rows.Err()) } res, err := db.ExecContext(ctx, q) if err != nil { return nil, dbErr(err) } n, _ := res.RowsAffected() return map[string]any{"changed": n}, nil } // dbErr says the common SQLite troubles in words. func dbErr(err error) error { if err == nil { return nil } m := err.Error() switch { case strings.Contains(m, "file is not a database"): return errors.New("это не база SQLite") case strings.Contains(m, "database is locked"), strings.Contains(m, "SQLITE_BUSY"): return errors.New("база занята другой программой - попробуйте ещё раз") case strings.Contains(m, "readonly"): return errors.New("база только для чтения (нет прав на файл или его папку)") } return err }