resource_service.go 15 KB

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