feat: edit SQLite databases in the file manager - the agent opens them through SQLite: values by a click, rows added and deleted, an SQL tab; changes go to the audit log

This commit is contained in:
redtea committed 2026-10-15 16:50:56 +03:00
1 parent 0279c1d887
commit 0fa3de757c
7 files changed
+947 -13

No files matched your search

+512
View File
@@ -0,0 +1,512 @@
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, &notnull, &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
}