feature (plg_backend_psql): storage connector for postgres

This commit is contained in:
MickaelK 2025-08-28 13:12:27 +10:00
parent fa3cc86fed
commit e4c46aa750
5 changed files with 403 additions and 0 deletions

View file

@ -0,0 +1,154 @@
package plg_backend_psql
import (
"context"
"database/sql"
"fmt"
"io"
. "github.com/mickael-kerjean/filestash/server/common"
_ "github.com/lib/pq"
)
type PSQL struct {
db *sql.DB
ctx context.Context
}
func init() {
Backend.Register("psql", PSQL{})
}
func (this PSQL) Init(params map[string]string, app *App) (IBackend, error) {
host := params["host"]
port := withDefault(params["port"], "5432")
user := params["user"]
password := params["password"]
dbname := withDefault(params["dbname"], "postgres")
sslmode := withDefault(params["sslmode"], "disable")
if host == "" || user == "" || password == "" {
return nil, ErrNotValid
}
db, err := sql.Open(
"postgres",
fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
host, port, user, password, dbname, sslmode,
),
)
if err != nil {
return nil, err
} else if err := db.Ping(); err != nil {
Log.Debug("plg_backend_psql::init err=%s", err.Error())
return nil, ErrNotValid
}
return PSQL{
db: db,
ctx: app.Context,
}, nil
}
func withDefault(val string, def string) string {
if val == "" {
return def
}
return val
}
func (this PSQL) LoginForm() Form {
return Form{
Elmnts: []FormElement{
FormElement{
Name: "type",
Type: "hidden",
Value: "psql",
},
FormElement{
Name: "host",
Type: "text",
Placeholder: "Host",
},
FormElement{
Name: "port",
Type: "number",
Placeholder: "Port",
},
FormElement{
Name: "user",
Type: "text",
Placeholder: "User",
},
FormElement{
Name: "password",
Type: "password",
Placeholder: "Password",
},
FormElement{
Name: "dbname",
Type: "text",
Placeholder: "DB Name",
},
FormElement{
Name: "sslmode",
Type: "text",
Placeholder: "SSL Mode",
},
},
}
}
func (this PSQL) Touch(path string) error { // TODO
this.db.Close()
return ErrNotImplemented
}
func (this PSQL) Save(path string, file io.Reader) error { // TODO
this.db.Close()
return ErrNotImplemented
}
func (this PSQL) Rm(path string) error { // TODO
this.db.Close()
return ErrNotImplemented
}
func (this PSQL) Mkdir(path string) error {
this.db.Close()
return ErrNotValid
}
func (this PSQL) Mv(from string, to string) error {
this.db.Close()
return ErrNotValid
}
func (this PSQL) Meta(path string) Metadata {
location, _ := getPath(path)
return Metadata{
CanCreateDirectory: NewBool(false),
CanCreateFile: func(l Location) *bool {
if l.table == "" {
return NewBool(false)
}
return NewBool(true)
}(location),
CanRename: NewBool(false),
CanDelete: func(l Location) *bool {
if l.table == "" {
return NewBool(false)
}
return NewBool(true)
}(location),
CanMove: NewBool(false),
CanUpload: func(l Location) *bool {
if l.row == "" {
return NewBool(false)
}
return NewBool(true)
}(location),
RefreshOnCreate: NewBool(true),
HideExtension: NewBool(true),
}
}

View file

@ -0,0 +1,62 @@
package plg_backend_psql
import (
"io"
. "github.com/mickael-kerjean/filestash/server/common"
)
func (this PSQL) Cat(path string) (io.ReadCloser, error) {
defer this.db.Close()
l, err := getPath(path)
if err != nil {
return nil, err
}
columnName, err := getKey(this.ctx, this.db, l.table)
if err != nil {
return nil, err
}
rows, err := this.db.QueryContext(this.ctx, `
SELECT *
FROM `+l.table+`
WHERE `+columnName+`='`+l.row+`'
`)
if err != nil {
return nil, err
}
defer rows.Close()
c, err := rows.Columns()
if err != nil {
return nil, err
}
t, err := rows.ColumnTypes()
if err != nil {
return nil, err
}
i := 0
col := make([]any, len(c))
for rows.Next() {
if i != 0 {
return nil, ErrNotValid
}
pcol := make([]any, len(c))
for i, _ := range pcol {
pcol[i] = &col[i]
}
if err := rows.Scan(pcol...); err != nil {
return nil, err
}
}
forms := make([]FormElement, len(c))
for i, _ := range c {
f := formType(t[i].ScanType(), c[i])
f.Name = c[i]
f.Value = col[i]
forms[i] = f
}
b, err := Form{Elmnts: forms}.MarshalJSON()
if err != nil {
return nil, err
}
return NewReadCloserFromBytes(b), nil
}

