513 lines
14 KiB
Go
513 lines
14 KiB
Go
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
|
|
}
|