diff --git a/docs/reference/api/tfm_api.py b/docs/reference/api/tfm_api.py new file mode 100644 index 0000000..f2d7825 --- /dev/null +++ b/docs/reference/api/tfm_api.py @@ -0,0 +1,374 @@ +from configparser import ConfigParser +from psycopg2.pool import ThreadedConnectionPool +from psycopg2.extras import RealDictCursor +from contextlib import contextmanager +from os import access, W_OK, makedirs, chmod, system +from os.path import isfile, join, basename +from shutil import move +from magic import Magic +from preview_generator.manager import PreviewManager + +conf = None + +mage = None +previewer = None + +db_pool = None + +DEFAULT_SORTING = { + "files": { + "key": "created", + "asc": False + }, + "tags": { + "key": "created", + "asc": False + }, + "categories": { + "key": "created", + "asc": False + }, + "pools": { + "key": "created", + "asc": False + }, +} + + +def Initialize(conf_path="/etc/tfm/tfm.conf"): + global mage, previewer + load_config(conf_path) + mage = Magic(mime=True) + previewer = PreviewManager(conf["Paths"]["Thumbs"]) + db_connect(conf["DB.limits"]["MinimumConnections"], conf["DB.limits"]["MaximumConnections"], **conf["DB.params"]) + + +def load_config(path): + global conf + conf = ConfigParser() + conf.read(path) + + +def db_connect(minconn, maxconn, **kwargs): + global db_pool + db_pool = ThreadedConnectionPool(minconn, maxconn, **kwargs) + + +@contextmanager +def _db_cursor(): + global db_pool + try: + conn = db_pool.getconn() + except: + raise RuntimeError("Database not connected") + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + yield cur + conn.commit() + except: + conn.rollback() + raise + finally: + db_pool.putconn(conn) + + +def _validate_column_name(cur, table, column): + cur.execute("SELECT get_column_names(%s) AS name", (table,)) + if all([column!=col["name"] for col in cur.fetchall()]): + raise RuntimeError("Invalid column name") + + +def authorize(username, password, useragent): + with _db_cursor() as cur: + cur.execute("SELECT tfm_session_request(tfm_user_auth(%s, %s), %s) AS sid", (username, password, useragent)) + sid = cur.fetchone()["sid"] + return TSession(sid) + + +class TSession: + sid = None + + def __init__(self, sid): + with _db_cursor() as cur: + cur.execute("SELECT tfm_session_validate(%s) IS NOT NULL AS valid", (sid,)) + if not cur.fetchone()["valid"]: + raise RuntimeError("Invalid sid") + self.sid = sid + + def terminate(self): + with _db_cursor() as cur: + cur.execute("CALL tfm_session_terminate(%s)", (self.sid,)) + del self + + @property + def username(self): + with _db_cursor() as cur: + cur.execute("SELECT tfm_session_username(%s) AS name", (self.sid,)) + return cur.fetchone()["name"] + + @property + def is_admin(self): + with _db_cursor() as cur: + cur.execute("SELECT * FROM tfm_user_get_info(%s)", (self.sid,)) + return cur.fetchone()["can_edit"] + + def get_files(self, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None): + with _db_cursor() as cur: + _validate_column_name(cur, "v_files", order_key) + cur.execute("SELECT * FROM tfm_get_files(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % ( + order_key, + "ASC" if order_asc else "DESC", + int(offset), + int(limit) if limit is not None else "ALL" + ), (self.sid,)) + return list(map(dict, cur.fetchall())) + + def get_files_by_filter(self, philter=None, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None): + with _db_cursor() as cur: + _validate_column_name(cur, "v_files", order_key) + cur.execute("SELECT * FROM tfm_get_files_by_filter(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % ( + order_key, + "ASC" if order_asc else "DESC", + int(offset), + int(limit) if limit is not None else "ALL" + ), (self.sid, philter)) + return list(map(dict, cur.fetchall())) + + def get_tags(self, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None): + with _db_cursor() as cur: + _validate_column_name(cur, "v_tags", order_key) + cur.execute("SELECT * FROM tfm_get_tags(%%s) ORDER BY %s %s, name ASC OFFSET %s LIMIT %s" % ( + order_key, + "ASC" if order_asc else "DESC", + int(offset), + int(limit) if limit is not None else "ALL" + ), (self.sid,)) + return list(map(dict, cur.fetchall())) + + def get_categories(self, order_key=DEFAULT_SORTING["categories"]["key"], order_asc=DEFAULT_SORTING["categories"]["asc"], offset=0, limit=None): + with _db_cursor() as cur: + _validate_column_name(cur, "v_categories", order_key) + cur.execute("SELECT * FROM tfm_get_categories(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % ( + order_key, + "ASC" if order_asc else "DESC", + int(offset), + int(limit) if limit is not None else "ALL" + ), (self.sid,)) + return list(map(dict, cur.fetchall())) + + def get_pools(self, order_key=DEFAULT_SORTING["pools"]["key"], order_asc=DEFAULT_SORTING["pools"]["asc"], offset=0, limit=None): + with _db_cursor() as cur: + _validate_column_name(cur, "v_pools", order_key) + cur.execute("SELECT * FROM tfm_get_pools(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % ( + order_key, + "ASC" if order_asc else "DESC", + int(offset), + int(limit) if limit is not None else "ALL" + ), (self.sid,)) + return list(map(dict, cur.fetchall())) + + def get_autotags(self, order_key="child_id", order_asc=True, offset=0, limit=None): + with _db_cursor() as cur: + _validate_column_name(cur, "v_autotags", order_key) + cur.execute("SELECT * FROM tfm_get_autotags(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % ( + order_key, + "ASC" if order_asc else "DESC", + int(offset), + int(limit) if limit is not None else "ALL" + ), (self.sid,)) + return list(map(dict, cur.fetchall())) + + def get_my_sessions(self, order_key="started", order_asc=False, offset=0, limit=None): + with _db_cursor() as cur: + _validate_column_name(cur, "v_sessions", order_key) + cur.execute("SELECT * FROM tfm_get_my_sessions(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % ( + order_key, + "ASC" if order_asc else "DESC", + int(offset), + int(limit) if limit is not None else "ALL" + ), (self.sid,)) + return list(map(dict, cur.fetchall())) + + def get_tags_by_file(self, file_id, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None): + with _db_cursor() as cur: + _validate_column_name(cur, "v_tags", order_key) + cur.execute("SELECT * FROM tfm_get_tags_by_file(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % ( + order_key, + "ASC" if order_asc else "DESC", + int(offset), + int(limit) if limit is not None else "ALL" + ), (self.sid, file_id)) + return list(map(dict, cur.fetchall())) + + def get_files_by_tag(self, tag_id, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None): + with _db_cursor() as cur: + _validate_column_name(cur, "v_files", order_key) + cur.execute("SELECT * FROM tfm_get_files_by_tag(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % ( + order_key, + "ASC" if order_asc else "DESC", + int(offset), + int(limit) if limit is not None else "ALL" + ), (self.sid, tag_id)) + return list(map(dict, cur.fetchall())) + + def get_files_by_pool(self, pool_id, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None): + with _db_cursor() as cur: + _validate_column_name(cur, "v_files", order_key) + cur.execute("SELECT * FROM tfm_get_files_by_pool(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % ( + order_key, + "ASC" if order_asc else "DESC", + int(offset), + int(limit) if limit is not None else "ALL" + ), (self.sid, pool_id)) + return list(map(dict, cur.fetchall())) + + def get_parent_tags(self, tag_id, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None): + with _db_cursor() as cur: + _validate_column_name(cur, "v_tags", order_key) + cur.execute("SELECT * FROM tfm_get_parent_tags(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % ( + order_key, + "ASC" if order_asc else "DESC", + int(offset), + int(limit) if limit is not None else "ALL" + ), (self.sid, tag_id)) + return list(map(dict, cur.fetchall())) + + def get_my_file_views(self, file_id=None, order_key="datetime", order_asc=False, offset=0, limit=None): + with _db_cursor() as cur: + _validate_column_name(cur, "v_files", order_key) + cur.execute("SELECT * FROM tfm_get_my_file_views(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % ( + order_key, + "ASC" if order_asc else "DESC", + int(offset), + int(limit) if limit is not None else "ALL" + ), (self.sid, file_id)) + return list(map(dict, cur.fetchall())) + + def get_file(self, file_id): + with _db_cursor() as cur: + cur.execute("SELECT * FROM tfm_get_files(%s) WHERE id=%s", (self.sid, file_id)) + return cur.fetchone() + + def get_tag(self, tag_id): + with _db_cursor() as cur: + cur.execute("SELECT * FROM tfm_get_tags(%s) WHERE id=%s", (self.sid, tag_id)) + return cur.fetchone() + + def get_category(self, category_id): + with _db_cursor() as cur: + cur.execute("SELECT * FROM tfm_get_categories(%s) WHERE id=%s", (self.sid, category_id)) + return cur.fetchone() + + def view_file(self, file_id): + with _db_cursor() as cur: + cur.execute("CALL tfm_view_file(%s, %s)", (self.sid, file_id)) + + def add_file(self, path, datetime=None, notes=None, is_private=None, orig_name=True): + if not isfile(path): + raise FileNotFoundError("No such file '%s'" % path) + if not access(conf["Paths"]["Files"], W_OK) or not access(conf["Paths"]["Thumbs"], W_OK): + raise PermissionError("Invalid directories for files and thumbs") + mime = mage.from_file(path) + if orig_name == True: + orig_name = basename(path) + with _db_cursor() as cur: + cur.execute("SELECT * FROM tfm_add_file(%s, %s, %s, %s, %s, %s)", (self.sid, mime, datetime, notes, is_private, orig_name)) + res = cur.fetchone() + file_id = res["f_id"] + ext = res["ext"] + file_path = join(conf["Paths"]["Files"], file_id) + move(path, file_path) + thumb_path = previewer.get_jpeg_preview(file_path, height=160, width=160) + preview_path = previewer.get_jpeg_preview(file_path, height=1080, width=1920) + chmod(file_path, 0o664) + chmod(thumb_path, 0o664) + chmod(preview_path, 0o664) + return file_id, ext + + def add_tag(self, name, notes=None, color=None, category_id=None, is_private=None): + if color is not None: + color = color.replace('#', '') + if not category_id: + category_id = None + with _db_cursor() as cur: + cur.execute("SELECT tfm_add_tag(%s, %s, %s, %s, %s, %s) AS id", (self.sid, name, notes, color, category_id, is_private)) + return cur.fetchone()["id"] + + def add_category(self, name, notes=None, color=None, is_private=None): + if color is not None: + color = color.replace('#', '') + with _db_cursor() as cur: + cur.execute("SELECT tfm_add_category(%s, %s, %s, %s, %s) AS id", (self.sid, name, notes, color, is_private)) + return cur.fetchone()["id"] + + def add_pool(self, name, notes=None, parent_id=None, is_private=None): + with _db_cursor() as cur: + cur.execute("SELECT tfm_add_pool(%s, %s, %s, %s, %s) AS id", (self.sid, name, notes, parent_id, is_private)) + return cur.fetchone()["id"] + + def add_autotag(self, child_id, parent_id, is_active=None, apply_to_existing=None): + with _db_cursor() as cur: + cur.execute("SELECT tfm_add_autotag(%s, %s, %s, %s, %s) AS added", (self.sid, child_id, parent_id, is_active, apply_to_existing)) + return cur.fetchone()["added"] + + def add_file_to_tag(self, file_id, tag_id): + with _db_cursor() as cur: + cur.execute("SELECT tfm_add_file_to_tag(%s, %s, %s) AS id", (self.sid, file_id, tag_id)) + return list(map(lambda t: t["id"], cur.fetchall())) + + def add_file_to_pool(self, file_id, pool_id): + with _db_cursor() as cur: + cur.execute("SELECT tfm_add_file_to_pool(%s, %s, %s) AS added", (self.sid, file_id, pool_id)) + return cur.fetchone()["added"] + + def edit_file(self, file_id, mime=None, datetime=None, notes=None, is_private=None): + with _db_cursor() as cur: + cur.execute("CALL tfm_edit_file(%s, %s, %s, %s, %s, %s)", (self.sid, file_id, mime, datetime, notes, is_private)) + + def edit_tag(self, tag_id, name=None, notes=None, color=None, category_id=None, is_private=None): + if color is not None: + color = color.replace('#', '') + if not category_id: + category_id = None + with _db_cursor() as cur: + cur.execute("CALL tfm_edit_tag(%s, %s, %s, %s, %s, %s, %s)", (self.sid, tag_id, name, notes, color, category_id, is_private)) + + def edit_category(self, category_id, name=None, notes=None, color=None, is_private=None): + if color is not None: + color = color.replace('#', '') + with _db_cursor() as cur: + cur.execute("CALL tfm_edit_category(%s, %s, %s, %s, %s, %s)", (self.sid, category_id, name, notes, color, is_private)) + + def edit_pool(self, pool_id, name=None, notes=None, parent_id=None, is_private=None): + with _db_cursor() as cur: + cur.execute("CALL tfm_edit_pool(%s, %s, %s, %s, %s, %s)", (self.sid, pool_id, name, notes, parent_id, is_private)) + + def remove_file(self, file_id): + with _db_cursor() as cur: + cur.execute("CALL tfm_remove_file(%s, %s)", (self.sid, file_id)) + if system("rm %s/%s*" % (conf["Paths"]["Files"], file_id)): + raise RuntimeError("Failed to remove file '%s'" % file_id) + + def remove_tag(self, tag_id): + with _db_cursor() as cur: + cur.execute("CALL tfm_remove_tag(%s, %s)", (self.sid, tag_id)) + + def remove_category(self, category_id): + with _db_cursor() as cur: + cur.execute("CALL tfm_remove_category(%s, %s)", (self.sid, category_id)) + + def remove_pool(self, pool_id): + with _db_cursor() as cur: + cur.execute("CALL tfm_remove_pool(%s, %s)", (self.sid, pool_id)) + + def remove_autotag(self, child_id, parent_id): + with _db_cursor() as cur: + cur.execute("CALL tfm_remove_autotag(%s, %s, %s)", (self.sid, child_id, parent_id)) + + def remove_file_to_tag(self, file_id, tag_id): + with _db_cursor() as cur: + cur.execute("CALL tfm_remove_file_to_tag(%s, %s, %s)", (self.sid, file_id, tag_id)) + + def remove_file_to_pool(self, file_id, pool_id): + with _db_cursor() as cur: + cur.execute("CALL tfm_remove_file_to_pool(%s, %s, %s)", (self.sid, file_id, pool_id)) diff --git a/docs/reference/backend/cmd/main.go b/docs/reference/backend/cmd/main.go new file mode 100644 index 0000000..92b9c84 --- /dev/null +++ b/docs/reference/backend/cmd/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "tanabata/internal/storage/postgres" +) + +func main() { + postgres.InitDB("postgres://hiko:taikibansei@192.168.0.25/Tanabata_new?application_name=Tanabata%20testing") + // test_json := json.RawMessage([]byte("{\"valery\": \"ponosoff\"}")) + // data, statusCode, err := db.FileGetSlice(1, "", "+2", -2, 0) + // data, statusCode, err := db.FileGet(1, "0197d056-cfb0-76b5-97e0-bd588826393c") + // data, statusCode, err := db.FileAdd(1, "ABOBA.png", "image/png", time.Now(), "slkdfjsldkflsdkfj;sldkf", test_json) + // statusCode, err := db.FileUpdate(2, "0197d159-bf3a-7617-a3a8-a4a9fc39eca6", map[string]interface{}{ + // "name": "ponos.png", + // }) + // statusCode, err := db.FileDelete(1, "0197d155-848f-7221-ba4a-4660f257c7d5") + // v, e, err := postgres.FileGetAccess(1, "0197d15a-57f9-712c-991e-c512290e774f") + // fmt.Printf("V: %s, E: %s\n", v, e) + // fmt.Printf("Status: %d\n", statusCode) + // fmt.Printf("Error: %s\n", err) + // fmt.Printf("%+v\n", data) +} diff --git a/docs/reference/backend/cmd/main.sync-conflict-20251008-195720-BYUI6ZC.go b/docs/reference/backend/cmd/main.sync-conflict-20251008-195720-BYUI6ZC.go new file mode 100644 index 0000000..0fd9c90 --- /dev/null +++ b/docs/reference/backend/cmd/main.sync-conflict-20251008-195720-BYUI6ZC.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + + "tanabata/db" +) + +func main() { + db.InitDB("postgres://hiko:taikibansei@192.168.0.25/Tanabata_new?application_name=Tanabata%20testing") + // test_json := json.RawMessage([]byte("{\"valery\": \"ponosoff\"}")) + // data, statusCode, err := db.FileGetSlice(2, "", "+2", -2, 0) + // data, statusCode, err := db.FileGet(1, "0197d056-cfb0-76b5-97e0-bd588826393c") + // data, statusCode, err := db.FileAdd(1, "ABOBA.png", "image/png", time.Now(), "slkdfjsldkflsdkfj;sldkf", test_json) + // statusCode, err := db.FileUpdate(2, "0197d159-bf3a-7617-a3a8-a4a9fc39eca6", map[string]interface{}{ + // "name": "ponos.png", + // }) + statusCode, err := db.FileDelete(1, "0197d155-848f-7221-ba4a-4660f257c7d5") + fmt.Printf("Status: %d\n", statusCode) + fmt.Printf("Error: %s\n", err) + // fmt.Printf("%+v\n", data) +} diff --git a/docs/reference/backend/db/db.go b/docs/reference/backend/db/db.go new file mode 100644 index 0000000..6f1c7b7 --- /dev/null +++ b/docs/reference/backend/db/db.go @@ -0,0 +1,79 @@ +package db + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +var connPool *pgxpool.Pool + +func InitDB(connString string) error { + poolConfig, err := pgxpool.ParseConfig(connString) + if err != nil { + return fmt.Errorf("error while parsing connection string: %w", err) + } + + poolConfig.MaxConns = 100 + poolConfig.MinConns = 0 + poolConfig.MaxConnLifetime = time.Hour + poolConfig.HealthCheckPeriod = 30 * time.Second + + connPool, err = pgxpool.NewWithConfig(context.Background(), poolConfig) + if err != nil { + return fmt.Errorf("error while initializing DB connections pool: %w", err) + } + return nil +} + +func transaction(handler func(context.Context, pgx.Tx) (statusCode int, err error)) (statusCode int, err error) { + ctx := context.Background() + tx, err := connPool.Begin(ctx) + if err != nil { + statusCode = http.StatusInternalServerError + return + } + statusCode, err = handler(ctx, tx) + if err != nil { + tx.Rollback(ctx) + return + } + err = tx.Commit(ctx) + if err != nil { + statusCode = http.StatusInternalServerError + } + return +} + +// Handle database error +func handleDBError(errIn error) (statusCode int, err error) { + if errIn == nil { + statusCode = http.StatusOK + return + } + if errors.Is(errIn, pgx.ErrNoRows) { + err = fmt.Errorf("not found") + statusCode = http.StatusNotFound + return + } + var pgErr *pgconn.PgError + if errors.As(errIn, &pgErr) { + switch pgErr.Code { + case "22P02", "22007": // Invalid data format + err = fmt.Errorf("%s", pgErr.Message) + statusCode = http.StatusBadRequest + return + case "23505": // Unique constraint violation + err = fmt.Errorf("already exists") + statusCode = http.StatusConflict + return + } + } + return http.StatusInternalServerError, errIn +} diff --git a/docs/reference/backend/db/utils.go b/docs/reference/backend/db/utils.go new file mode 100644 index 0000000..2274099 --- /dev/null +++ b/docs/reference/backend/db/utils.go @@ -0,0 +1,53 @@ +package db + +import ( + "fmt" + "net/http" + "strconv" + "strings" +) + +// Convert "filter" URL param to SQL "WHERE" condition +func filterToSQL(filter string) (sql string, statusCode int, err error) { + // filterTokens := strings.Split(string(filter), ";") + sql = "(true)" + return +} + +// Convert "sort" URL param to SQL "ORDER BY" +func sortToSQL(sort string) (sql string, statusCode int, err error) { + if sort == "" { + return + } + sortOptions := strings.Split(sort, ",") + sql = " ORDER BY " + for i, sortOption := range sortOptions { + sortOrder := sortOption[:1] + sortColumn := sortOption[1:] + // parse sorting order marker + switch sortOrder { + case "+": + sortOrder = "ASC" + case "-": + sortOrder = "DESC" + default: + err = fmt.Errorf("invalid sorting order mark: %q", sortOrder) + statusCode = http.StatusBadRequest + return + } + // validate sorting column + var n int + n, err = strconv.Atoi(sortColumn) + if err != nil || n < 0 { + err = fmt.Errorf("invalid sorting column: %q", sortColumn) + statusCode = http.StatusBadRequest + return + } + // add sorting option to query + if i > 0 { + sql += "," + } + sql += fmt.Sprintf("%s %s NULLS LAST", sortColumn, sortOrder) + } + return +} diff --git a/docs/reference/backend/go.mod b/docs/reference/backend/go.mod new file mode 100644 index 0000000..f4a63a1 --- /dev/null +++ b/docs/reference/backend/go.mod @@ -0,0 +1,19 @@ +module tanabata + +go 1.23.0 + +toolchain go1.23.10 + +require github.com/jackc/pgx/v5 v5.7.5 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx v3.6.2+incompatible // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/stretchr/testify v1.9.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/docs/reference/backend/go.sum b/docs/reference/backend/go.sum new file mode 100644 index 0000000..744b3fd --- /dev/null +++ b/docs/reference/backend/go.sum @@ -0,0 +1,32 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o= +github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/docs/reference/backend/internal/domain/domain.go b/docs/reference/backend/internal/domain/domain.go new file mode 100644 index 0000000..9b661eb --- /dev/null +++ b/docs/reference/backend/internal/domain/domain.go @@ -0,0 +1,122 @@ +package domain + +import ( + "encoding/json" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +type User struct { + Name string `json:"name"` + IsAdmin bool `json:"isAdmin"` + CanCreate bool `json:"canCreate"` +} + +type MIME struct { + Name string `json:"name"` + Extension string `json:"extension"` +} + +type ( + CategoryCore struct { + ID string `json:"id"` + Name string `json:"name"` + Color pgtype.Text `json:"color"` + } + CategoryItem struct { + CategoryCore + } + CategoryFull struct { + CategoryCore + CreatedAt time.Time `json:"createdAt"` + Creator User `json:"creator"` + Notes pgtype.Text `json:"notes"` + } +) + +type ( + FileCore struct { + ID string `json:"id"` + Name pgtype.Text `json:"name"` + MIME MIME `json:"mime"` + } + FileItem struct { + FileCore + CreatedAt time.Time `json:"createdAt"` + Creator User `json:"creator"` + } + FileFull struct { + FileCore + CreatedAt time.Time `json:"createdAt"` + Creator User `json:"creator"` + Notes pgtype.Text `json:"notes"` + Metadata json.RawMessage `json:"metadata"` + Tags []TagCore `json:"tags"` + Viewed int `json:"viewed"` + } +) + +type ( + TagCore struct { + ID string `json:"id"` + Name string `json:"name"` + Color pgtype.Text `json:"color"` + } + TagItem struct { + TagCore + Category CategoryCore `json:"category"` + } + TagFull struct { + TagCore + Category CategoryCore `json:"category"` + CreatedAt time.Time `json:"createdAt"` + Creator User `json:"creator"` + Notes pgtype.Text `json:"notes"` + UsedIncl int `json:"usedIncl"` + UsedExcl int `json:"usedExcl"` + } +) + +type Autotag struct { + TriggerTag TagCore `json:"triggerTag"` + AddTag TagCore `json:"addTag"` + IsActive bool `json:"isActive"` +} + +type ( + PoolCore struct { + ID string `json:"id"` + Name string `json:"name"` + } + PoolItem struct { + PoolCore + } + PoolFull struct { + PoolCore + CreatedAt time.Time `json:"createdAt"` + Creator User `json:"creator"` + Notes pgtype.Text `json:"notes"` + Viewed int `json:"viewed"` + } +) + +type Session struct { + ID int `json:"id"` + UserAgent string `json:"userAgent"` + StartedAt time.Time `json:"startedAt"` + ExpiresAt time.Time `json:"expiresAt"` + LastActivity time.Time `json:"lastActivity"` +} + +type Pagination struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Count int `json:"count"` +} + +type Slice[T any] struct { + Pagination Pagination `json:"pagination"` + Data []T `json:"data"` +} diff --git a/docs/reference/backend/internal/storage/postgres/auth.go b/docs/reference/backend/internal/storage/postgres/auth.go new file mode 100644 index 0000000..3aaf07b --- /dev/null +++ b/docs/reference/backend/internal/storage/postgres/auth.go @@ -0,0 +1,16 @@ +package postgres + +import "context" + +func UserLogin(ctx context.Context, name, password string) (user_id int, err error) { + row := connPool.QueryRow(ctx, "SELECT id FROM users WHERE name=$1 AND password=crypt($2, password)", name, password) + err = row.Scan(&user_id) + return +} + +func UserAuth(ctx context.Context, user_id int) (ok, isAdmin bool) { + row := connPool.QueryRow(ctx, "SELECT is_admin FROM users WHERE id=$1", user_id) + err := row.Scan(&isAdmin) + ok = (err == nil) + return +} diff --git a/docs/reference/backend/internal/storage/postgres/file.go b/docs/reference/backend/internal/storage/postgres/file.go new file mode 100644 index 0000000..91f968d --- /dev/null +++ b/docs/reference/backend/internal/storage/postgres/file.go @@ -0,0 +1,268 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "tanabata/internal/domain" +) + +type FileStore struct { + db *pgxpool.Pool +} + +func NewFileStore(db *pgxpool.Pool) *FileStore { + return &FileStore{db: db} +} + +// Get user's access rights to file +func (s *FileStore) getAccess(user_id int, file_id string) (canView, canEdit bool, err error) { + ctx := context.Background() + row := connPool.QueryRow(ctx, ` + SELECT + COALESCE(a.view, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE), + COALESCE(a.edit, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE) + FROM data.files f + LEFT JOIN acl.files a ON a.file_id=f.id AND a.user_id=$1 + LEFT JOIN system.users u ON u.id=$1 + WHERE f.id=$2 + `, user_id, file_id) + err = row.Scan(&canView, &canEdit) + return +} + +// Get a set of files +func (s *FileStore) GetSlice(user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], statusCode int, err error) { + filterCond, statusCode, err := filterToSQL(filter) + if err != nil { + return + } + sortExpr, statusCode, err := sortToSQL(sort) + if err != nil { + return + } + // prepare query + query := ` + SELECT + f.id, + f.name, + m.name, + m.extension, + uuid_extract_timestamp(f.id), + u.name, + u.is_admin + FROM data.files f + JOIN system.mime m ON m.id=f.mime_id + JOIN system.users u ON u.id=f.creator_id + WHERE NOT f.is_deleted AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=f.id AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1)) AND + ` + query += filterCond + queryCount := query + query += sortExpr + if limit >= 0 { + query += fmt.Sprintf(" LIMIT %d", limit) + } + if offset > 0 { + query += fmt.Sprintf(" OFFSET %d", offset) + } + // execute query + statusCode, err = transaction(func(ctx context.Context, tx pgx.Tx) (statusCode int, err error) { + rows, err := tx.Query(ctx, query, user_id) + if err != nil { + statusCode, err = handleDBError(err) + return + } + defer rows.Close() + count := 0 + for rows.Next() { + var file domain.FileItem + err = rows.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin) + if err != nil { + statusCode = http.StatusInternalServerError + return + } + files.Data = append(files.Data, file) + count++ + } + err = rows.Err() + if err != nil { + statusCode = http.StatusInternalServerError + return + } + files.Pagination.Limit = limit + files.Pagination.Offset = offset + files.Pagination.Count = count + row := tx.QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*) FROM (%s) tmp", queryCount), user_id) + err = row.Scan(&files.Pagination.Total) + if err != nil { + statusCode = http.StatusInternalServerError + } + return + }) + if err == nil { + statusCode = http.StatusOK + } + return +} + +// Get file +func (s *FileStore) Get(user_id int, file_id string) (file domain.FileFull, statusCode int, err error) { + ctx := context.Background() + row := connPool.QueryRow(ctx, ` + SELECT + f.id, + f.name, + m.name, + m.extension, + uuid_extract_timestamp(f.id), + u.name, + u.is_admin, + f.notes, + f.metadata, + (SELECT COUNT(*) FROM activity.file_views fv WHERE fv.file_id=$2 AND fv.user_id=$1) + FROM data.files f + JOIN system.mime m ON m.id=f.mime_id + JOIN system.users u ON u.id=f.creator_id + WHERE NOT f.is_deleted AND f.id=$2 AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1)) + `, user_id, file_id) + err = row.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin, &file.Notes, &file.Metadata, &file.Viewed) + if err != nil { + statusCode, err = handleDBError(err) + return + } + rows, err := connPool.Query(ctx, ` + SELECT + t.id, + t.name, + COALESCE(t.color, c.color) + FROM data.tags t + LEFT JOIN data.categories c ON c.id=t.category_id + JOIN data.file_tag ft ON ft.tag_id=t.id + WHERE ft.file_id=$1 + `, file_id) + if err != nil { + statusCode, err = handleDBError(err) + return + } + defer rows.Close() + for rows.Next() { + var tag domain.TagCore + err = rows.Scan(&tag.ID, &tag.Name, &tag.Color) + if err != nil { + statusCode = http.StatusInternalServerError + return + } + file.Tags = append(file.Tags, tag) + } + err = rows.Err() + if err != nil { + statusCode = http.StatusInternalServerError + return + } + statusCode = http.StatusOK + return +} + +// Add file +func (s *FileStore) Add(user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, statusCode int, err error) { + ctx := context.Background() + var mime_id int + var extension string + row := connPool.QueryRow(ctx, "SELECT id, extension FROM system.mime WHERE name=$1", mime) + err = row.Scan(&mime_id, &extension) + if err != nil { + if err == pgx.ErrNoRows { + err = fmt.Errorf("unsupported file type: %q", mime) + statusCode = http.StatusBadRequest + } else { + statusCode, err = handleDBError(err) + } + return + } + row = connPool.QueryRow(ctx, ` + INSERT INTO data.files (name, mime_id, datetime, creator_id, notes, metadata) + VALUES (NULLIF($1, ''), $2, $3, $4, NULLIF($5 ,''), $6) + RETURNING id + `, name, mime_id, datetime, user_id, notes, metadata) + err = row.Scan(&file.ID) + if err != nil { + statusCode, err = handleDBError(err) + return + } + file.Name.String = name + file.Name.Valid = (name != "") + file.MIME.Name = mime + file.MIME.Extension = extension + statusCode = http.StatusOK + return +} + +// Update file +func (s *FileStore) Update(user_id int, file_id string, updates map[string]interface{}) (statusCode int, err error) { + if len(updates) == 0 { + err = fmt.Errorf("no fields provided for update") + statusCode = http.StatusBadRequest + return + } + writableFields := map[string]bool{ + "name": true, + "datetime": true, + "notes": true, + "metadata": true, + } + query := "UPDATE data.files SET" + newValues := []interface{}{user_id} + count := 2 + for field, value := range updates { + if !writableFields[field] { + err = fmt.Errorf("invalid field: %q", field) + statusCode = http.StatusBadRequest + return + } + query += fmt.Sprintf(" %s=NULLIF($%d, '')", field, count) + newValues = append(newValues, value) + count++ + } + query += fmt.Sprintf( + " WHERE id=$%d AND (creator_id=$1 OR (SELECT edit FROM acl.files WHERE file_id=$%d AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))", + count, count) + newValues = append(newValues, file_id) + ctx := context.Background() + commandTag, err := connPool.Exec(ctx, query, newValues...) + if err != nil { + statusCode, err = handleDBError(err) + return + } + if commandTag.RowsAffected() == 0 { + err = fmt.Errorf("not found") + statusCode = http.StatusNotFound + return + } + statusCode = http.StatusNoContent + return +} + +// Delete file +func (s *FileStore) Delete(user_id int, file_id string) (statusCode int, err error) { + ctx := context.Background() + commandTag, err := connPool.Exec(ctx, + "DELETE FROM data.files WHERE id=$2 AND (creator_id=$1 OR (SELECT edit FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))", + user_id, file_id) + if err != nil { + statusCode, err = handleDBError(err) + return + } + if commandTag.RowsAffected() == 0 { + err = fmt.Errorf("not found") + statusCode = http.StatusNotFound + return + } + statusCode = http.StatusNoContent + return +} diff --git a/docs/reference/backend/internal/storage/postgres/store.go b/docs/reference/backend/internal/storage/postgres/store.go new file mode 100644 index 0000000..e255602 --- /dev/null +++ b/docs/reference/backend/internal/storage/postgres/store.go @@ -0,0 +1,92 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Storage struct { + db *pgxpool.Pool +} + +var connPool *pgxpool.Pool + +// Initialize new database storage +func New(dbURL string) (*Storage, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + config, err := pgxpool.ParseConfig(dbURL) + if err != nil { + return nil, fmt.Errorf("failed to parse DB URL: %w", err) + } + config.MaxConns = 10 + config.MinConns = 2 + config.HealthCheckPeriod = time.Minute + db, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + err = db.Ping(ctx) + if err != nil { + return nil, fmt.Errorf("database ping failed: %w", err) + } + return &Storage{db: db}, nil +} + +// Close database storage +func (s *Storage) Close() { + s.db.Close() +} + +// Run handler inside transaction +func (s *Storage) transaction(ctx context.Context, handler func(context.Context, pgx.Tx) (statusCode int, err error)) (statusCode int, err error) { + tx, err := connPool.Begin(ctx) + if err != nil { + statusCode = http.StatusInternalServerError + return + } + statusCode, err = handler(ctx, tx) + if err != nil { + tx.Rollback(ctx) + return + } + err = tx.Commit(ctx) + if err != nil { + statusCode = http.StatusInternalServerError + } + return +} + +// Handle database error +func (s *Storage) handleDBError(errIn error) (statusCode int, err error) { + if errIn == nil { + statusCode = http.StatusOK + return + } + if errors.Is(errIn, pgx.ErrNoRows) { + err = fmt.Errorf("not found") + statusCode = http.StatusNotFound + return + } + var pgErr *pgconn.PgError + if errors.As(errIn, &pgErr) { + switch pgErr.Code { + case "22P02", "22007": // Invalid data format + err = fmt.Errorf("%s", pgErr.Message) + statusCode = http.StatusBadRequest + return + case "23505": // Unique constraint violation + err = fmt.Errorf("already exists") + statusCode = http.StatusConflict + return + } + } + return http.StatusInternalServerError, errIn +} diff --git a/docs/reference/backend/internal/storage/postgres/utils.go b/docs/reference/backend/internal/storage/postgres/utils.go new file mode 100644 index 0000000..f190f5a --- /dev/null +++ b/docs/reference/backend/internal/storage/postgres/utils.go @@ -0,0 +1,53 @@ +package postgres + +import ( + "fmt" + "net/http" + "strconv" + "strings" +) + +// Convert "filter" URL param to SQL "WHERE" condition +func filterToSQL(filter string) (sql string, statusCode int, err error) { + // filterTokens := strings.Split(string(filter), ";") + sql = "(true)" + return +} + +// Convert "sort" URL param to SQL "ORDER BY" +func sortToSQL(sort string) (sql string, statusCode int, err error) { + if sort == "" { + return + } + sortOptions := strings.Split(sort, ",") + sql = " ORDER BY " + for i, sortOption := range sortOptions { + sortOrder := sortOption[:1] + sortColumn := sortOption[1:] + // parse sorting order marker + switch sortOrder { + case "+": + sortOrder = "ASC" + case "-": + sortOrder = "DESC" + default: + err = fmt.Errorf("invalid sorting order mark: %q", sortOrder) + statusCode = http.StatusBadRequest + return + } + // validate sorting column + var n int + n, err = strconv.Atoi(sortColumn) + if err != nil || n < 0 { + err = fmt.Errorf("invalid sorting column: %q", sortColumn) + statusCode = http.StatusBadRequest + return + } + // add sorting option to query + if i > 0 { + sql += "," + } + sql += fmt.Sprintf("%s %s NULLS LAST", sortColumn, sortOrder) + } + return +} diff --git a/docs/reference/backend/internal/storage/storage.go b/docs/reference/backend/internal/storage/storage.go new file mode 100644 index 0000000..369879d --- /dev/null +++ b/docs/reference/backend/internal/storage/storage.go @@ -0,0 +1,21 @@ +package storage + +import ( + "encoding/json" + "time" + + "tanabata/internal/domain" +) + +type Storage interface { + FileRepository + Close() +} + +type FileRepository interface { + GetSlice(user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], statusCode int, err error) + Get(user_id int, file_id string) (file domain.FileFull, statusCode int, err error) + Add(user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, statusCode int, err error) + Update(user_id int, file_id string, updates map[string]interface{}) (statusCode int, err error) + Delete(user_id int, file_id string) (statusCode int, err error) +} diff --git a/docs/reference/backend/models/models.go b/docs/reference/backend/models/models.go new file mode 100644 index 0000000..7a66022 --- /dev/null +++ b/docs/reference/backend/models/models.go @@ -0,0 +1,120 @@ +package models + +import ( + "encoding/json" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +type User struct { + Name string `json:"name"` + IsAdmin bool `json:"isAdmin"` + CanCreate bool `json:"canCreate"` +} + +type MIME struct { + Name string `json:"name"` + Extension string `json:"extension"` +} + +type ( + CategoryCore struct { + ID string `json:"id"` + Name string `json:"name"` + Color pgtype.Text `json:"color"` + } + CategoryItem struct { + CategoryCore + } + CategoryFull struct { + CategoryCore + CreatedAt time.Time `json:"createdAt"` + Creator User `json:"creator"` + Notes pgtype.Text `json:"notes"` + } +) + +type ( + FileCore struct { + ID string `json:"id"` + Name pgtype.Text `json:"name"` + MIME MIME `json:"mime"` + } + FileItem struct { + FileCore + CreatedAt time.Time `json:"createdAt"` + Creator User `json:"creator"` + } + FileFull struct { + FileCore + CreatedAt time.Time `json:"createdAt"` + Creator User `json:"creator"` + Notes pgtype.Text `json:"notes"` + Metadata json.RawMessage `json:"metadata"` + Tags []TagCore `json:"tags"` + Viewed int `json:"viewed"` + } +) + +type ( + TagCore struct { + ID string `json:"id"` + Name string `json:"name"` + Color pgtype.Text `json:"color"` + } + TagItem struct { + TagCore + Category CategoryCore `json:"category"` + } + TagFull struct { + TagCore + Category CategoryCore `json:"category"` + CreatedAt time.Time `json:"createdAt"` + Creator User `json:"creator"` + Notes pgtype.Text `json:"notes"` + } +) + +type Autotag struct { + TriggerTag TagCore `json:"triggerTag"` + AddTag TagCore `json:"addTag"` + IsActive bool `json:"isActive"` +} + +type ( + PoolCore struct { + ID string `json:"id"` + Name string `json:"name"` + } + PoolItem struct { + PoolCore + } + PoolFull struct { + PoolCore + CreatedAt time.Time `json:"createdAt"` + Creator User `json:"creator"` + Notes pgtype.Text `json:"notes"` + Viewed int `json:"viewed"` + } +) + +type Session struct { + ID int `json:"id"` + UserAgent string `json:"userAgent"` + StartedAt time.Time `json:"startedAt"` + ExpiresAt time.Time `json:"expiresAt"` + LastActivity time.Time `json:"lastActivity"` +} + +type Pagination struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Count int `json:"count"` +} + +type Slice[T any] struct { + Pagination Pagination `json:"pagination"` + Data []T `json:"data"` +} diff --git a/docs/reference/web/static/css/auth.css b/docs/reference/web/static/css/auth.css new file mode 100644 index 0000000..2dfcdfc --- /dev/null +++ b/docs/reference/web/static/css/auth.css @@ -0,0 +1,36 @@ +body { + justify-content: center; + align-items: center; +} + +.decoration { + position: absolute; + top: 0; +} + +.decoration.left { + left: 0; + width: 20vw; +} + +.decoration.right { + right: 0; + width: 20vw; +} + +#auth { + max-width: 100%; +} + +#auth h1 { + margin-bottom: 28px; +} + +#auth .form-control { + margin: 14px 0; + border-radius: 14px; +} + +#login { + margin-top: 20px; +} diff --git a/docs/reference/web/static/css/general.css b/docs/reference/web/static/css/general.css new file mode 100644 index 0000000..f4d3bf5 --- /dev/null +++ b/docs/reference/web/static/css/general.css @@ -0,0 +1,54 @@ +html, body { + margin: 0; + padding: 0; +} + +body { + background-color: #312F45; + color: #f0f0f0; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + font-family: Epilogue; + overflow-x: hidden; +} + +.btn { + height: 50px; + width: 100%; + border-radius: 14px; + font-size: 1.5rem; + font-weight: 500; +} + +.btn-primary { + background-color: #9592B5; + border-color: #454261; +} + +.btn-primary:hover { + background-color: #7D7AA4; + border-color: #454261; +} + +.btn-danger { + background-color: #DB6060; + border-color: #851E1E; +} + +.btn-danger:hover { + background-color: #D64848; + border-color: #851E1E; +} + +.btn-row { + display: flex; + justify-content: space-between; +} diff --git a/docs/reference/web/static/css/interface.css b/docs/reference/web/static/css/interface.css new file mode 100644 index 0000000..1b2d3b1 --- /dev/null +++ b/docs/reference/web/static/css/interface.css @@ -0,0 +1,388 @@ +header, footer { + margin: 0; + padding: 10px; + box-sizing: border-box; +} + +header { + padding: 20px; + box-shadow: 0 5px 5px #0004; +} + +.icon-header { + height: .8em; +} + +#select { + cursor: pointer; +} + +.sorting { + position: relative; + display: flex; + justify-content: space-between; +} + +#sorting { + cursor: pointer; +} + +.highlighted { + color: #9999AD; +} + +#icon-expand { + height: 10px; +} + +#sorting-options { + position: absolute; + right: 0; + top: 114%; + padding: 4px 10px; + box-sizing: border-box; + background-color: #111118; + border-radius: 10px; + text-align: left; + box-shadow: 0 0 10px black; + z-index: 9999; +} + +.sorting-option { + padding: 4px 0; + display: flex; + justify-content: space-between; +} + +.sorting-option input[type="radio"] { + float: unset; + margin-left: 1.8em; +} + +.filtering-wrapper { + margin-top: 10px; +} + +.filtering-block { + position: absolute; + top: 128px; + left: 14px; + right: 14px; + padding: 14px; + background-color: #111118; + border-radius: 10px; + box-shadow: 0 0 10px 4px #0004; + z-index: 9998; +} + +main { + margin: 0; + padding: 10px; + height: 100%; + display: flex; + justify-content: space-between; + align-content: flex-start; + align-items: flex-start; + flex-wrap: wrap; + box-sizing: border-box; + overflow-x: hidden; + overflow-y: scroll; +} + +main:after { + content: ""; + flex: auto; +} + +.item-preview { + position: relative; +} + +.item-selected:after { + content: ""; + display: block; + position: absolute; + top: 10px; + right: 10px; + width: 100%; + height: 50%; + background-image: url("/static/images/icon-select.svg"); + background-size: contain; + background-position: right; + background-repeat: no-repeat; +} + +.file-preview { + margin: 1px 0; + padding: 0; + width: 160px; + height: 160px; + max-width: calc(33vw - 7px); + max-height: calc(33vw - 7px); + overflow: hidden; + cursor: pointer; +} + +.file-thumb { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; +} + +.file-preview .overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: #0002; +} + +.file-preview:hover .overlay { + background-color: #0004; +} + +.tag-preview, .filtering-token { + margin: 5px 5px; + padding: 5px 10px; + border-radius: 5px; + background-color: #444455; + cursor: pointer; +} + +.category-preview { + margin: 5px 5px; + padding: 5px 10px; + border-radius: 5px; + background-color: #444455; + cursor: pointer; +} + +.file { + margin: 0; + padding: 0; + min-width: 100vw; + min-height: 100vh; + max-width: 100vw; + max-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background-color: black; +} + +.file .preview-img { + max-width: 100vw; + max-height: 100vh; +} + +.selection-manager { + position: fixed; + left: 10px; + right: 10px; + bottom: 65px; + box-sizing: border-box; + max-height: 40vh; + display: flex; + flex-direction: column; + padding: 15px 10px; + background-color: #181721; + border-radius: 10px; + box-shadow: 0 0 5px #0008; +} + +.selection-manager hr { + margin: 5px 0; +} + +.selection-header { + display: flex; + justify-content: space-between; +} + +.selection-header > * { + cursor: pointer; +} + +#selection-edit-tags { + color: #4DC7ED; +} + +#selection-add-to-pool { + color: #F5E872; +} + +#selection-delete { + color: #DB6060; +} + +.selection-tags { + max-height: 100%; + overflow-x: hidden; + overflow-y: scroll; +} + +input[type="color"] { + width: 100%; +} + +.tags-container, .filtering-operators, .filtering-tokens { + padding: 5px; + background-color: #212529; + border: 1px solid #495057; + border-radius: .375rem; + display: flex; + justify-content: space-between; + align-content: flex-start; + align-items: flex-start; + flex-wrap: wrap; + box-sizing: border-box; +} + +.filtering-operators { + margin-bottom: 15px; +} + +.tags-container, .filtering-tokens { + margin: 15px 0; + height: 200px; + overflow-x: hidden; + overflow-y: scroll; +} + +.tags-container:after, .filtering-tokens:after { + content: ""; + flex: auto; +} + +.tags-container-selected { + height: 100px; +} + +#files-filter { + margin-bottom: 0; + height: 56px; +} + +.viewer-wrapper { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + background-color: #000a; + display: flex; + justify-content: center; +/* overflow-y: scroll;*/ +} + +.viewer-nav { + position: absolute; + top: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +} + +.viewer-nav:hover { + background-color: #b4adff40; +} + +.viewer-nav-prev { + left: 0; + right: 80vw; +} + +.viewer-nav-next { + left: 80vw; + right: 0; +} + +.viewer-nav-close { + left: 0; + right: 0; + bottom: unset; + height: 15vh; +} + +.viewer-nav-icon { + width: 20px; + height: 32px; +} + +.viewer-nav-close > .viewer-nav-icon { + width: 16px; + height: 16px; +} + +#viewer { + width: 100%; + height: 100%; + max-width: 100%; +} + +.sessions-wrapper { + padding: 14px; + background-color: #111118; + border-radius: 10px; +} + +.btn-terminate { + height: 20px; + cursor: pointer; +} + +footer { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #0007; +} + +.nav { + display: flex; + justify-content: center; + align-items: center; + height: 40px; + width: 18vw; + background: transparent; + border: 0; + border-radius: 10px; + outline: 0; +} + +.nav.curr, .nav:hover { + background-color: #343249; +} + +.navicon { + display: block; + height: 30px; +} + +#loader { + position: fixed; + left: 0; + top: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + background-color: #000a; + z-index: 9999; +} + +.loader-wrapper { + padding: 15px; + border-radius: 12px; + background-color: white; +} + +.loader-img { + max-width: 20vw; + max-height: 20vh; +} diff --git a/docs/reference/web/static/fonts/Epilogue-VariableFont_wght.ttf b/docs/reference/web/static/fonts/Epilogue-VariableFont_wght.ttf new file mode 100644 index 0000000..7c3d128 Binary files /dev/null and b/docs/reference/web/static/fonts/Epilogue-VariableFont_wght.ttf differ diff --git a/docs/reference/web/static/images/android-icon-144x144.png b/docs/reference/web/static/images/android-icon-144x144.png new file mode 100644 index 0000000..34eb091 Binary files /dev/null and b/docs/reference/web/static/images/android-icon-144x144.png differ diff --git a/docs/reference/web/static/images/android-icon-192x192.png b/docs/reference/web/static/images/android-icon-192x192.png new file mode 100644 index 0000000..e3444e9 Binary files /dev/null and b/docs/reference/web/static/images/android-icon-192x192.png differ diff --git a/docs/reference/web/static/images/android-icon-36x36.png b/docs/reference/web/static/images/android-icon-36x36.png new file mode 100644 index 0000000..5788f0f Binary files /dev/null and b/docs/reference/web/static/images/android-icon-36x36.png differ diff --git a/docs/reference/web/static/images/android-icon-48x48.png b/docs/reference/web/static/images/android-icon-48x48.png new file mode 100644 index 0000000..ed5aef8 Binary files /dev/null and b/docs/reference/web/static/images/android-icon-48x48.png differ diff --git a/docs/reference/web/static/images/android-icon-72x72.png b/docs/reference/web/static/images/android-icon-72x72.png new file mode 100644 index 0000000..a57649b Binary files /dev/null and b/docs/reference/web/static/images/android-icon-72x72.png differ diff --git a/docs/reference/web/static/images/android-icon-96x96.png b/docs/reference/web/static/images/android-icon-96x96.png new file mode 100644 index 0000000..71c2493 Binary files /dev/null and b/docs/reference/web/static/images/android-icon-96x96.png differ diff --git a/docs/reference/web/static/images/apple-icon-114x114.png b/docs/reference/web/static/images/apple-icon-114x114.png new file mode 100644 index 0000000..7c706ab Binary files /dev/null and b/docs/reference/web/static/images/apple-icon-114x114.png differ diff --git a/docs/reference/web/static/images/apple-icon-120x120.png b/docs/reference/web/static/images/apple-icon-120x120.png new file mode 100644 index 0000000..5c65977 Binary files /dev/null and b/docs/reference/web/static/images/apple-icon-120x120.png differ diff --git a/docs/reference/web/static/images/apple-icon-144x144.png b/docs/reference/web/static/images/apple-icon-144x144.png new file mode 100644 index 0000000..34eb091 Binary files /dev/null and b/docs/reference/web/static/images/apple-icon-144x144.png differ diff --git a/docs/reference/web/static/images/apple-icon-152x152.png b/docs/reference/web/static/images/apple-icon-152x152.png new file mode 100644 index 0000000..0096fc7 Binary files /dev/null and b/docs/reference/web/static/images/apple-icon-152x152.png differ diff --git a/docs/reference/web/static/images/apple-icon-180x180.png b/docs/reference/web/static/images/apple-icon-180x180.png new file mode 100644 index 0000000..72852d7 Binary files /dev/null and b/docs/reference/web/static/images/apple-icon-180x180.png differ diff --git a/docs/reference/web/static/images/apple-icon-57x57.png b/docs/reference/web/static/images/apple-icon-57x57.png new file mode 100644 index 0000000..f69ff76 Binary files /dev/null and b/docs/reference/web/static/images/apple-icon-57x57.png differ diff --git a/docs/reference/web/static/images/apple-icon-60x60.png b/docs/reference/web/static/images/apple-icon-60x60.png new file mode 100644 index 0000000..6a584fd Binary files /dev/null and b/docs/reference/web/static/images/apple-icon-60x60.png differ diff --git a/docs/reference/web/static/images/apple-icon-72x72.png b/docs/reference/web/static/images/apple-icon-72x72.png new file mode 100644 index 0000000..a57649b Binary files /dev/null and b/docs/reference/web/static/images/apple-icon-72x72.png differ diff --git a/docs/reference/web/static/images/apple-icon-76x76.png b/docs/reference/web/static/images/apple-icon-76x76.png new file mode 100644 index 0000000..aa260fa Binary files /dev/null and b/docs/reference/web/static/images/apple-icon-76x76.png differ diff --git a/docs/reference/web/static/images/apple-icon-precomposed.png b/docs/reference/web/static/images/apple-icon-precomposed.png new file mode 100644 index 0000000..a15a379 Binary files /dev/null and b/docs/reference/web/static/images/apple-icon-precomposed.png differ diff --git a/docs/reference/web/static/images/apple-icon.png b/docs/reference/web/static/images/apple-icon.png new file mode 100644 index 0000000..a15a379 Binary files /dev/null and b/docs/reference/web/static/images/apple-icon.png differ diff --git a/docs/reference/web/static/images/favicon-16x16.png b/docs/reference/web/static/images/favicon-16x16.png new file mode 100644 index 0000000..4860d9f Binary files /dev/null and b/docs/reference/web/static/images/favicon-16x16.png differ diff --git a/docs/reference/web/static/images/favicon-32x32.png b/docs/reference/web/static/images/favicon-32x32.png new file mode 100644 index 0000000..d51d3d3 Binary files /dev/null and b/docs/reference/web/static/images/favicon-32x32.png differ diff --git a/docs/reference/web/static/images/favicon-96x96.png b/docs/reference/web/static/images/favicon-96x96.png new file mode 100644 index 0000000..8a67b3d Binary files /dev/null and b/docs/reference/web/static/images/favicon-96x96.png differ diff --git a/docs/reference/web/static/images/favicon-bg.png b/docs/reference/web/static/images/favicon-bg.png new file mode 100644 index 0000000..4c52e1a Binary files /dev/null and b/docs/reference/web/static/images/favicon-bg.png differ diff --git a/docs/reference/web/static/images/favicon.png b/docs/reference/web/static/images/favicon.png new file mode 100644 index 0000000..2ffd366 Binary files /dev/null and b/docs/reference/web/static/images/favicon.png differ diff --git a/docs/reference/web/static/images/icon-add.svg b/docs/reference/web/static/images/icon-add.svg new file mode 100644 index 0000000..2d66071 --- /dev/null +++ b/docs/reference/web/static/images/icon-add.svg @@ -0,0 +1,4 @@ + diff --git a/docs/reference/web/static/images/icon-category.svg b/docs/reference/web/static/images/icon-category.svg new file mode 100644 index 0000000..c43ee57 --- /dev/null +++ b/docs/reference/web/static/images/icon-category.svg @@ -0,0 +1,5 @@ + diff --git a/docs/reference/web/static/images/icon-expand.svg b/docs/reference/web/static/images/icon-expand.svg new file mode 100644 index 0000000..1606ddb --- /dev/null +++ b/docs/reference/web/static/images/icon-expand.svg @@ -0,0 +1,3 @@ + diff --git a/docs/reference/web/static/images/icon-file.svg b/docs/reference/web/static/images/icon-file.svg new file mode 100644 index 0000000..07848b1 --- /dev/null +++ b/docs/reference/web/static/images/icon-file.svg @@ -0,0 +1,4 @@ + diff --git a/docs/reference/web/static/images/icon-pool.svg b/docs/reference/web/static/images/icon-pool.svg new file mode 100644 index 0000000..035e4ce --- /dev/null +++ b/docs/reference/web/static/images/icon-pool.svg @@ -0,0 +1,3 @@ + diff --git a/docs/reference/web/static/images/icon-select.svg b/docs/reference/web/static/images/icon-select.svg new file mode 100644 index 0000000..6505e0a --- /dev/null +++ b/docs/reference/web/static/images/icon-select.svg @@ -0,0 +1,3 @@ + diff --git a/docs/reference/web/static/images/icon-settings.svg b/docs/reference/web/static/images/icon-settings.svg new file mode 100644 index 0000000..25aebd2 --- /dev/null +++ b/docs/reference/web/static/images/icon-settings.svg @@ -0,0 +1,4 @@ + diff --git a/docs/reference/web/static/images/icon-tag.svg b/docs/reference/web/static/images/icon-tag.svg new file mode 100644 index 0000000..89b1e4b --- /dev/null +++ b/docs/reference/web/static/images/icon-tag.svg @@ -0,0 +1,5 @@ + diff --git a/docs/reference/web/static/images/icon-terminate.svg b/docs/reference/web/static/images/icon-terminate.svg new file mode 100644 index 0000000..0e0b527 --- /dev/null +++ b/docs/reference/web/static/images/icon-terminate.svg @@ -0,0 +1,3 @@ + diff --git a/docs/reference/web/static/images/layer-controls.png b/docs/reference/web/static/images/layer-controls.png new file mode 100644 index 0000000..4e97926 Binary files /dev/null and b/docs/reference/web/static/images/layer-controls.png differ diff --git a/docs/reference/web/static/images/loader.gif b/docs/reference/web/static/images/loader.gif new file mode 100644 index 0000000..d15dcdb Binary files /dev/null and b/docs/reference/web/static/images/loader.gif differ diff --git a/docs/reference/web/static/images/ms-icon-144x144.png b/docs/reference/web/static/images/ms-icon-144x144.png new file mode 100644 index 0000000..103a7f1 Binary files /dev/null and b/docs/reference/web/static/images/ms-icon-144x144.png differ diff --git a/docs/reference/web/static/images/ms-icon-150x150.png b/docs/reference/web/static/images/ms-icon-150x150.png new file mode 100644 index 0000000..7d71939 Binary files /dev/null and b/docs/reference/web/static/images/ms-icon-150x150.png differ diff --git a/docs/reference/web/static/images/ms-icon-310x310.png b/docs/reference/web/static/images/ms-icon-310x310.png new file mode 100644 index 0000000..1a8c389 Binary files /dev/null and b/docs/reference/web/static/images/ms-icon-310x310.png differ diff --git a/docs/reference/web/static/images/ms-icon-70x70.png b/docs/reference/web/static/images/ms-icon-70x70.png new file mode 100644 index 0000000..c3e84df Binary files /dev/null and b/docs/reference/web/static/images/ms-icon-70x70.png differ diff --git a/docs/reference/web/static/images/tanabata-left.png b/docs/reference/web/static/images/tanabata-left.png new file mode 100644 index 0000000..dd15de2 Binary files /dev/null and b/docs/reference/web/static/images/tanabata-left.png differ diff --git a/docs/reference/web/static/images/tanabata-right.png b/docs/reference/web/static/images/tanabata-right.png new file mode 100644 index 0000000..1a6d484 Binary files /dev/null and b/docs/reference/web/static/images/tanabata-right.png differ diff --git a/docs/reference/web/static/js/add-category.js b/docs/reference/web/static/js/add-category.js new file mode 100644 index 0000000..90ea61a --- /dev/null +++ b/docs/reference/web/static/js/add-category.js @@ -0,0 +1,22 @@ +$(document).on("submit", "#object-add", function (e) { + e.preventDefault(); + $("#loader").css("display", ""); + $.ajax({ + url: location.pathname, + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (resp) { + $("#loader").css("display", "none"); + if (resp.status) { + location.href = location.pathname.substring(0, location.pathname.lastIndexOf("/")); + } else { + alert(resp.error); + } + }, + failure: function (err) { + $("#loader").css("display", "none"); + alert(err); + } + }); +}); diff --git a/docs/reference/web/static/js/add-tag.js b/docs/reference/web/static/js/add-tag.js new file mode 100644 index 0000000..90ea61a --- /dev/null +++ b/docs/reference/web/static/js/add-tag.js @@ -0,0 +1,22 @@ +$(document).on("submit", "#object-add", function (e) { + e.preventDefault(); + $("#loader").css("display", ""); + $.ajax({ + url: location.pathname, + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (resp) { + $("#loader").css("display", "none"); + if (resp.status) { + location.href = location.pathname.substring(0, location.pathname.lastIndexOf("/")); + } else { + alert(resp.error); + } + }, + failure: function (err) { + $("#loader").css("display", "none"); + alert(err); + } + }); +}); diff --git a/docs/reference/web/static/js/auth.js b/docs/reference/web/static/js/auth.js new file mode 100644 index 0000000..f256d8e --- /dev/null +++ b/docs/reference/web/static/js/auth.js @@ -0,0 +1,19 @@ +$("#auth").on("submit", function submit(e) { + e.preventDefault(); + $.ajax({ + url: "/auth", + type: "POST", + data: $("#auth").serialize(), + dataType: "json", + success: function(resp) { + if (resp.status) { + location.reload(); + } else { + alert(resp.error); + } + }, + failure: function(err) { + alert(err); + } + }); +}); diff --git a/docs/reference/web/static/js/category.js b/docs/reference/web/static/js/category.js new file mode 100644 index 0000000..8226c20 --- /dev/null +++ b/docs/reference/web/static/js/category.js @@ -0,0 +1,20 @@ +$(document).on("submit", "#object-edit", function (e) { + e.preventDefault(); + $("#loader").css("display", ""); + $.ajax({ + url: location.pathname + "/edit", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (resp) { + $("#loader").css("display", "none"); + if (!resp.status) { + alert(resp.error); + } + }, + failure: function (err) { + $("#loader").css("display", "none"); + alert(err); + } + }); +}); diff --git a/docs/reference/web/static/js/file.js b/docs/reference/web/static/js/file.js new file mode 100644 index 0000000..0286b01 --- /dev/null +++ b/docs/reference/web/static/js/file.js @@ -0,0 +1,88 @@ +$(document).on("input", "#file-tags-filter", function (e) { + let filter = $(this).val().toLowerCase(); + let unfiltered = $("#file-tags-other > .tag-preview"); + if (filter === "") { + unfiltered.css("display", ""); + return; + } + unfiltered.each((index, element) => { + let current = $(element); + if (current.text().toLowerCase().includes(filter)) { + current.css("display", ""); + } else { + current.css("display", "none"); + } + }); +}); + +$(document).on("click", "#file-tags-other > .tag-preview", function (e) { + $("#loader").css("display", ""); + $.ajax({ + url: location.pathname + "/tag", + type: "POST", + contentType: "application/json", + data: JSON.stringify({add: true, tag_id: $(this).attr("tag_id")}), + dataType: "json", + success: function (resp) { + $("#loader").css("display", "none"); + if (resp.status) { + resp.tags.forEach((tag_id) => { + $(`#file-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "none"); + $(`#file-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", ""); + }); + } else { + alert(resp.error); + } + }, + failure: function (err) { + $("#loader").css("display", "none"); + alert(err); + } + }); +}); + +$(document).on("click", "#file-tags-selected > .tag-preview", function (e) { + $("#loader").css("display", ""); + let tag_id = $(this).attr("tag_id"); + $.ajax({ + url: location.pathname + "/tag", + type: "POST", + contentType: "application/json", + data: JSON.stringify({add: false, tag_id: $(this).attr("tag_id")}), + dataType: "json", + success: function (resp) { + $("#loader").css("display", "none"); + if (resp.status) { + $(`#file-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "none"); + $(`#file-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", ""); + } else { + alert(resp.error); + } + }, + failure: function (err) { + $("#loader").css("display", "none"); + alert(err); + } + }); +}); + +$(document).on("submit", "#object-edit", function (e) { + e.preventDefault(); + $("#loader").css("display", ""); + $.ajax({ + url: location.pathname + "/edit", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (resp) { + $("#loader").css("display", "none"); + if (!resp.status) { + alert(resp.error); + } + }, + failure: function (err) { + $("#loader").css("display", "none"); + alert(err); + } + }); +}); diff --git a/docs/reference/web/static/js/files.js b/docs/reference/web/static/js/files.js new file mode 100644 index 0000000..dc311da --- /dev/null +++ b/docs/reference/web/static/js/files.js @@ -0,0 +1,130 @@ +var curr_page = 0; +var load_lock = false; +var init_filter = null; + +function files_load() { + if (load_lock) { + return; + } + load_lock = true; + let container = $("main"); + $("#loader").css("display", ""); + $.ajax({ + url: "/api/files?limit=50&offset=" + curr_page*50 + (init_filter ? "&filter=" + encodeURIComponent("{" + init_filter + "}") : ""), + type: "GET", + contentType: "application/json", + async: false, + success: function (resp) { + $("#loader").css("display", "none"); + resp.forEach((file) => { + container.append(`
+
+
+
+
+
diff --git a/docs/reference/web/templates/head.html b/docs/reference/web/templates/head.html
new file mode 100644
index 0000000..1e37514
--- /dev/null
+++ b/docs/reference/web/templates/head.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/reference/web/templates/new-category.html b/docs/reference/web/templates/new-category.html
new file mode 100644
index 0000000..b33f34e
--- /dev/null
+++ b/docs/reference/web/templates/new-category.html
@@ -0,0 +1,43 @@
+
+
+
+ {% include 'head.html' %}
+
+ | User agent | +Started | +Expires | +Terminate | +
|---|