diff --git a/backend/db/files.go b/backend/db/files.go new file mode 100644 index 0000000..76b380d --- /dev/null +++ b/backend/db/files.go @@ -0,0 +1,234 @@ +package db + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "tanabata/models" + + "github.com/jackc/pgx/v5" +) + +// Get a set of files +func FileGetSlice(user_id int, filter, sort string, limit, offset int) (files models.Slice[models.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 + } + count := 0 + for rows.Next() { + var file models.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 FileGet(user_id int, file_id string) (file models.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) + for rows.Next() { + var tag models.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 +} + +// Add file +func FileAdd(user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file models.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 + return +} + +// Update file +func FileUpdate(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 FileDelete(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 +}