View file

@ -0,0 +1,64 @@
package plg_backend_psql
import (
"os"
. "github.com/mickael-kerjean/filestash/server/common"
)
func (this PSQL) Ls(path string) ([]os.FileInfo, error) {
defer this.db.Close()
l, err := getPath(path)
if err != nil {
Log.Debug("pl_backend_psql::ls method=getPath err=%s", err.Error())
return nil, err
}
if l.table == "" {
rows, err := this.db.QueryContext(this.ctx, "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")
if err != nil {
Log.Debug("plg_backend_psql::ls method=query err=%s", err.Error())
return nil, err
}
defer rows.Close()
out := []os.FileInfo{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
Log.Debug("plg_backend_psql::ls method=scan err=%s", err.Error())
return nil, err
}
out = append(out, File{
FName: name,
FType: "directory",
})
}
return out, nil
} else if l.row == "" {
key, err := getKey(this.ctx, this.db, l.table)
if err != nil {
Log.Debug("plg_backend_psql::ls method=getKey err=%s", err.Error())
return nil, err
}
rows, err := this.db.QueryContext(this.ctx, "SELECT "+key+" FROM "+l.table+" LIMIT 500000")
if err != nil {
Log.Debug("plg_backend_psql::ls method=query err=%s", err.Error())
return nil, err
}
defer rows.Close()
out := []os.FileInfo{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
Log.Debug("plg_backend_psql::ls method=scan err=%s", err.Error())
return nil, err
}
out = append(out, File{
FName: name + ".form",
FType: "file",
})
}
return out, nil
}
Log.Stdout("plg_backend_psql::ls err=invalid location=%v", l)
return []os.FileInfo{}, ErrNotValid
}

View file

@ -0,0 +1,12 @@
package plg_backend_psql
type Column struct {
Name string
Type string
Constraint string
}
type Location struct {
table string
row string
}

View file

@ -0,0 +1,111 @@
package plg_backend_psql
import (
"context"
"database/sql"
"reflect"
"strings"
. "github.com/mickael-kerjean/filestash/server/common"
)
func getPath(path string) (Location, error) {
l := Location{}
for i, chunk := range strings.Split(path, "/") {
if i == 0 {
if chunk != "" {
return l, ErrNotValid
}
} else if i == 1 {
l.table = chunk
} else if i == 2 {
l.row = strings.TrimSuffix(chunk, ".form")
} else {
return l, ErrNotValid
}
}
return l, nil
}
func getColumns(ctx context.Context, db *sql.DB, table string) ([]Column, error) {
rows, err := db.QueryContext(ctx, `
SELECT c.column_name, c.data_type, tc.constraint_type
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage AS ccu USING (constraint_schema, constraint_name)
JOIN information_schema.columns AS c ON c.table_schema = tc.constraint_schema
AND tc.table_name = c.table_name AND ccu.column_name = c.column_name
WHERE tc.table_name = $1
`, table)
if err != nil {
return nil, err
}
columns := []Column{}
for rows.Next() {
var c Column
if err := rows.Scan(&c.Name, &c.Type, &c.Type); err != nil {
return nil, err
}
columns = append(columns, c)
}
return columns, nil
}
func getKey(ctx context.Context, db *sql.DB, table string) (string, error) {
columns, err := getColumns(ctx, db, table)
if err != nil {
return "", err
}
key := ""
score := 0
for _, column := range columns {
if c := calculateScore(column); c > score {
key = column.Name
score = c
}
}
if key == "" {
return "", ErrNotValid
}
return key, nil
}
func calculateScore(column Column) int {
scoreType := 0
scoreName := 1
switch column.Type {
case "PRIMARY KEY":
scoreType = 3
case "UNIQUE":
scoreType = 2
}
switch strings.ToLower(column.Name) {
case "name":
scoreName = 2
case "label":
scoreName = 2
case "email":
scoreName = 5
}
return scoreType * scoreName
}
func formType(rt reflect.Type, label string) FormElement {
switch rt.String() {
case "bool":
return FormElement{
Type: "boolean",
}
case "time.Time":
return FormElement{
Type: "datetime",
}
}
if strings.Contains(strings.ToLower(label), "password") {
return FormElement{
Type: "password",
}
}
return FormElement{
Type: "text",
}
}