resource_service.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. package v1
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/binary"
  6. "fmt"
  7. "io"
  8. "log/slog"
  9. "os"
  10. "path/filepath"
  11. "regexp"
  12. "strings"
  13. "time"
  14. "github.com/disintegration/imaging"
  15. "github.com/lithammer/shortuuid/v4"
  16. "github.com/pkg/errors"
  17. "google.golang.org/genproto/googleapis/api/httpbody"
  18. "google.golang.org/grpc/codes"
  19. "google.golang.org/grpc/status"
  20. "google.golang.org/protobuf/types/known/emptypb"
  21. "google.golang.org/protobuf/types/known/timestamppb"
  22. "github.com/usememos/memos/internal/util"
  23. "github.com/usememos/memos/plugin/storage/s3"
  24. v1pb "github.com/usememos/memos/proto/gen/api/v1"
  25. storepb "github.com/usememos/memos/proto/gen/store"
  26. "github.com/usememos/memos/store"
  27. )
  28. const (
  29. // The upload memory buffer is 32 MiB.
  30. // It should be kept low, so RAM usage doesn't get out of control.
  31. // This is unrelated to maximum upload size limit, which is now set through system setting.
  32. MaxUploadBufferSizeBytes = 32 << 20
  33. MebiByte = 1024 * 1024
  34. // ThumbnailCacheFolder is the folder name where the thumbnail images are stored.
  35. ThumbnailCacheFolder = ".thumbnail_cache"
  36. )
  37. var SupportedThumbnailMimeTypes = []string{
  38. "image/png",
  39. "image/jpeg",
  40. }
  41. func (s *APIV1Service) CreateResource(ctx context.Context, request *v1pb.CreateResourceRequest) (*v1pb.Resource, error) {
  42. user, err := s.GetCurrentUser(ctx)
  43. if err != nil {
  44. return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
  45. }
  46. create := &store.Resource{
  47. UID: shortuuid.New(),
  48. CreatorID: user.ID,
  49. Filename: request.Resource.Filename,
  50. Type: request.Resource.Type,
  51. }
  52. workspaceStorageSetting, err := s.Store.GetWorkspaceStorageSetting(ctx)
  53. if err != nil {
  54. return nil, status.Errorf(codes.Internal, "failed to get workspace storage setting: %v", err)
  55. }
  56. size := binary.Size(request.Resource.Content)
  57. uploadSizeLimit := int(workspaceStorageSetting.UploadSizeLimitMb) * MebiByte
  58. if uploadSizeLimit == 0 {
  59. uploadSizeLimit = MaxUploadBufferSizeBytes
  60. }
  61. if size > uploadSizeLimit {
  62. return nil, status.Errorf(codes.InvalidArgument, "file size exceeds the limit")
  63. }
  64. create.Size = int64(size)
  65. create.Blob = request.Resource.Content
  66. if err := SaveResourceBlob(ctx, s.Store, create); err != nil {
  67. return nil, status.Errorf(codes.Internal, "failed to save resource blob: %v", err)
  68. }
  69. if request.Resource.Memo != nil {
  70. memoID, err := ExtractMemoIDFromName(*request.Resource.Memo)
  71. if err != nil {
  72. return nil, status.Errorf(codes.InvalidArgument, "invalid memo id: %v", err)
  73. }
  74. create.MemoID = &memoID
  75. }
  76. resource, err := s.Store.CreateResource(ctx, create)
  77. if err != nil {
  78. return nil, status.Errorf(codes.Internal, "failed to create resource: %v", err)
  79. }
  80. return s.convertResourceFromStore(ctx, resource), nil
  81. }
  82. func (s *APIV1Service) ListResources(ctx context.Context, _ *v1pb.ListResourcesRequest) (*v1pb.ListResourcesResponse, error) {
  83. user, err := s.GetCurrentUser(ctx)
  84. if err != nil {
  85. return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
  86. }
  87. resources, err := s.Store.ListResources(ctx, &store.FindResource{
  88. CreatorID: &user.ID,
  89. })
  90. if err != nil {
  91. return nil, status.Errorf(codes.Internal, "failed to list resources: %v", err)
  92. }
  93. response := &v1pb.ListResourcesResponse{}
  94. for _, resource := range resources {
  95. response.Resources = append(response.Resources, s.convertResourceFromStore(ctx, resource))
  96. }
  97. return response, nil
  98. }
  99. func (s *APIV1Service) GetResource(ctx context.Context, request *v1pb.GetResourceRequest) (*v1pb.Resource, error) {
  100. id, err := ExtractResourceIDFromName(request.Name)
  101. if err != nil {
  102. return nil, status.Errorf(codes.InvalidArgument, "invalid resource id: %v", err)
  103. }
  104. resource, err := s.Store.GetResource(ctx, &store.FindResource{
  105. ID: &id,
  106. })
  107. if err != nil {
  108. return nil, status.Errorf(codes.Internal, "failed to get resource: %v", err)
  109. }
  110. if resource == nil {
  111. return nil, status.Errorf(codes.NotFound, "resource not found")
  112. }
  113. return s.convertResourceFromStore(ctx, resource), nil
  114. }
  115. //nolint:all
  116. func (s *APIV1Service) GetResourceByUid(ctx context.Context, request *v1pb.GetResourceByUidRequest) (*v1pb.Resource, error) {
  117. resource, err := s.Store.GetResource(ctx, &store.FindResource{
  118. UID: &request.Uid,
  119. })
  120. if err != nil {
  121. return nil, status.Errorf(codes.Internal, "failed to get resource: %v", err)
  122. }
  123. if resource == nil {
  124. return nil, status.Errorf(codes.NotFound, "resource not found")
  125. }
  126. return s.convertResourceFromStore(ctx, resource), nil
  127. }
  128. func (s *APIV1Service) GetResourceBinary(ctx context.Context, request *v1pb.GetResourceBinaryRequest) (*httpbody.HttpBody, error) {
  129. resourceFind := &store.FindResource{
  130. GetBlob: true,
  131. }
  132. if request.Name != "" {
  133. id, err := ExtractResourceIDFromName(request.Name)
  134. if err != nil {
  135. return nil, status.Errorf(codes.InvalidArgument, "invalid resource id: %v", err)
  136. }
  137. resourceFind.ID = &id
  138. }
  139. resource, err := s.Store.GetResource(ctx, resourceFind)
  140. if err != nil {
  141. return nil, status.Errorf(codes.Internal, "failed to get resource: %v", err)
  142. }
  143. if resource == nil {
  144. return nil, status.Errorf(codes.NotFound, "resource not found")
  145. }
  146. // Check the related memo visibility.
  147. if resource.MemoID != nil {
  148. memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
  149. ID: resource.MemoID,
  150. })
  151. if err != nil {
  152. return nil, status.Errorf(codes.Internal, "failed to find memo by ID: %v", resource.MemoID)
  153. }
  154. if memo != nil && memo.Visibility != store.Public {
  155. user, err := s.GetCurrentUser(ctx)
  156. if err != nil {
  157. return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
  158. }
  159. if user == nil {
  160. return nil, status.Errorf(codes.Unauthenticated, "unauthorized access")
  161. }
  162. if memo.Visibility == store.Private && user.ID != resource.CreatorID {
  163. return nil, status.Errorf(codes.Unauthenticated, "unauthorized access")
  164. }
  165. }
  166. }
  167. if request.Thumbnail && util.HasPrefixes(resource.Type, SupportedThumbnailMimeTypes...) {
  168. thumbnailBlob, err := s.getOrGenerateThumbnail(resource)
  169. if err != nil {
  170. // thumbnail failures are logged as warnings and not cosidered critical failures as
  171. // a resource image can be used in its place.
  172. slog.Warn("failed to get resource thumbnail image", slog.Any("error", err))
  173. } else {
  174. return &httpbody.HttpBody{
  175. ContentType: resource.Type,
  176. Data: thumbnailBlob,
  177. }, nil
  178. }
  179. }
  180. blob, err := s.GetResourceBlob(resource)
  181. if err != nil {
  182. return nil, status.Errorf(codes.Internal, "failed to get resource blob: %v", err)
  183. }
  184. contentType := resource.Type
  185. if strings.HasPrefix(contentType, "text/") {
  186. contentType += "; charset=utf-8"
  187. }
  188. return &httpbody.HttpBody{
  189. ContentType: contentType,
  190. Data: blob,
  191. }, nil
  192. }
  193. func (s *APIV1Service) UpdateResource(ctx context.Context, request *v1pb.UpdateResourceRequest) (*v1pb.Resource, error) {
  194. id, err := ExtractResourceIDFromName(request.Resource.Name)
  195. if err != nil {
  196. return nil, status.Errorf(codes.InvalidArgument, "invalid resource id: %v", err)
  197. }
  198. if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
  199. return nil, status.Errorf(codes.InvalidArgument, "update mask is required")
  200. }
  201. currentTs := time.Now().Unix()
  202. update := &store.UpdateResource{
  203. ID: id,
  204. UpdatedTs: &currentTs,
  205. }
  206. for _, field := range request.UpdateMask.Paths {
  207. if field == "filename" {
  208. update.Filename = &request.Resource.Filename
  209. } else if field == "memo" {
  210. if request.Resource.Memo == nil {
  211. return nil, status.Errorf(codes.InvalidArgument, "memo is required")
  212. }
  213. memoID, err := ExtractMemoIDFromName(*request.Resource.Memo)
  214. if err != nil {
  215. return nil, status.Errorf(codes.InvalidArgument, "invalid memo id: %v", err)
  216. }
  217. update.MemoID = &memoID
  218. }
  219. }
  220. if err := s.Store.UpdateResource(ctx, update); err != nil {
  221. return nil, status.Errorf(codes.Internal, "failed to update resource: %v", err)
  222. }
  223. return s.GetResource(ctx, &v1pb.GetResourceRequest{
  224. Name: request.Resource.Name,
  225. })
  226. }
  227. func (s *APIV1Service) DeleteResource(ctx context.Context, request *v1pb.DeleteResourceRequest) (*emptypb.Empty, error) {
  228. id, err := ExtractResourceIDFromName(request.Name)
  229. if err != nil {
  230. return nil, status.Errorf(codes.InvalidArgument, "invalid resource id: %v", err)
  231. }
  232. user, err := s.GetCurrentUser(ctx)
  233. if err != nil {
  234. return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
  235. }
  236. resource, err := s.Store.GetResource(ctx, &store.FindResource{
  237. ID: &id,
  238. CreatorID: &user.ID,
  239. })
  240. if err != nil {
  241. return nil, status.Errorf(codes.Internal, "failed to find resource: %v", err)
  242. }
  243. if resource == nil {
  244. return nil, status.Errorf(codes.NotFound, "resource not found")
  245. }
  246. // Delete the resource from the database.
  247. if err := s.Store.DeleteResource(ctx, &store.DeleteResource{
  248. ID: resource.ID,
  249. }); err != nil {
  250. return nil, status.Errorf(codes.Internal, "failed to delete resource: %v", err)
  251. }
  252. return &emptypb.Empty{}, nil
  253. }
  254. func (s *APIV1Service) convertResourceFromStore(ctx context.Context, resource *store.Resource) *v1pb.Resource {
  255. resourceMessage := &v1pb.Resource{
  256. Name: fmt.Sprintf("%s%d", ResourceNamePrefix, resource.ID),
  257. Uid: resource.UID,
  258. CreateTime: timestamppb.New(time.Unix(resource.CreatedTs, 0)),
  259. Filename: resource.Filename,
  260. Type: resource.Type,
  261. Size: resource.Size,
  262. }
  263. if resource.StorageType == storepb.ResourceStorageType_EXTERNAL || resource.StorageType == storepb.ResourceStorageType_S3 {
  264. resourceMessage.ExternalLink = resource.Reference
  265. }
  266. if resource.MemoID != nil {
  267. memo, _ := s.Store.GetMemo(ctx, &store.FindMemo{
  268. ID: resource.MemoID,
  269. })
  270. if memo != nil {
  271. memoName := fmt.Sprintf("%s%d", MemoNamePrefix, memo.ID)
  272. resourceMessage.Memo = &memoName
  273. }
  274. }
  275. return resourceMessage
  276. }
  277. // SaveResourceBlob save the blob of resource based on the storage config.
  278. func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resource) error {
  279. workspaceStorageSetting, err := s.GetWorkspaceStorageSetting(ctx)
  280. if err != nil {
  281. return errors.Wrap(err, "Failed to find workspace storage setting")
  282. }
  283. if workspaceStorageSetting.StorageType == storepb.WorkspaceStorageSetting_LOCAL {
  284. filepathTemplate := "assets/{timestamp}_{filename}"
  285. if workspaceStorageSetting.FilepathTemplate != "" {
  286. filepathTemplate = workspaceStorageSetting.FilepathTemplate
  287. }
  288. internalPath := filepathTemplate
  289. if !strings.Contains(internalPath, "{filename}") {
  290. internalPath = filepath.Join(internalPath, "{filename}")
  291. }
  292. internalPath = replaceFilenameWithPathTemplate(internalPath, create.Filename)
  293. internalPath = filepath.ToSlash(internalPath)
  294. // Ensure the directory exists.
  295. osPath := filepath.FromSlash(internalPath)
  296. if !filepath.IsAbs(osPath) {
  297. osPath = filepath.Join(s.Profile.Data, osPath)
  298. }
  299. dir := filepath.Dir(osPath)
  300. if err = os.MkdirAll(dir, os.ModePerm); err != nil {
  301. return errors.Wrap(err, "Failed to create directory")
  302. }
  303. dst, err := os.Create(osPath)
  304. if err != nil {
  305. return errors.Wrap(err, "Failed to create file")
  306. }
  307. defer dst.Close()
  308. // Write the blob to the file.
  309. if err := os.WriteFile(osPath, create.Blob, 0644); err != nil {
  310. return errors.Wrap(err, "Failed to write file")
  311. }
  312. create.Reference = internalPath
  313. create.Blob = nil
  314. create.StorageType = storepb.ResourceStorageType_LOCAL
  315. } else if workspaceStorageSetting.StorageType == storepb.WorkspaceStorageSetting_S3 {
  316. s3Config := workspaceStorageSetting.S3Config
  317. if s3Config == nil {
  318. return errors.Errorf("No actived external storage found")
  319. }
  320. s3Client, err := s3.NewClient(ctx, s3Config)
  321. if err != nil {
  322. return errors.Wrap(err, "Failed to create s3 client")
  323. }
  324. filepathTemplate := workspaceStorageSetting.FilepathTemplate
  325. if !strings.Contains(filepathTemplate, "{filename}") {
  326. filepathTemplate = filepath.Join(filepathTemplate, "{filename}")
  327. }
  328. filepathTemplate = replaceFilenameWithPathTemplate(filepathTemplate, create.Filename)
  329. key, err := s3Client.UploadObject(ctx, filepathTemplate, create.Type, bytes.NewReader(create.Blob))
  330. if err != nil {
  331. return errors.Wrap(err, "Failed to upload via s3 client")
  332. }
  333. presignURL, err := s3Client.PresignGetObject(ctx, key)
  334. if err != nil {
  335. return errors.Wrap(err, "Failed to presign via s3 client")
  336. }
  337. create.Reference = presignURL
  338. create.Blob = nil
  339. create.StorageType = storepb.ResourceStorageType_S3
  340. create.Payload = &storepb.ResourcePayload{
  341. Payload: &storepb.ResourcePayload_S3Object_{
  342. S3Object: &storepb.ResourcePayload_S3Object{
  343. S3Config: s3Config,
  344. Key: key,
  345. LastPresignedTime: timestamppb.New(time.Now()),
  346. },
  347. },
  348. }
  349. }
  350. return nil
  351. }
  352. func (s *APIV1Service) GetResourceBlob(resource *store.Resource) ([]byte, error) {
  353. blob := resource.Blob
  354. if resource.StorageType == storepb.ResourceStorageType_LOCAL {
  355. resourcePath := filepath.FromSlash(resource.Reference)
  356. if !filepath.IsAbs(resourcePath) {
  357. resourcePath = filepath.Join(s.Profile.Data, resourcePath)
  358. }
  359. file, err := os.Open(resourcePath)
  360. if err != nil {
  361. if os.IsNotExist(err) {
  362. return nil, errors.Wrap(err, "file not found")
  363. }
  364. return nil, errors.Wrap(err, "failed to open the file")
  365. }
  366. defer file.Close()
  367. blob, err = io.ReadAll(file)
  368. if err != nil {
  369. return nil, errors.Wrap(err, "failed to read the file")
  370. }
  371. }
  372. return blob, nil
  373. }
  374. const (
  375. // thumbnailRatio is the ratio of the thumbnail image.
  376. thumbnailRatio = 0.8
  377. )
  378. // getOrGenerateThumbnail returns the thumbnail image of the resource.
  379. func (s *APIV1Service) getOrGenerateThumbnail(resource *store.Resource) ([]byte, error) {
  380. thumbnailCacheFolder := filepath.Join(s.Profile.Data, ThumbnailCacheFolder)
  381. if err := os.MkdirAll(thumbnailCacheFolder, os.ModePerm); err != nil {
  382. return nil, errors.Wrap(err, "failed to create thumbnail cache folder")
  383. }
  384. filePath := filepath.Join(thumbnailCacheFolder, fmt.Sprintf("%d%s", resource.ID, filepath.Ext(resource.Filename)))
  385. if _, err := os.Stat(filePath); err != nil {
  386. if !os.IsNotExist(err) {
  387. return nil, errors.Wrap(err, "failed to check thumbnail image stat")
  388. }
  389. // If thumbnail image does not exist, generate and save the thumbnail image.
  390. blob, err := s.GetResourceBlob(resource)
  391. if err != nil {
  392. return nil, errors.Wrap(err, "failed to get resource blob")
  393. }
  394. img, err := imaging.Decode(bytes.NewReader(blob), imaging.AutoOrientation(true))
  395. if err != nil {
  396. return nil, errors.Wrap(err, "failed to decode thumbnail image")
  397. }
  398. thumbnailWidth := int(float64(img.Bounds().Dx()) * thumbnailRatio)
  399. // Resize the image to the thumbnailWidth.
  400. thumbnailImage := imaging.Resize(img, thumbnailWidth, 0, imaging.Lanczos)
  401. if err := imaging.Save(thumbnailImage, filePath); err != nil {
  402. return nil, errors.Wrap(err, "failed to save thumbnail file")
  403. }
  404. }
  405. thumbnailFile, err := os.Open(filePath)
  406. if err != nil {
  407. return nil, errors.Wrap(err, "failed to open thumbnail file")
  408. }
  409. defer thumbnailFile.Close()
  410. blob, err := io.ReadAll(thumbnailFile)
  411. if err != nil {
  412. return nil, errors.Wrap(err, "failed to read thumbnail file")
  413. }
  414. return blob, nil
  415. }
  416. var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
  417. func replaceFilenameWithPathTemplate(path, filename string) string {
  418. t := time.Now()
  419. path = fileKeyPattern.ReplaceAllStringFunc(path, func(s string) string {
  420. switch s {
  421. case "{filename}":
  422. return filename
  423. case "{timestamp}":
  424. return fmt.Sprintf("%d", t.Unix())
  425. case "{year}":
  426. return fmt.Sprintf("%d", t.Year())
  427. case "{month}":
  428. return fmt.Sprintf("%02d", t.Month())
  429. case "{day}":
  430. return fmt.Sprintf("%02d", t.Day())
  431. case "{hour}":
  432. return fmt.Sprintf("%02d", t.Hour())
  433. case "{minute}":
  434. return fmt.Sprintf("%02d", t.Minute())
  435. case "{second}":
  436. return fmt.Sprintf("%02d", t.Second())
  437. case "{uuid}":
  438. return util.GenUUID()
  439. }
  440. return s
  441. })
  442. return path
  443. }