resource.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667
  1. package v1
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "net/url"
  10. "os"
  11. "path"
  12. "path/filepath"
  13. "regexp"
  14. "strconv"
  15. "strings"
  16. "sync/atomic"
  17. "time"
  18. "github.com/disintegration/imaging"
  19. "github.com/labstack/echo/v4"
  20. "github.com/pkg/errors"
  21. "go.uber.org/zap"
  22. "github.com/usememos/memos/common/log"
  23. "github.com/usememos/memos/common/util"
  24. "github.com/usememos/memos/plugin/storage/s3"
  25. "github.com/usememos/memos/store"
  26. )
  27. type Resource struct {
  28. ID int32 `json:"id"`
  29. // Standard fields
  30. CreatorID int32 `json:"creatorId"`
  31. CreatedTs int64 `json:"createdTs"`
  32. UpdatedTs int64 `json:"updatedTs"`
  33. // Domain specific fields
  34. Filename string `json:"filename"`
  35. Blob []byte `json:"-"`
  36. InternalPath string `json:"-"`
  37. ExternalLink string `json:"externalLink"`
  38. Type string `json:"type"`
  39. Size int64 `json:"size"`
  40. }
  41. type CreateResourceRequest struct {
  42. Filename string `json:"filename"`
  43. ExternalLink string `json:"externalLink"`
  44. Type string `json:"type"`
  45. }
  46. type FindResourceRequest struct {
  47. ID *int32 `json:"id"`
  48. CreatorID *int32 `json:"creatorId"`
  49. Filename *string `json:"filename"`
  50. }
  51. type UpdateResourceRequest struct {
  52. Filename *string `json:"filename"`
  53. }
  54. const (
  55. // The upload memory buffer is 32 MiB.
  56. // It should be kept low, so RAM usage doesn't get out of control.
  57. // This is unrelated to maximum upload size limit, which is now set through system setting.
  58. maxUploadBufferSizeBytes = 32 << 20
  59. MebiByte = 1024 * 1024
  60. // thumbnailImagePath is the directory to store image thumbnails.
  61. thumbnailImagePath = ".thumbnail_cache"
  62. )
  63. var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
  64. func (s *APIV1Service) registerResourceRoutes(g *echo.Group) {
  65. g.GET("/resource", s.GetResourceList)
  66. g.POST("/resource", s.CreateResource)
  67. g.POST("/resource/blob", s.UploadResource)
  68. g.PATCH("/resource/:resourceId", s.UpdateResource)
  69. g.DELETE("/resource/:resourceId", s.DeleteResource)
  70. }
  71. func (s *APIV1Service) registerResourcePublicRoutes(g *echo.Group) {
  72. g.GET("/r/:resourceId", s.streamResource)
  73. g.GET("/r/:resourceId/*", s.streamResource)
  74. }
  75. // GetResourceList godoc
  76. //
  77. // @Summary Get a list of resources
  78. // @Tags resource
  79. // @Produce json
  80. // @Param limit query int false "Limit"
  81. // @Param offset query int false "Offset"
  82. // @Success 200 {object} []store.Resource "Resource list"
  83. // @Failure 401 {object} nil "Missing user in session"
  84. // @Failure 500 {object} nil "Failed to fetch resource list"
  85. // @Router /api/v1/resource [GET]
  86. func (s *APIV1Service) GetResourceList(c echo.Context) error {
  87. ctx := c.Request().Context()
  88. userID, ok := c.Get(userIDContextKey).(int32)
  89. if !ok {
  90. return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
  91. }
  92. find := &store.FindResource{
  93. CreatorID: &userID,
  94. }
  95. if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
  96. find.Limit = &limit
  97. }
  98. if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
  99. find.Offset = &offset
  100. }
  101. list, err := s.Store.ListResources(ctx, find)
  102. if err != nil {
  103. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
  104. }
  105. resourceMessageList := []*Resource{}
  106. for _, resource := range list {
  107. resourceMessageList = append(resourceMessageList, convertResourceFromStore(resource))
  108. }
  109. return c.JSON(http.StatusOK, resourceMessageList)
  110. }
  111. // CreateResource godoc
  112. //
  113. // @Summary Create resource
  114. // @Tags resource
  115. // @Accept json
  116. // @Produce json
  117. // @Param body body CreateResourceRequest true "Request object."
  118. // @Success 200 {object} store.Resource "Created resource"
  119. // @Failure 400 {object} nil "Malformatted post resource request | Invalid external link | Invalid external link scheme | Failed to request %s | Failed to read %s | Failed to read mime from %s"
  120. // @Failure 401 {object} nil "Missing user in session"
  121. // @Failure 500 {object} nil "Failed to save resource | Failed to create resource | Failed to create activity"
  122. // @Router /api/v1/resource [POST]
  123. func (s *APIV1Service) CreateResource(c echo.Context) error {
  124. ctx := c.Request().Context()
  125. userID, ok := c.Get(userIDContextKey).(int32)
  126. if !ok {
  127. return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
  128. }
  129. request := &CreateResourceRequest{}
  130. if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
  131. return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post resource request").SetInternal(err)
  132. }
  133. create := &store.Resource{
  134. CreatorID: userID,
  135. Filename: request.Filename,
  136. ExternalLink: request.ExternalLink,
  137. Type: request.Type,
  138. }
  139. if request.ExternalLink != "" {
  140. // Only allow those external links scheme with http/https
  141. linkURL, err := url.Parse(request.ExternalLink)
  142. if err != nil {
  143. return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link").SetInternal(err)
  144. }
  145. if linkURL.Scheme != "http" && linkURL.Scheme != "https" {
  146. return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link scheme")
  147. }
  148. }
  149. resource, err := s.Store.CreateResource(ctx, create)
  150. if err != nil {
  151. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
  152. }
  153. if err := s.createResourceCreateActivity(ctx, resource); err != nil {
  154. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
  155. }
  156. return c.JSON(http.StatusOK, convertResourceFromStore(resource))
  157. }
  158. // UploadResource godoc
  159. //
  160. // @Summary Upload resource
  161. // @Tags resource
  162. // @Accept multipart/form-data
  163. // @Produce json
  164. // @Param file formData file true "File to upload"
  165. // @Success 200 {object} store.Resource "Created resource"
  166. // @Failure 400 {object} nil "Upload file not found | File size exceeds allowed limit of %d MiB | Failed to parse upload data"
  167. // @Failure 401 {object} nil "Missing user in session"
  168. // @Failure 500 {object} nil "Failed to get uploading file | Failed to open file | Failed to save resource | Failed to create resource | Failed to create activity"
  169. // @Router /api/v1/resource/blob [POST]
  170. func (s *APIV1Service) UploadResource(c echo.Context) error {
  171. ctx := c.Request().Context()
  172. userID, ok := c.Get(userIDContextKey).(int32)
  173. if !ok {
  174. return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
  175. }
  176. // This is the backend default max upload size limit.
  177. maxUploadSetting := s.Store.GetSystemSettingValueWithDefault(&ctx, SystemSettingMaxUploadSizeMiBName.String(), "32")
  178. var settingMaxUploadSizeBytes int
  179. if settingMaxUploadSizeMiB, err := strconv.Atoi(maxUploadSetting); err == nil {
  180. settingMaxUploadSizeBytes = settingMaxUploadSizeMiB * MebiByte
  181. } else {
  182. log.Warn("Failed to parse max upload size", zap.Error(err))
  183. settingMaxUploadSizeBytes = 0
  184. }
  185. file, err := c.FormFile("file")
  186. if err != nil {
  187. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get uploading file").SetInternal(err)
  188. }
  189. if file == nil {
  190. return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err)
  191. }
  192. if file.Size > int64(settingMaxUploadSizeBytes) {
  193. message := fmt.Sprintf("File size exceeds allowed limit of %d MiB", settingMaxUploadSizeBytes/MebiByte)
  194. return echo.NewHTTPError(http.StatusBadRequest, message).SetInternal(err)
  195. }
  196. if err := c.Request().ParseMultipartForm(maxUploadBufferSizeBytes); err != nil {
  197. return echo.NewHTTPError(http.StatusBadRequest, "Failed to parse upload data").SetInternal(err)
  198. }
  199. sourceFile, err := file.Open()
  200. if err != nil {
  201. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err)
  202. }
  203. defer sourceFile.Close()
  204. create := &store.Resource{
  205. CreatorID: userID,
  206. Filename: file.Filename,
  207. Type: file.Header.Get("Content-Type"),
  208. Size: file.Size,
  209. }
  210. err = SaveResourceBlob(ctx, s.Store, create, sourceFile)
  211. if err != nil {
  212. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save resource").SetInternal(err)
  213. }
  214. resource, err := s.Store.CreateResource(ctx, create)
  215. if err != nil {
  216. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
  217. }
  218. if err := s.createResourceCreateActivity(ctx, resource); err != nil {
  219. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
  220. }
  221. return c.JSON(http.StatusOK, convertResourceFromStore(resource))
  222. }
  223. // DeleteResource godoc
  224. //
  225. // @Summary Delete a resource
  226. // @Tags resource
  227. // @Produce json
  228. // @Param resourceId path int true "Resource ID"
  229. // @Success 200 {boolean} true "Resource deleted"
  230. // @Failure 400 {object} nil "ID is not a number: %s"
  231. // @Failure 401 {object} nil "Missing user in session"
  232. // @Failure 404 {object} nil "Resource not found: %d"
  233. // @Failure 500 {object} nil "Failed to find resource | Failed to delete resource"
  234. // @Router /api/v1/resource/{resourceId} [DELETE]
  235. func (s *APIV1Service) DeleteResource(c echo.Context) error {
  236. ctx := c.Request().Context()
  237. userID, ok := c.Get(userIDContextKey).(int32)
  238. if !ok {
  239. return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
  240. }
  241. resourceID, err := util.ConvertStringToInt32(c.Param("resourceId"))
  242. if err != nil {
  243. return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
  244. }
  245. resource, err := s.Store.GetResource(ctx, &store.FindResource{
  246. ID: &resourceID,
  247. CreatorID: &userID,
  248. })
  249. if err != nil {
  250. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
  251. }
  252. if resource == nil {
  253. return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
  254. }
  255. if resource.InternalPath != "" {
  256. if err := os.Remove(resource.InternalPath); err != nil {
  257. log.Warn(fmt.Sprintf("failed to delete local file with path %s", resource.InternalPath), zap.Error(err))
  258. }
  259. }
  260. ext := filepath.Ext(resource.Filename)
  261. thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext))
  262. if err := os.Remove(thumbnailPath); err != nil {
  263. log.Warn(fmt.Sprintf("failed to delete local thumbnail with path %s", thumbnailPath), zap.Error(err))
  264. }
  265. if err := s.Store.DeleteResource(ctx, &store.DeleteResource{
  266. ID: resourceID,
  267. }); err != nil {
  268. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
  269. }
  270. return c.JSON(http.StatusOK, true)
  271. }
  272. // UpdateResource godoc
  273. //
  274. // @Summary Update a resource
  275. // @Tags resource
  276. // @Produce json
  277. // @Param resourceId path int true "Resource ID"
  278. // @Param patch body UpdateResourceRequest true "Patch resource request"
  279. // @Success 200 {object} store.Resource "Updated resource"
  280. // @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch resource request"
  281. // @Failure 401 {object} nil "Missing user in session | Unauthorized"
  282. // @Failure 404 {object} nil "Resource not found: %d"
  283. // @Failure 500 {object} nil "Failed to find resource | Failed to patch resource"
  284. // @Router /api/v1/resource/{resourceId} [PATCH]
  285. func (s *APIV1Service) UpdateResource(c echo.Context) error {
  286. ctx := c.Request().Context()
  287. userID, ok := c.Get(userIDContextKey).(int32)
  288. if !ok {
  289. return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
  290. }
  291. resourceID, err := util.ConvertStringToInt32(c.Param("resourceId"))
  292. if err != nil {
  293. return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
  294. }
  295. resource, err := s.Store.GetResource(ctx, &store.FindResource{
  296. ID: &resourceID,
  297. })
  298. if err != nil {
  299. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
  300. }
  301. if resource == nil {
  302. return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
  303. }
  304. if resource.CreatorID != userID {
  305. return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
  306. }
  307. request := &UpdateResourceRequest{}
  308. if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
  309. return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
  310. }
  311. currentTs := time.Now().Unix()
  312. update := &store.UpdateResource{
  313. ID: resourceID,
  314. UpdatedTs: &currentTs,
  315. }
  316. if request.Filename != nil && *request.Filename != "" {
  317. update.Filename = request.Filename
  318. }
  319. resource, err = s.Store.UpdateResource(ctx, update)
  320. if err != nil {
  321. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch resource").SetInternal(err)
  322. }
  323. return c.JSON(http.StatusOK, convertResourceFromStore(resource))
  324. }
  325. // streamResource godoc
  326. //
  327. // @Summary Stream a resource
  328. // @Description *Swagger UI may have problems displaying other file types than images
  329. // @Tags resource
  330. // @Produce octet-stream
  331. // @Param resourceId path int true "Resource ID"
  332. // @Param thumbnail query int false "Thumbnail"
  333. // @Success 200 {object} nil "Requested resource"
  334. // @Failure 400 {object} nil "ID is not a number: %s | Failed to get resource visibility"
  335. // @Failure 401 {object} nil "Resource visibility not match"
  336. // @Failure 404 {object} nil "Resource not found: %d"
  337. // @Failure 500 {object} nil "Failed to find resource by ID: %v | Failed to open the local resource: %s | Failed to read the local resource: %s"
  338. // @Router /o/r/{resourceId} [GET]
  339. func (s *APIV1Service) streamResource(c echo.Context) error {
  340. ctx := c.Request().Context()
  341. resourceID, err := util.ConvertStringToInt32(c.Param("resourceId"))
  342. if err != nil {
  343. return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
  344. }
  345. resource, err := s.Store.GetResource(ctx, &store.FindResource{
  346. ID: &resourceID,
  347. GetBlob: true,
  348. })
  349. if err != nil {
  350. return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err)
  351. }
  352. if resource == nil {
  353. return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
  354. }
  355. // Check the related memo visibility.
  356. if resource.MemoID != nil {
  357. memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
  358. ID: resource.MemoID,
  359. })
  360. if err != nil {
  361. return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", resource.MemoID)).SetInternal(err)
  362. }
  363. if memo != nil && memo.Visibility != store.Public {
  364. userID, ok := c.Get(userIDContextKey).(int32)
  365. if !ok || (memo.Visibility == store.Private && userID != resource.CreatorID) {
  366. return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match")
  367. }
  368. }
  369. }
  370. blob := resource.Blob
  371. if resource.InternalPath != "" {
  372. resourcePath := resource.InternalPath
  373. src, err := os.Open(resourcePath)
  374. if err != nil {
  375. return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resourcePath)).SetInternal(err)
  376. }
  377. defer src.Close()
  378. blob, err = io.ReadAll(src)
  379. if err != nil {
  380. return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resourcePath)).SetInternal(err)
  381. }
  382. }
  383. if c.QueryParam("thumbnail") == "1" && util.HasPrefixes(resource.Type, "image/png", "image/jpeg") {
  384. ext := filepath.Ext(resource.Filename)
  385. thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext))
  386. thumbnailBlob, err := getOrGenerateThumbnailImage(blob, thumbnailPath)
  387. if err != nil {
  388. log.Warn(fmt.Sprintf("failed to get or generate local thumbnail with path %s", thumbnailPath), zap.Error(err))
  389. } else {
  390. blob = thumbnailBlob
  391. }
  392. }
  393. c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
  394. c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'")
  395. resourceType := strings.ToLower(resource.Type)
  396. if strings.HasPrefix(resourceType, "text") {
  397. resourceType = echo.MIMETextPlainCharsetUTF8
  398. } else if strings.HasPrefix(resourceType, "video") || strings.HasPrefix(resourceType, "audio") {
  399. http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(blob))
  400. return nil
  401. }
  402. c.Response().Writer.Header().Set("Content-Disposition", fmt.Sprintf(`filename="%s"`, resource.Filename))
  403. return c.Stream(http.StatusOK, resourceType, bytes.NewReader(blob))
  404. }
  405. func (s *APIV1Service) createResourceCreateActivity(ctx context.Context, resource *store.Resource) error {
  406. payload := ActivityResourceCreatePayload{
  407. Filename: resource.Filename,
  408. Type: resource.Type,
  409. Size: resource.Size,
  410. }
  411. payloadBytes, err := json.Marshal(payload)
  412. if err != nil {
  413. return errors.Wrap(err, "failed to marshal activity payload")
  414. }
  415. activity, err := s.Store.CreateActivity(ctx, &store.Activity{
  416. CreatorID: resource.CreatorID,
  417. Type: ActivityResourceCreate.String(),
  418. Level: ActivityInfo.String(),
  419. Payload: string(payloadBytes),
  420. })
  421. if err != nil || activity == nil {
  422. return errors.Wrap(err, "failed to create activity")
  423. }
  424. return err
  425. }
  426. func replacePathTemplate(path, filename string) string {
  427. t := time.Now()
  428. path = fileKeyPattern.ReplaceAllStringFunc(path, func(s string) string {
  429. switch s {
  430. case "{filename}":
  431. return filename
  432. case "{timestamp}":
  433. return fmt.Sprintf("%d", t.Unix())
  434. case "{year}":
  435. return fmt.Sprintf("%d", t.Year())
  436. case "{month}":
  437. return fmt.Sprintf("%02d", t.Month())
  438. case "{day}":
  439. return fmt.Sprintf("%02d", t.Day())
  440. case "{hour}":
  441. return fmt.Sprintf("%02d", t.Hour())
  442. case "{minute}":
  443. return fmt.Sprintf("%02d", t.Minute())
  444. case "{second}":
  445. return fmt.Sprintf("%02d", t.Second())
  446. }
  447. return s
  448. })
  449. return path
  450. }
  451. var availableGeneratorAmount int32 = 32
  452. func getOrGenerateThumbnailImage(srcBlob []byte, dstPath string) ([]byte, error) {
  453. if _, err := os.Stat(dstPath); err != nil {
  454. if !errors.Is(err, os.ErrNotExist) {
  455. return nil, errors.Wrap(err, "failed to check thumbnail image stat")
  456. }
  457. if atomic.LoadInt32(&availableGeneratorAmount) <= 0 {
  458. return nil, errors.New("not enough available generator amount")
  459. }
  460. atomic.AddInt32(&availableGeneratorAmount, -1)
  461. defer func() {
  462. atomic.AddInt32(&availableGeneratorAmount, 1)
  463. }()
  464. reader := bytes.NewReader(srcBlob)
  465. src, err := imaging.Decode(reader, imaging.AutoOrientation(true))
  466. if err != nil {
  467. return nil, errors.Wrap(err, "failed to decode thumbnail image")
  468. }
  469. thumbnailImage := imaging.Resize(src, 512, 0, imaging.Lanczos)
  470. dstDir := path.Dir(dstPath)
  471. if err := os.MkdirAll(dstDir, os.ModePerm); err != nil {
  472. return nil, errors.Wrap(err, "failed to create thumbnail dir")
  473. }
  474. if err := imaging.Save(thumbnailImage, dstPath); err != nil {
  475. return nil, errors.Wrap(err, "failed to resize thumbnail image")
  476. }
  477. }
  478. dstFile, err := os.Open(dstPath)
  479. if err != nil {
  480. return nil, errors.Wrap(err, "failed to open the local resource")
  481. }
  482. defer dstFile.Close()
  483. dstBlob, err := io.ReadAll(dstFile)
  484. if err != nil {
  485. return nil, errors.Wrap(err, "failed to read the local resource")
  486. }
  487. return dstBlob, nil
  488. }
  489. func convertResourceFromStore(resource *store.Resource) *Resource {
  490. return &Resource{
  491. ID: resource.ID,
  492. CreatorID: resource.CreatorID,
  493. CreatedTs: resource.CreatedTs,
  494. UpdatedTs: resource.UpdatedTs,
  495. Filename: resource.Filename,
  496. Blob: resource.Blob,
  497. InternalPath: resource.InternalPath,
  498. ExternalLink: resource.ExternalLink,
  499. Type: resource.Type,
  500. Size: resource.Size,
  501. }
  502. }
  503. // SaveResourceBlob save the blob of resource based on the storage config
  504. //
  505. // Depend on the storage config, some fields of *store.ResourceCreate will be changed:
  506. // 1. *DatabaseStorage*: `create.Blob`.
  507. // 2. *LocalStorage*: `create.InternalPath`.
  508. // 3. Others( external service): `create.ExternalLink`.
  509. func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resource, r io.Reader) error {
  510. systemSettingStorageServiceID, err := s.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingStorageServiceIDName.String()})
  511. if err != nil {
  512. return errors.Wrap(err, "Failed to find SystemSettingStorageServiceIDName")
  513. }
  514. storageServiceID := DefaultStorage
  515. if systemSettingStorageServiceID != nil {
  516. err = json.Unmarshal([]byte(systemSettingStorageServiceID.Value), &storageServiceID)
  517. if err != nil {
  518. return errors.Wrap(err, "Failed to unmarshal storage service id")
  519. }
  520. }
  521. // `DatabaseStorage` means store blob into database
  522. if storageServiceID == DatabaseStorage {
  523. fileBytes, err := io.ReadAll(r)
  524. if err != nil {
  525. return errors.Wrap(err, "Failed to read file")
  526. }
  527. create.Blob = fileBytes
  528. return nil
  529. } else if storageServiceID == LocalStorage {
  530. // `LocalStorage` means save blob into local disk
  531. systemSettingLocalStoragePath, err := s.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingLocalStoragePathName.String()})
  532. if err != nil {
  533. return errors.Wrap(err, "Failed to find SystemSettingLocalStoragePathName")
  534. }
  535. localStoragePath := "assets/{timestamp}_{filename}"
  536. if systemSettingLocalStoragePath != nil && systemSettingLocalStoragePath.Value != "" {
  537. err = json.Unmarshal([]byte(systemSettingLocalStoragePath.Value), &localStoragePath)
  538. if err != nil {
  539. return errors.Wrap(err, "Failed to unmarshal SystemSettingLocalStoragePathName")
  540. }
  541. }
  542. filePath := filepath.FromSlash(localStoragePath)
  543. if !strings.Contains(filePath, "{filename}") {
  544. filePath = filepath.Join(filePath, "{filename}")
  545. }
  546. filePath = filepath.Join(s.Profile.Data, replacePathTemplate(filePath, create.Filename))
  547. dir := filepath.Dir(filePath)
  548. if err = os.MkdirAll(dir, os.ModePerm); err != nil {
  549. return errors.Wrap(err, "Failed to create directory")
  550. }
  551. dst, err := os.Create(filePath)
  552. if err != nil {
  553. return errors.Wrap(err, "Failed to create file")
  554. }
  555. defer dst.Close()
  556. _, err = io.Copy(dst, r)
  557. if err != nil {
  558. return errors.Wrap(err, "Failed to copy file")
  559. }
  560. create.InternalPath = filePath
  561. return nil
  562. }
  563. // Others: store blob into external service, such as S3
  564. storage, err := s.GetStorage(ctx, &store.FindStorage{ID: &storageServiceID})
  565. if err != nil {
  566. return errors.Wrap(err, "Failed to find StorageServiceID")
  567. }
  568. if storage == nil {
  569. return errors.Errorf("Storage %d not found", storageServiceID)
  570. }
  571. storageMessage, err := ConvertStorageFromStore(storage)
  572. if err != nil {
  573. return errors.Wrap(err, "Failed to ConvertStorageFromStore")
  574. }
  575. if storageMessage.Type != StorageS3 {
  576. return errors.Errorf("Unsupported storage type: %s", storageMessage.Type)
  577. }
  578. s3Config := storageMessage.Config.S3Config
  579. s3Client, err := s3.NewClient(ctx, &s3.Config{
  580. AccessKey: s3Config.AccessKey,
  581. SecretKey: s3Config.SecretKey,
  582. EndPoint: s3Config.EndPoint,
  583. Region: s3Config.Region,
  584. Bucket: s3Config.Bucket,
  585. URLPrefix: s3Config.URLPrefix,
  586. URLSuffix: s3Config.URLSuffix,
  587. })
  588. if err != nil {
  589. return errors.Wrap(err, "Failed to create s3 client")
  590. }
  591. filePath := s3Config.Path
  592. if !strings.Contains(filePath, "{filename}") {
  593. filePath = filepath.Join(filePath, "{filename}")
  594. }
  595. filePath = replacePathTemplate(filePath, create.Filename)
  596. link, err := s3Client.UploadFile(ctx, filePath, create.Type, r)
  597. if err != nil {
  598. return errors.Wrap(err, "Failed to upload via s3 client")
  599. }
  600. create.ExternalLink = link
  601. return nil
  602. }