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(`
`); + }); + if (resp.length == 50) { + load_lock = false; + } + }, + error: function (xhr, status) { + $("#loader").css("display", "none"); + alert(xhr.responseText); + location.href = "/files"; + } + }); + curr_page++; +} + +function tags_load(target) { + $("#loader").css("display", ""); + $.ajax({ + url: "/api/tags", + type: "GET", + contentType: "application/json", + async: false, + success: function (resp) { + $("#loader").css("display", "none"); + resp.forEach((tag) => { + target.append(`
${escapeHTML(tag.name)}
`); + }); + }, + error: function (xhr, status) { + $("#loader").css("display", "none"); + alert(status); + } + }); +} + +function filter_load() { + if (!init_filter) { + return; + } + $("#files-filter").html(""); + let filtering_tokens = init_filter.split(','); + filtering_tokens.forEach((element) => { + $(`.filtering-block .filtering-token[val='${element}']`).clone().appendTo("#files-filter"); + }); +} + +$(window).on("load", function (e) { + init_filter = /filter=\{([^\}]+)/.exec(decodeURIComponent(location.search)); + init_filter = init_filter ? init_filter[1] : null; + let container = $("main"); + while (!load_lock && container.scrollTop() + container.innerHeight() >= container[0].scrollHeight) { + files_load(); + } + tags_load($("#filtering-tokens-all")); + filter_load(); +}); + +$("main").scroll(function (e) { + if ($(this).scrollTop() + $(this).innerHeight() >= $(this)[0].scrollHeight - 100) { + files_load(); + } +}); + +$(document).on("click", "#files-filter", function (e) { + if ($(".filtering-block").is(":hidden")) { + $(".filtering-block").slideDown("fast"); + } +}); + +$(document).on("click", "#filtering-apply", function (e) { + let filtering_tokens = []; + $("#files-filter > .filtering-token").each((index, element) => { + filtering_tokens.push($(element).attr("val")); + }); + location.href = "/files?filter=" + encodeURIComponent("{" + filtering_tokens.join(',') + "}"); +}); + +$(document).on("click", "#filtering-reset", function (e) { + $(".filtering-block").slideUp("fast"); + filter_load(); + $("#filter-filtering").val("").trigger("input"); +}); + +$(document).on("click", ".filtering-block .filtering-token", function (e) { + $(this).clone().appendTo("#files-filter"); +}); + +$(document).on("click", "#files-filter > .filtering-token", function (e) { + $(this).remove(); +}); + +$(document).on("input", "#filter-filtering", function (e) { + let filter = $(this).val().toLowerCase(); + let unfiltered = $("#filtering-tokens-all > .filtering-token"); + 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", "#filter-filtering", function (e) { + $(this).val("").trigger("input"); +}); diff --git a/docs/reference/web/static/js/interface.js b/docs/reference/web/static/js/interface.js new file mode 100644 index 0000000..e3ea854 --- /dev/null +++ b/docs/reference/web/static/js/interface.js @@ -0,0 +1,363 @@ +var lazy_loader; + +function escapeHTML(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function beautify_date(date_string) { + if (date_string == null) { + return null; + } + let ts = new Date(date_string).getTime(); + let tz = new Date().getTimezoneOffset(); + return new Date(ts-tz*60000).toISOString().slice(0, 19).replace("T", " "); +} + +function close_select_manager() { + $(".item-selected").removeClass("item-selected"); + $(".selection-manager").css("display", "none"); + $("#selection-count").text(0); + $("main").css("padding-bottom", ""); + $("#selection-tags-other > .tag-preview").css("display", ""); + $("#selection-tags-selected > .tag-preview").css("display", "none"); + $("#selection-tags-filter").val("").trigger("input"); + $(".selection-tags").css("display", "none"); +} + +function refresh_selection_tags() { + $("#loader").css("display", ""); + let file_id_list = []; + $("main > .file-preview.item-selected").each((index, element) => { + file_id_list.push($(element).attr("file_id")); + }); + $("#selection-tags-other > .tag-preview").css("display", ""); + $("#selection-tags-selected > .tag-preview").css("display", "none"); + $.ajax({ + url: location.pathname + "/tags", + type: "POST", + contentType: "application/json", + data: JSON.stringify({action: "get", file_id_list: file_id_list}), + dataType: "json", + success: function (resp) { + $("#loader").css("display", "none"); + if (resp.status) { + resp.tag_id_list.forEach((tag_id) => { + $(`#selection-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "none"); + $(`#selection-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", ""); + }); + } else { + alert(resp.error); + } + }, + failure: function (err) { + $("#loader").css("display", "none"); + alert(err); + } + }); +} + +function select_handler(curr) { + let selection_count = +$("#selection-count").text(); + if (curr.hasClass("item-selected")) { + curr.removeClass("item-selected"); + selection_count--; + $("#selection-count").text(selection_count); + if (!selection_count) { + close_select_manager(); + return; + } + } else { + curr.addClass("item-selected"); + $(".selection-manager").css("display", ""); + $("main").css("padding-bottom", "80px"); + selection_count++; + $("#selection-count").text(selection_count); + } + refresh_selection_tags(); +} + +// $(window).on("load", function () { +// lazy_loader = $(".file-thumb").Lazy({ +// scrollDirection: "vertical", +// effect: "fadeIn", +// visibleOnly: true, +// appendScroll: $("main")[0], +// chainable: false, +// }); +// }); + +$(document).keyup(function (e) { + switch (e.key) { + case "Esc": + case "Escape": + close_select_manager(); + break; + // case "Left": + // case "ArrowLeft": + // if (current_sasa_index >= 0) { + // file_prev(); + // } + // break; + // case "Right": + // case "ArrowRight": + // if (current_sasa_index >= 0) { + // file_next(); + // } + // break; + default: + return; + } +}); + +$(document).on("selectstart", ".item-preview", function (e) { + e.preventDefault(); +}); + +$(document).on("click", "#select", function (e) { + if ($(".selection-manager").is(":visible")) { + close_select_manager(); + return; + } + $(".selection-manager").css("display", ""); + $("main").css("padding-bottom", "80px"); + selection_count++; + $("#selection-count").text(selection_count); +}); + +$(document).on("click", "main > .file-preview", function (e) { + e.preventDefault(); + if ($(".selection-manager").is(":visible")) { + select_handler($(this)); + return; + } + let id = $(this).attr("file_id"); + $("#viewer").attr("src", "/files/" + id); + $("#view-prev").attr("file_id", $(this).prev(":visible").attr("file_id")); + $("#view-next").attr("file_id", $(this).next(":visible").attr("file_id")); + $(".viewer-wrapper").css("display", ""); +}); + +$(document).on("click", "main > .tag-preview", function (e) { + e.preventDefault(); + if ($(".selection-manager").is(":visible")) { + select_handler($(this)); + return; + } + let id = $(this).attr("tag_id"); + location.href = "/tags/" + id; +}); + +$(document).on("click", "main > .category-preview", function (e) { + e.preventDefault(); + if ($(".selection-manager").is(":visible")) { + select_handler($(this)); + return; + } + let id = $(this).attr("category_id"); + location.href = "/categories/" + id; +}); + +$(document).on("click", "#sorting", function (e) { + $("#sorting-options").slideToggle("fast"); + if ($("#sorting-options").is(":visible")) { + key_prev = $("input[name='sorting'][prev-checked]").val(); + key_curr = $("input[name='sorting']:checked").val(); + asc_prev = $("input[name='order'][prev-checked]").val(); + asc_curr = $("input[name='order']:checked").val(); + if (key_curr != key_prev || asc_curr != asc_prev) { + $.ajax({ + url: "/settings/sorting", + type: "POST", + contentType: "application/json", + data: JSON.stringify({[location.pathname.split('/')[1]]: {key: key_curr, asc: (asc_curr == "asc")}}), + dataType: "json", + success: function (resp) { + if (resp.status) { + location.reload(); + } else { + alert(resp.error); + } + }, + failure: function (err) { + alert(err); + } + }); + } + } +}); + +$(document).on("input", "#filter", function (e) { + let filter = $(this).val().toLowerCase(); + let unfiltered = $("main > .item-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", ".filtering", function (e) { + $(this).val("").trigger("input"); +}) + +$(document).on("click", "#selection-info", function (e) { + close_select_manager(); +}); + +$(document).on("click", "#selection-edit-tags", function (e) { + $(".selection-tags").slideToggle("fast"); +}); + +$(document).on("click", "#selection-delete", function (e) { + if (!confirm("Delete selected?")) { + return; + } + let file_id_list = []; + $("main > .file-preview.item-selected").each((index, element) => { + file_id_list.push($(element).attr("file_id")); + }); + $.ajax({ + url: location.pathname + "/delete", + type: "POST", + contentType: "application/json", + data: JSON.stringify({file_id_list: file_id_list}), + dataType: "json", + success: function (resp) { + $("#loader").css("display", "none"); + if (resp.status) { + location.reload(); + } else { + alert(resp.error); + } + }, + failure: function (err) { + $("#loader").css("display", "none"); + alert(err); + } + }); +}); + +$(document).on("click", "#selection-tags-other > .tag-preview", function (e) { + $("#loader").css("display", ""); + let tag_id = $(this).attr("tag_id"); + let file_id_list = []; + $("main > .file-preview.item-selected").each((index, element) => { + file_id_list.push($(element).attr("file_id")); + }); + $.ajax({ + url: location.pathname + "/tags", + type: "POST", + contentType: "application/json", + data: JSON.stringify({action: "add", file_id_list: file_id_list, tag_id: tag_id}), + dataType: "json", + success: function (resp) { + $("#loader").css("display", "none"); + if (resp.status) { + resp.tag_id_list.forEach((tag_id) => { + $(`#selection-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "none"); + $(`#selection-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", "#selection-tags-selected > .tag-preview", function (e) { + $("#loader").css("display", ""); + let tag_id = $(this).attr("tag_id"); + let file_id_list = []; + $("main > .file-preview.item-selected").each((index, element) => { + file_id_list.push($(element).attr("file_id")); + }); + $.ajax({ + url: location.pathname + "/tags", + type: "POST", + contentType: "application/json", + data: JSON.stringify({action: "remove", file_id_list: file_id_list, tag_id: tag_id}), + dataType: "json", + success: function (resp) { + $("#loader").css("display", "none"); + if (resp.status) { + $(`#selection-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "none"); + $(`#selection-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("input", "#selection-tags-filter", function (e) { + let filter = $(this).val().toLowerCase(); + let unfiltered = $("#selection-tags-other > .item-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("scroll", $("#viewer").contents(), function (e) { + let pos = $(this).scrollTop(); + $(window.parent.document).find(".viewer-nav").css({ + top: (-pos) + "px", + bottom: pos + "px" + }); +}); + +$(document).on("click", "#view-next", function (e) { + let id = $(this).attr("file_id"); + if (!id) { + return; + } + let curr = $(`.file-preview[file_id='${id}']`) + $("#viewer").attr("src", "/files/" + id); + $("#view-prev").attr("file_id", curr.prev(":visible").attr("file_id")); + $("#view-next").attr("file_id", curr.next(":visible").attr("file_id")); + $(".viewer-wrapper").css("display", ""); +}); + +$(document).on("click", "#view-prev", function (e) { + let id = $(this).attr("file_id"); + if (!id) { + return; + } + let curr = $(`.file-preview[file_id='${id}']`) + $("#viewer").attr("src", "/files/" + id); + $("#view-prev").attr("file_id", curr.prev(":visible").attr("file_id")); + $("#view-next").attr("file_id", curr.next(":visible").attr("file_id")); + $(".viewer-wrapper").css("display", ""); +}); + +$(document).on("click", "#view-close", function (e) { + $(".viewer-wrapper").css("display", "none"); +}); diff --git a/docs/reference/web/static/js/settings.js b/docs/reference/web/static/js/settings.js new file mode 100644 index 0000000..f5af05f --- /dev/null +++ b/docs/reference/web/static/js/settings.js @@ -0,0 +1,18 @@ +$(window).on("load", function () { + $.ajax({ + url: "/api/get_my_sessions", + type: "GET", + contentType: "application/json", + success: function (resp) { + let timezone_offset = new Date().getTimezoneOffset(); + resp.forEach((session) => { + let s_started = beautify_date(session.started); + let s_expires = beautify_date(session.expires); + $("#sessions-table").append(`${session.user_agent_name}${s_started}${s_expires === null ? "-" : session.expires}Terminate`); + }); + }, + failure: function (err) { + alert(err); + } + }); +}); diff --git a/docs/reference/web/static/js/tag.js b/docs/reference/web/static/js/tag.js new file mode 100644 index 0000000..204b63a --- /dev/null +++ b/docs/reference/web/static/js/tag.js @@ -0,0 +1,89 @@ +$(document).on("input", "#parent-tags-filter", function (e) { + let filter = $(this).val().toLowerCase(); + let unfiltered = $("#parent-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", "#parent-tags-other > .tag-preview", function (e) { + $("#loader").css("display", ""); + let tag_id = $(this).attr("tag_id"); + $.ajax({ + url: location.pathname + "/parent", + 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) { + $(`#parent-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "none"); + $(`#parent-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", "#parent-tags-selected > .tag-preview", function (e) { + $("#loader").css("display", ""); + let tag_id = $(this).attr("tag_id"); + $.ajax({ + url: location.pathname + "/parent", + 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) { + $(`#parent-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "none"); + $(`#parent-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) { + 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/service/browserconfig.xml b/docs/reference/web/static/service/browserconfig.xml new file mode 100644 index 0000000..746848c --- /dev/null +++ b/docs/reference/web/static/service/browserconfig.xml @@ -0,0 +1,11 @@ + + + + + + + + #615880 + + + diff --git a/docs/reference/web/static/service/favicon.ico b/docs/reference/web/static/service/favicon.ico new file mode 100644 index 0000000..812dbc1 Binary files /dev/null and b/docs/reference/web/static/service/favicon.ico differ diff --git a/docs/reference/web/static/service/robots.txt b/docs/reference/web/static/service/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/docs/reference/web/static/service/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/docs/reference/web/static/service/tanabata.webmanifest b/docs/reference/web/static/service/tanabata.webmanifest new file mode 100644 index 0000000..29895bd --- /dev/null +++ b/docs/reference/web/static/service/tanabata.webmanifest @@ -0,0 +1,49 @@ +{ + "name": "Tanabata File Manager", + "short_name": "Tanabata", + "lang": "en-US", + "description": "Tanabata File Manager PWA", + "start_url": "/", + "scope": "/", + "display": "standalone", + "theme_color": "#615880", + "background_color": "#312F45", + "icons": [ + { + "src": "\/images\/android-icon-36x36.png", + "sizes": "36x36", + "type": "image\/png", + "density": "0.75" + }, + { + "src": "\/images\/android-icon-48x48.png", + "sizes": "48x48", + "type": "image\/png", + "density": "1.0" + }, + { + "src": "\/images\/android-icon-72x72.png", + "sizes": "72x72", + "type": "image\/png", + "density": "1.5" + }, + { + "src": "\/images\/android-icon-96x96.png", + "sizes": "96x96", + "type": "image\/png", + "density": "2.0" + }, + { + "src": "\/images\/android-icon-144x144.png", + "sizes": "144x144", + "type": "image\/png", + "density": "3.0" + }, + { + "src": "\/images\/android-icon-192x192.png", + "sizes": "192x192", + "type": "image\/png", + "density": "4.0" + } + ] +} diff --git a/docs/reference/web/templates/auth.html b/docs/reference/web/templates/auth.html new file mode 100644 index 0000000..e9ba929 --- /dev/null +++ b/docs/reference/web/templates/auth.html @@ -0,0 +1,25 @@ + + + + {% include 'head.html' %} + Welcome to Tanabata File Manager! + + + + + +
+

Welcome to Tanabata File Manager!

+
+ +
+
+ +
+
+ +
+
+ + + 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' %} + + New category | Tanabata File Manager + + +
+

Category - New category

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + + \ No newline at end of file diff --git a/docs/reference/web/templates/new-file.html b/docs/reference/web/templates/new-file.html new file mode 100644 index 0000000..4611d29 --- /dev/null +++ b/docs/reference/web/templates/new-file.html @@ -0,0 +1,82 @@ + + + + {% include 'head.html' %} + + New file | Tanabata File Manager + + + +
+ + {{ file['id'] }}.{{ file['extension'] }} + +
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+
+
+ {% for tag in tags %} +
{{ tag['name'] }}
+ {% endfor %} +
+ +
+ {% for tag in tags_all %} + {% if tag not in tags %} +
{{ tag['name'] }}
+ {% endif %} + {% endfor %} +
+
+
+ + + + + \ No newline at end of file diff --git a/docs/reference/web/templates/new-tag.html b/docs/reference/web/templates/new-tag.html new file mode 100644 index 0000000..1e3b280 --- /dev/null +++ b/docs/reference/web/templates/new-tag.html @@ -0,0 +1,52 @@ + + + + {% include 'head.html' %} + + New tag | Tanabata File Manager + + +
+

Tag - New tag

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + + \ No newline at end of file diff --git a/docs/reference/web/templates/section-categories.html b/docs/reference/web/templates/section-categories.html new file mode 100644 index 0000000..ca17c3b --- /dev/null +++ b/docs/reference/web/templates/section-categories.html @@ -0,0 +1,15 @@ +{% extends 'section.html' %} +{% set section = 'categories' %} +{% set sorting_options = ['name', 'color', 'created'] %} + +{% block Header %} +
+ +
+{% endblock %} + +{% block Main %} + {% for category in categories %} +
{{ category['name'] }}
+ {% endfor %} +{% endblock %} diff --git a/docs/reference/web/templates/section-files.html b/docs/reference/web/templates/section-files.html new file mode 100644 index 0000000..932eab7 --- /dev/null +++ b/docs/reference/web/templates/section-files.html @@ -0,0 +1,33 @@ +{% extends 'section.html' %} +{% set section = 'files' %} +{% set sorting_options = ['mime_name', 'datetime', 'created'] %} + +{% block Header %} +
+
+
+ +{% endblock %} + +{% block Main %} +{% endblock %} diff --git a/docs/reference/web/templates/section-settings.html b/docs/reference/web/templates/section-settings.html new file mode 100644 index 0000000..26d3cfe --- /dev/null +++ b/docs/reference/web/templates/section-settings.html @@ -0,0 +1,35 @@ +{% extends 'section.html' %} +{% set section = 'settings' %} + +{% block Main %} +
+

User settings

+
+
+ + +
+
+ + +
+
+
+ +
+
+
+

Sessions

+ + + + + + + + + + +
User agentStartedExpiresTerminate
+
+{% endblock %} diff --git a/docs/reference/web/templates/section-tags.html b/docs/reference/web/templates/section-tags.html new file mode 100644 index 0000000..2233d28 --- /dev/null +++ b/docs/reference/web/templates/section-tags.html @@ -0,0 +1,15 @@ +{% extends 'section.html' %} +{% set section = 'tags' %} +{% set sorting_options = ['name', 'color', 'category_name', 'created'] %} + +{% block Header %} +
+ +
+{% endblock %} + +{% block Main %} + {% for tag in tags %} +
{{ tag['name'] }}
+ {% endfor %} +{% endblock %} diff --git a/docs/reference/web/templates/section.html b/docs/reference/web/templates/section.html new file mode 100644 index 0000000..8e8c65c --- /dev/null +++ b/docs/reference/web/templates/section.html @@ -0,0 +1,117 @@ + + + + {% include 'head.html' %} + {{ section[0]|upper() }}{{ section[1:] }} | Tanabata File Manager + + {% if section == 'files' %} + + {% endif %} + + + {% if section != 'settings' %} +
+
+
Select
+
Sorting by {{ sorting['key'] }} ({% if sorting['asc'] %}asc{% else %}desc{% endif %})
+ +
+ {% block Header %}{% endblock %} +
+ {% endif %} +
+ {% block Main %}{% endblock %} +
+ {% if section != 'settings' %} + + {% endif %} + {% if section == 'files' %} + + {% endif %} + + + + + diff --git a/docs/reference/web/templates/view-category.html b/docs/reference/web/templates/view-category.html new file mode 100644 index 0000000..cbacc7c --- /dev/null +++ b/docs/reference/web/templates/view-category.html @@ -0,0 +1,43 @@ + + + + {% include 'head.html' %} + + Category - {{ category['name'] }} | Tanabata File Manager + + +
+

Category - {{ category['name'] }}

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + + \ No newline at end of file diff --git a/docs/reference/web/templates/view-file.html b/docs/reference/web/templates/view-file.html new file mode 100644 index 0000000..08bf63b --- /dev/null +++ b/docs/reference/web/templates/view-file.html @@ -0,0 +1,57 @@ + + + + {% include 'head.html' %} + + File - {{ file['id'] }}.{{ file['extension'] }} | Tanabata File Manager + + +
+ + {{ file['id'] }} + +
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+
+
+ {% for tag in tags_all %} +
{{ tag['name'] }}
+ {% endfor %} +
+ +
+ {% for tag in tags_all %} +
{{ tag['name'] }}
+ {% endfor %} +
+
+
+ + + + + \ No newline at end of file diff --git a/docs/reference/web/templates/view-tag.html b/docs/reference/web/templates/view-tag.html new file mode 100644 index 0000000..a6f9473 --- /dev/null +++ b/docs/reference/web/templates/view-tag.html @@ -0,0 +1,65 @@ + + + + {% include 'head.html' %} + + Tag - {{ tag['name'] }} | Tanabata File Manager + + +
+

Tag - {{ tag['name'] }}

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ {% for tag in tags_all %} +
{{ tag['name'] }}
+ {% endfor %} +
+ +
+ {% for tag in tags_all %} +
{{ tag['name'] }}
+ {% endfor %} +
+
+
+ + + + + \ No newline at end of file diff --git a/docs/reference/web/tfm_web.py b/docs/reference/web/tfm_web.py new file mode 100644 index 0000000..41c04e9 --- /dev/null +++ b/docs/reference/web/tfm_web.py @@ -0,0 +1,476 @@ +#!../venv/bin/python3 + +from flask import Flask, render_template, request, session, jsonify, redirect, url_for, send_from_directory, send_file, abort +from flask_cors import CORS +from ua_parser.user_agent_parser import ParseUserAgent +import sys +from os.path import dirname, abspath, join + +sys.path.append(dirname(dirname(abspath(__file__)))) +import api.tfm_api as tfm_api + +tfm_api.Initialize() +app = Flask("TFM") +CORS(app) +app.secret_key = tfm_api.conf["Flask"]["SecretKey"] + + +@app.route("/api/", methods=["GET"]) +def api(func): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + return redirect("/") + try: + if func == "files": + sorting = session.get("sorting")["files"] + philter = request.args.get("filter") + offset = request.args.get("offset", type=int, default=0) + limit = request.args.get("limit", type=int) + return jsonify(ts.get_files_by_filter(philter, sorting["key"], sorting["asc"], offset, limit)) + if func == "tags": + sorting = session.get("sorting")["tags"] + offset = request.args.get("offset", type=int, default=0) + limit = request.args.get("limit", type=int) + return jsonify(ts.get_tags(sorting["key"], sorting["asc"], offset, limit)) + if func == "get_my_sessions": + offset = request.args.get("offset", type=int, default=0) + limit = request.args.get("limit", type=int) + return jsonify(ts.get_my_sessions(offset=offset, limit=limit)) + if func == "terminate_session": + session_id = request.args.get("id"); + if session_id is None: + session_id = ts.sid + return jsonify(), 204 + abort(400) + except Exception as e: + print(e) + abort(500, str(e)) + + +@app.route("/favicon.ico") +@app.route("/robots.txt") +@app.route("/tanabata.webmanifest") +@app.route("/browserconfig.xml") +def favicon(): + return send_from_directory(join(app.root_path, "static/service"), request.path[1:]) + + +@app.route("/", methods=["GET"]) +def index(): + if session.get("id"): + return redirect("/files") + return render_template("auth.html") + + +@app.route("/auth", methods=["POST"]) +def auth(): + try: + ts = tfm_api.authorize( + request.form.get("username"), + request.form.get("password"), + ParseUserAgent(request.headers.get("User-Agent"))["family"] + ) + except Exception as e: + return jsonify({"status": False, "error": str(e).split('\n')[0]}) + else: + logout() + session["id"] = ts.sid + session["sorting"] = tfm_api.DEFAULT_SORTING + session.permanent = True + session.modified = True + return jsonify({"status": True, "is_admin": ts.is_admin}) + + +@app.route("/logout", methods=["GET"]) +def logout(): + try: + ts = tfm_api.TSession(session.get("id")) + ts.terminate() + finally: + session.clear() + return redirect("/") + + +@app.route("/files", methods=["GET"]) +def files(): + try: + ts = tfm_api.TSession(session.get("id")) + sorting = session.get("sorting")["files"] + sorting_t = session.get("sorting")["tags"] + except Exception as e: + logout() + return redirect("/") + return render_template("section-files.html", + files=ts.get_files(sorting["key"], sorting["asc"], limit=100), + sorting=sorting, + tags_all=ts.get_tags(sorting_t["key"], sorting_t["asc"]) + ) + + +@app.route("/tags", methods=["GET"]) +def tags(): + try: + ts = tfm_api.TSession(session.get("id")) + sorting = session.get("sorting")["tags"] + except Exception as e: + logout() + return redirect("/") + return render_template("section-tags.html", + tags=ts.get_tags(sorting["key"], sorting["asc"]), + sorting=sorting + ) + + +@app.route("/categories", methods=["GET"]) +def categories(): + try: + ts = tfm_api.TSession(session.get("id")) + sorting = session.get("sorting")["categories"] + except Exception as e: + logout() + return redirect("/") + return render_template("section-categories.html", + categories=ts.get_categories(sorting["key"], sorting["asc"]), + sorting=sorting + ) + + +@app.route("/settings", methods=["GET"]) +def settings(): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + return redirect("/") + return render_template("section-settings.html") + + +@app.route("/files/", methods=["GET"]) +def file(file_id): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + return redirect("/") + try: + file = ts.get_file(file_id) + if not file: + abort(404, "File does not exist") + file["datetime"] = file["datetime"].strftime('%Y-%m-%dT%H:%M:%S') + sorting = session.get("sorting")["tags"] + ts.view_file(file_id) + return render_template("view-file.html", + file=file, + tags=ts.get_tags_by_file(file_id, sorting["key"], sorting["asc"]), + tags_all=ts.get_tags(sorting["key"], sorting["asc"]) + ) + except Exception as e: + abort(400, str(e).split('\n')[0]) + + +@app.route("/tags/", methods=["GET", "POST"]) +def tag(tag_id): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + if request.method == "POST": + abort(401) + return redirect("/") + try: + tag = ts.get_tag(tag_id) + if not tag: + raise RuntimeError("Tag does not exist") + sorting_c = session.get("sorting")["categories"] + sorting_t = session.get("sorting")["tags"] + return render_template("view-tag.html", + tag=tag, + categories=ts.get_categories(sorting_c["key"], sorting_c["asc"]), + parent_tags=ts.get_parent_tags(tag_id), + tags_all=ts.get_tags(sorting_t["key"], sorting_t["asc"]) + ) + except Exception as e: + abort(400, str(e).split('\n')[0]) + + +@app.route("/categories/", methods=["GET", "POST"]) +def category(category_id): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + if request.method == "POST": + abort(401) + return redirect("/") + try: + category = ts.get_category(category_id) + if not category: + raise RuntimeError("Category does not exist") + return render_template("view-category.html", + category=category + ) + except Exception as e: + abort(400, str(e).split('\n')[0]) + + +@app.route("/tags/new", methods=["GET", "POST"]) +def new_tag(): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + if request.method == "POST": + abort(401) + return redirect("/") + if request.method == "POST": + try: + color = request.form.get("color") + if color == "#444455": + color = None + return jsonify({"status": True, "tag_id": ts.add_tag(request.form.get("name").strip(), + request.form.get("notes"), + color, + request.form.get("category_id"), + request.form.get("is_private", False))}) + except Exception as e: + return jsonify({"status": False, "error": str(e).split('\n')[0]}) + try: + sorting = session.get("sorting")["categories"] + return render_template("new-tag.html", + categories=ts.get_categories(sorting["key"], sorting["asc"]) + ) + except Exception as e: + abort(400, str(e).split('\n')[0]) + + +@app.route("/categories/new", methods=["GET", "POST"]) +def new_category(): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + if request.method == "POST": + abort(401) + return redirect("/") + if request.method == "POST": + try: + color = request.form.get("color") + if color == "#444455": + color = None + return jsonify({"status": True, "tag_id": ts.add_category(request.form.get("name").strip(), + request.form.get("notes"), + color, + request.form.get("is_private", False))}) + except Exception as e: + return jsonify({"status": False, "error": str(e).split('\n')[0]}) + try: + return render_template("new-category.html") + except Exception as e: + abort(400, str(e).split('\n')[0]) + + +@app.route("/files//edit", methods=["POST"]) +def edit_file(file_id): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + abort(401) + try: + ts.edit_file(file_id, None, request.form.get("datetime"), request.form.get("notes"), request.form.get("is_private", False)) + return jsonify({"status": True}) + except Exception as e: + return jsonify({"status": False, "error": str(e).split('\n')[0]}) + + +@app.route("/tags//edit", methods=["POST"]) +def edit_tag(tag_id): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + abort(401) + try: + color = request.form.get("color") + if color == "#444455": + color = "" + ts.edit_tag(tag_id, request.form.get("name").strip(), request.form.get("notes"), color, request.form.get("category_id"), request.form.get("is_private", False)) + return jsonify({"status": True}) + except Exception as e: + return jsonify({"status": False, "error": str(e).split('\n')[0]}) + + +@app.route("/categories//edit", methods=["POST"]) +def edit_category(category_id): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + abort(401) + try: + color = request.form.get("color") + if color == "#444455": + color = "" + ts.edit_category(category_id, request.form.get("name").strip(), request.form.get("notes"), color, request.form.get("is_private", False)) + return jsonify({"status": True}) + except Exception as e: + return jsonify({"status": False, "error": str(e).split('\n')[0]}) + + +@app.route("/files//tag", methods=["POST"]) +def file_tags(file_id): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + abort(401) + try: + req = request.get_json() + if req["add"]: + return jsonify({"status": True, "tags": ts.add_file_to_tag(file_id, req["tag_id"])}) + else: + ts.remove_file_to_tag(file_id, req["tag_id"]) + return jsonify({"status": True}) + except Exception as e: + return jsonify({"status": False, "error": str(e).split('\n')[0]}) + + +@app.route("/tags//parent", methods=["POST"]) +def parent_tags(tag_id): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + abort(401) + try: + req = request.get_json() + if req["add"]: + ts.add_autotag(tag_id, req["tag_id"]) + return jsonify({"status": True}) + else: + ts.remove_autotag(tag_id, req["tag_id"]) + return jsonify({"status": True}) + except Exception as e: + return jsonify({"status": False, "error": str(e).split('\n')[0]}) + + +@app.route("/files/tags", methods=["POST"]) +def files_tags(): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + abort(401) + try: + req = request.get_json() + if req["action"] == "get": + res = set(map(lambda t: t["id"], ts.get_tags_by_file(req["file_id_list"][0]))) + for file_id in req["file_id_list"][1:]: + res &= set(map(lambda t: t["id"], ts.get_tags_by_file(file_id))) + return jsonify({"status": True, "tag_id_list": list(res)}) + elif req["action"] == "add": + res = set() + for file_id in req["file_id_list"]: + res |= set(ts.add_file_to_tag(file_id, req["tag_id"])) + return jsonify({"status": True, "tag_id_list": list(res)}) + elif req["action"] == "remove": + for file_id in req["file_id_list"]: + ts.remove_file_to_tag(file_id, req["tag_id"]) + return jsonify({"status": True}) + else: + return jsonify({"status": False, "error": "unsupported action"}) + except Exception as e: + return jsonify({"status": False, "error": str(e).split('\n')[0]}) + + +@app.route("/files/delete", methods=["POST"]) +def files_delete(): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + abort(401) + try: + req = request.get_json() + for file_id in req["file_id_list"]: + ts.remove_file(file_id) + return jsonify({"status": True}) + except Exception as e: + return jsonify({"status": False, "error": str(e).split('\n')[0]}) + + +@app.route("/static/files/", methods=["GET"]) +def file_full(file_id=None): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + abort(404) + try: + file = ts.get_file(file_id) + if not file: + raise RuntimeError("File does not exist") + return send_file( + join(tfm_api.conf["Paths"]["Files"], file_id), + mimetype=file["mime_name"], + download_name=(file["orig_name"] if file["orig_name"] else "%s.%s" % (file_id, file["extension"])) + ) + except: + abort(404) + + +@app.route("/static/thumbs/", methods=["GET"]) +def thumb(file_id=None): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + abort(404) + try: + file = ts.get_file(file_id) + if not file: + raise RuntimeError("File does not exist") + return send_file( + tfm_api.previewer.get_jpeg_preview(join(tfm_api.conf["Paths"]["Files"], file_id), height=160, width=160), + mimetype="image/jpeg" + ) + except: + abort(404) + + +@app.route("/static/previews/", methods=["GET"]) +def preview(file_id=None): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + abort(404) + try: + file = ts.get_file(file_id) + if not file: + raise RuntimeError("File does not exist") + return send_file( + tfm_api.previewer.get_jpeg_preview(join(tfm_api.conf["Paths"]["Files"], file_id), height=1080, width=1920), + mimetype="image/jpeg" + ) + except: + abort(404) + + +@app.route("/settings/sorting", methods=["POST"]) +def sorting(): + try: + ts = tfm_api.TSession(session.get("id")) + except Exception as e: + logout() + return redirect("/") + req = request.get_json() + session["sorting"].update(req) + session.modified = True + return jsonify({"status": True}) + + +if __name__ == "__main__": + app.run(host=tfm_api.conf["Flask"]["Host"], port=tfm_api.conf["Flask"]["Port"], debug=True)