diff --git a/api/resource.go b/api/resource.go index b412c44a..7db67288 100644 --- a/api/resource.go +++ b/api/resource.go @@ -15,6 +15,7 @@ type Resource struct { ExternalLink string `json:"externalLink"` Type string `json:"type"` Size int64 `json:"size"` + PublicID string `json:"publicId"` // Related fields LinkedMemoAmount int `json:"linkedMemoAmount"` @@ -31,6 +32,7 @@ type ResourceCreate struct { ExternalLink string `json:"externalLink"` Type string `json:"type"` Size int64 `json:"-"` + PublicID string `json:"publicId"` } type ResourceFind struct { @@ -42,6 +44,7 @@ type ResourceFind struct { // Domain specific fields Filename *string `json:"filename"` MemoID *int + PublicID *string `json:"publicId"` GetBlob bool // Pagination @@ -56,7 +59,9 @@ type ResourcePatch struct { UpdatedTs *int64 // Domain specific fields - Filename *string `json:"filename"` + Filename *string `json:"filename"` + ResetPublicID *bool `json:"resetPublicId"` + PublicID *string `json:"-"` } type ResourceDelete struct { diff --git a/server/jwt.go b/server/jwt.go index 613bc819..f8d0f120 100644 --- a/server/jwt.go +++ b/server/jwt.go @@ -122,6 +122,10 @@ func JWTMiddleware(server *Server, next echo.HandlerFunc, secret string) echo.Ha token := findAccessToken(c) if token == "" { + // Allow the user to access the public endpoints. + if common.HasPrefixes(path, "/o") { + return next(c) + } // When the request is not authenticated, we allow the user to access the memo endpoints for those public memos. if common.HasPrefixes(path, "/api/memo") && method == http.MethodGet { return next(c) diff --git a/server/resource.go b/server/resource.go index 0d64c170..3d52c9e5 100644 --- a/server/resource.go +++ b/server/resource.go @@ -194,6 +194,11 @@ func (s *Server) registerResourceRoutes(g *echo.Group) { } } + if s.Profile.IsDev() { + publicID := common.GenUUID() + resourceCreate.PublicID = publicID + } + resource, err := s.Store.CreateResource(ctx, resourceCreate) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err) @@ -227,52 +232,6 @@ func (s *Server) registerResourceRoutes(g *echo.Group) { return c.JSON(http.StatusOK, composeResponse(list)) }) - g.GET("/resource/:resourceId", func(c echo.Context) error { - ctx := c.Request().Context() - resourceID, err := strconv.Atoi(c.Param("resourceId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) - } - - userID, ok := c.Get(getUserIDContextKey()).(int) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } - resourceFind := &api.ResourceFind{ - ID: &resourceID, - CreatorID: &userID, - GetBlob: true, - } - resource, err := s.Store.FindResource(ctx, resourceFind) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err) - } - return c.JSON(http.StatusOK, composeResponse(resource)) - }) - - g.GET("/resource/:resourceId/blob", func(c echo.Context) error { - ctx := c.Request().Context() - resourceID, err := strconv.Atoi(c.Param("resourceId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) - } - - userID, ok := c.Get(getUserIDContextKey()).(int) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } - resourceFind := &api.ResourceFind{ - ID: &resourceID, - CreatorID: &userID, - GetBlob: true, - } - resource, err := s.Store.FindResource(ctx, resourceFind) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err) - } - return c.Stream(http.StatusOK, resource.Type, bytes.NewReader(resource.Blob)) - }) - g.PATCH("/resource/:resourceId", func(c echo.Context) error { ctx := c.Request().Context() userID, ok := c.Get(getUserIDContextKey()).(int) @@ -304,6 +263,11 @@ func (s *Server) registerResourceRoutes(g *echo.Group) { return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err) } + if resourcePatch.ResetPublicID != nil && *resourcePatch.ResetPublicID { + publicID := common.GenUUID() + resourcePatch.PublicID = &publicID + } + resourcePatch.ID = resourceID resource, err = s.Store.PatchResource(ctx, resourcePatch) if err != nil { @@ -349,19 +313,19 @@ func (s *Server) registerResourceRoutes(g *echo.Group) { } func (s *Server) registerResourcePublicRoutes(g *echo.Group) { - g.GET("/r/:resourceId/:filename", func(c echo.Context) error { + g.GET("/r/:resourceId/:publicId", func(c echo.Context) error { ctx := c.Request().Context() resourceID, err := strconv.Atoi(c.Param("resourceId")) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) } - filename, err := url.QueryUnescape(c.Param("filename")) + publicID, err := url.QueryUnescape(c.Param("publicId")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("filename is invalid: %s", c.Param("filename"))).SetInternal(err) + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("publicID is invalid: %s", c.Param("publicId"))).SetInternal(err) } resourceFind := &api.ResourceFind{ ID: &resourceID, - Filename: &filename, + PublicID: &publicID, GetBlob: true, } resource, err := s.Store.FindResource(ctx, resourceFind) @@ -369,6 +333,10 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err) } + if resource.ExternalLink != "" { + return c.Redirect(http.StatusSeeOther, resource.ExternalLink) + } + blob := resource.Blob if resource.InternalPath != "" { src, err := os.Open(resource.InternalPath) diff --git a/server/server.go b/server/server.go index e853aadd..34cd56a3 100644 --- a/server/server.go +++ b/server/server.go @@ -79,29 +79,32 @@ func NewServer(ctx context.Context, profile *profile.Profile) (*Server, error) { } s.ID = serverID - secretSessionName := "usememos" + embedFrontend(e) + + secret := "usememos" if profile.Mode == "prod" { - secretSessionName, err = s.getSystemSecretSessionName(ctx) + secret, err = s.getSystemSecretSessionName(ctx) if err != nil { return nil, err } } - embedFrontend(e) - rootGroup := e.Group("") s.registerRSSRoutes(rootGroup) publicGroup := e.Group("/o") - s.registerResourcePublicRoutes(publicGroup) + publicGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc { + return JWTMiddleware(s, next, secret) + }) registerGetterPublicRoutes(publicGroup) + s.registerResourcePublicRoutes(publicGroup) apiGroup := e.Group("/api") apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc { - return JWTMiddleware(s, next, secretSessionName) + return JWTMiddleware(s, next, secret) }) s.registerSystemRoutes(apiGroup) - s.registerAuthRoutes(apiGroup, secretSessionName) + s.registerAuthRoutes(apiGroup, secret) s.registerUserRoutes(apiGroup) s.registerMemoRoutes(apiGroup) s.registerShortcutRoutes(apiGroup) diff --git a/store/db/migration/dev/LATEST__SCHEMA.sql b/store/db/migration/dev/LATEST__SCHEMA.sql index 1e50942f..a5b3cfac 100644 --- a/store/db/migration/dev/LATEST__SCHEMA.sql +++ b/store/db/migration/dev/LATEST__SCHEMA.sql @@ -74,10 +74,12 @@ CREATE TABLE resource ( updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), filename TEXT NOT NULL DEFAULT '', blob BLOB DEFAULT NULL, - internal_path TEXT NOT NULL DEFAULT '', external_link TEXT NOT NULL DEFAULT '', type TEXT NOT NULL DEFAULT '', - size INTEGER NOT NULL DEFAULT 0 + size INTEGER NOT NULL DEFAULT 0, + internal_path TEXT NOT NULL DEFAULT '', + public_id TEXT NOT NULL DEFAULT '', + UNIQUE(id, public_id) ); -- memo_resource diff --git a/store/db/migration/prod/0.12/03__resource_internal_path.sql b/store/db/migration/prod/0.12/03__resource_internal_path.sql new file mode 100644 index 00000000..97f37a37 --- /dev/null +++ b/store/db/migration/prod/0.12/03__resource_internal_path.sql @@ -0,0 +1,4 @@ +ALTER TABLE + resource +ADD + COLUMN internal_path TEXT NOT NULL DEFAULT ''; \ No newline at end of file diff --git a/store/db/migration/prod/0.12/04__resource_public_id.sql b/store/db/migration/prod/0.12/04__resource_public_id.sql new file mode 100644 index 00000000..3d54e5b7 --- /dev/null +++ b/store/db/migration/prod/0.12/04__resource_public_id.sql @@ -0,0 +1,21 @@ +ALTER TABLE + resource +ADD + COLUMN public_id TEXT NOT NULL DEFAULT ''; + +CREATE UNIQUE INDEX resource_id_public_id_unique_index ON resource (id, public_id); + +UPDATE + resource +SET + public_id = ( + SELECT + printf( + '%s-%s-%s-%s-%s', + lower(hex(randomblob(4))), + lower(hex(randomblob(2))), + lower(hex(randomblob(2))), + lower(hex(randomblob(2))), + lower(hex(randomblob(6))) + ) as uuid + ); \ No newline at end of file diff --git a/store/resource.go b/store/resource.go index 943597fe..9266b086 100644 --- a/store/resource.go +++ b/store/resource.go @@ -28,6 +28,7 @@ type resourceRaw struct { ExternalLink string Type string Size int64 + PublicID string LinkedMemoAmount int } @@ -47,6 +48,7 @@ func (raw *resourceRaw) toResource() *api.Resource { ExternalLink: raw.ExternalLink, Type: raw.Type, Size: raw.Size, + PublicID: raw.PublicID, LinkedMemoAmount: raw.LinkedMemoAmount, } } @@ -195,9 +197,9 @@ func (s *Store) createResourceImpl(ctx context.Context, tx *sql.Tx, create *api. values := []any{create.Filename, create.Blob, create.ExternalLink, create.Type, create.Size, create.CreatorID} placeholders := []string{"?", "?", "?", "?", "?", "?"} if s.profile.IsDev() { - fields = append(fields, "internal_path") - values = append(values, create.InternalPath) - placeholders = append(placeholders, "?") + fields = append(fields, "internal_path", "public_id") + values = append(values, create.InternalPath, create.PublicID) + placeholders = append(placeholders, "?", "?") } query := ` @@ -218,7 +220,7 @@ func (s *Store) createResourceImpl(ctx context.Context, tx *sql.Tx, create *api. &resourceRaw.CreatorID, } if s.profile.IsDev() { - dests = append(dests, &resourceRaw.InternalPath) + dests = append(dests, &resourceRaw.InternalPath, &resourceRaw.PublicID) } dests = append(dests, []any{&resourceRaw.CreatedTs, &resourceRaw.UpdatedTs}...) if err := tx.QueryRowContext(ctx, query, values...).Scan(dests...); err != nil { @@ -237,12 +239,15 @@ func (s *Store) patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.Re if v := patch.Filename; v != nil { set, args = append(set, "filename = ?"), append(args, *v) } + if v := patch.PublicID; v != nil { + set, args = append(set, "public_id = ?"), append(args, *v) + } args = append(args, patch.ID) fields := []string{"id", "filename", "external_link", "type", "size", "creator_id", "created_ts", "updated_ts"} if s.profile.IsDev() { - fields = append(fields, "internal_path") + fields = append(fields, "internal_path", "public_id") } query := ` @@ -262,7 +267,7 @@ func (s *Store) patchResourceImpl(ctx context.Context, tx *sql.Tx, patch *api.Re &resourceRaw.UpdatedTs, } if s.profile.IsDev() { - dests = append(dests, &resourceRaw.InternalPath) + dests = append(dests, &resourceRaw.InternalPath, &resourceRaw.PublicID) } if err := tx.QueryRowContext(ctx, query, args...).Scan(dests...); err != nil { return nil, FormatError(err) @@ -286,13 +291,16 @@ func (s *Store) findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api. if v := find.MemoID; v != nil { where, args = append(where, "resource.id in (SELECT resource_id FROM memo_resource WHERE memo_id = ?)"), append(args, *v) } + if v := find.PublicID; v != nil { + where, args = append(where, "resource.public_id = ?"), append(args, *v) + } fields := []string{"resource.id", "resource.filename", "resource.external_link", "resource.type", "resource.size", "resource.creator_id", "resource.created_ts", "resource.updated_ts"} if find.GetBlob { fields = append(fields, "resource.blob") } if s.profile.IsDev() { - fields = append(fields, "internal_path") + fields = append(fields, "internal_path", "public_id") } query := fmt.Sprintf(` @@ -336,7 +344,7 @@ func (s *Store) findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api. dests = append(dests, &resourceRaw.Blob) } if s.profile.IsDev() { - dests = append(dests, &resourceRaw.InternalPath) + dests = append(dests, &resourceRaw.InternalPath, &resourceRaw.PublicID) } if err := rows.Scan(dests...); err != nil { return nil, FormatError(err) diff --git a/web/src/components/ResourceCard.tsx b/web/src/components/ResourceCard.tsx index 45bf88ec..24afd5d5 100644 --- a/web/src/components/ResourceCard.tsx +++ b/web/src/components/ResourceCard.tsx @@ -5,15 +5,7 @@ import ResourceCover from "./ResourceCover"; import ResourceItemDropdown from "./ResourceItemDropdown"; import "@/less/resource-card.less"; -const ResourceCard = ({ - resource, - handleCheckClick, - handleUncheckClick, - handlePreviewBtnClick, - handleCopyResourceLinkBtnClick, - handleRenameBtnClick, - handleDeleteResourceBtnClick, -}: ResourceItemType) => { +const ResourceCard = ({ resource, handleCheckClick, handleUncheckClick }: ResourceItemType) => { const [isSelected, setIsSelected] = useState(false); const handleSelectBtnClick = () => { @@ -32,13 +24,7 @@ const ResourceCard = ({ {isSelected ? : }
- +
diff --git a/web/src/components/ResourceItem.tsx b/web/src/components/ResourceItem.tsx index 8c311015..6b00df6a 100644 --- a/web/src/components/ResourceItem.tsx +++ b/web/src/components/ResourceItem.tsx @@ -1,15 +1,8 @@ +import { Checkbox } from "@mui/joy"; import { useState } from "react"; import ResourceItemDropdown from "./ResourceItemDropdown"; -const ResourceItem = ({ - resource, - handleCheckClick, - handleUncheckClick, - handlePreviewBtnClick, - handleCopyResourceLinkBtnClick, - handleRenameBtnClick, - handleDeleteResourceBtnClick, -}: ResourceItemType) => { +const ResourceItem = ({ resource, handleCheckClick, handleUncheckClick }: ResourceItemType) => { const [isSelected, setIsSelected] = useState(false); const handleSelectBtnClick = () => { @@ -23,21 +16,13 @@ const ResourceItem = ({ return (
- - + + - {resource.id} - handleRenameBtnClick(resource)}> - {resource.filename} - -
- + {resource.id} + {resource.filename} +
+
); diff --git a/web/src/components/ResourceItemDropdown.tsx b/web/src/components/ResourceItemDropdown.tsx index f39dc640..5980d8fe 100644 --- a/web/src/components/ResourceItemDropdown.tsx +++ b/web/src/components/ResourceItemDropdown.tsx @@ -1,24 +1,77 @@ +import copy from "copy-to-clipboard"; import React from "react"; +import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; +import { useResourceStore } from "@/store/module"; +import { getResourceUrl } from "@/utils/resource"; import Dropdown from "./base/Dropdown"; import Icon from "./Icon"; +import { showCommonDialog } from "./Dialog/CommonDialog"; +import showChangeResourceFilenameDialog from "./ChangeResourceFilenameDialog"; +import showPreviewImageDialog from "./PreviewImageDialog"; -interface ResourceItemDropdown { +interface Props { resource: Resource; - handleRenameBtnClick: (resource: Resource) => void; - handleDeleteResourceBtnClick: (resource: Resource) => void; - handlePreviewBtnClick: (resource: Resource) => void; - handleCopyResourceLinkBtnClick: (resource: Resource) => void; } -const ResourceItemDropdown = ({ - resource, - handlePreviewBtnClick, - handleCopyResourceLinkBtnClick, - handleRenameBtnClick, - handleDeleteResourceBtnClick, -}: ResourceItemDropdown) => { +const ResourceItemDropdown = ({ resource }: Props) => { const { t } = useTranslation(); + const resourceStore = useResourceStore(); + const resources = resourceStore.state.resources; + + const handlePreviewBtnClick = (resource: Resource) => { + const resourceUrl = getResourceUrl(resource); + if (resource.type.startsWith("image")) { + showPreviewImageDialog( + resources.filter((r) => r.type.startsWith("image")).map((r) => getResourceUrl(r)), + resources.findIndex((r) => r.id === resource.id) + ); + } else { + window.open(resourceUrl); + } + }; + + const handleCopyResourceLinkBtnClick = (resource: Resource) => { + const url = getResourceUrl(resource); + copy(url); + toast.success(t("message.succeed-copy-resource-link")); + }; + + const handleResetResourceLinkBtnClick = (resource: Resource) => { + showCommonDialog({ + title: "Reset resource link", + content: "Are you sure to reset the resource link?", + style: "warning", + dialogName: "reset-resource-link-dialog", + onConfirm: async () => { + await resourceStore.patchResource({ + id: resource.id, + resetPublicId: true, + }); + }, + }); + }; + + const handleRenameBtnClick = (resource: Resource) => { + showChangeResourceFilenameDialog(resource.id, resource.filename); + }; + + const handleDeleteResourceBtnClick = (resource: Resource) => { + let warningText = t("resources.warning-text"); + if (resource.linkedMemoAmount > 0) { + warningText = warningText + `\n${t("resources.linked-amount")}: ${resource.linkedMemoAmount}`; + } + + showCommonDialog({ + title: t("resources.delete-resource"), + content: warningText, + style: "warning", + dialogName: "delete-resource-dialog", + onConfirm: async () => { + await resourceStore.deleteResourceById(resource.id); + }, + }); + }; return ( {t("resources.copy-link")} +