resource.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. package v1
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "net/url"
  9. "os"
  10. "path/filepath"
  11. "regexp"
  12. "strconv"
  13. "strings"
  14. "time"
  15. "github.com/labstack/echo/v4"
  16. "github.com/pkg/errors"
  17. "go.uber.org/zap"
  18. "github.com/usememos/memos/internal/log"
  19. "github.com/usememos/memos/internal/util"
  20. "github.com/usememos/memos/plugin/storage/s3"
  21. "github.com/usememos/memos/store"
  22. )
  23. type Resource struct {
  24. ID int32 `json:"id"`
  25. // Standard fields
  26. CreatorID int32 `json:"creatorId"`
  27. CreatedTs int64 `json:"createdTs"`
  28. UpdatedTs int64 `json:"updatedTs"`
  29. // Domain specific fields
  30. Filename string `json:"filename"`
  31. Blob []byte `json:"-"`
  32. InternalPath string `json:"-"`
  33. ExternalLink string `json:"externalLink"`
  34. Type string `json:"type"`
  35. Size int64 `json:"size"`
  36. }
  37. type CreateResourceRequest struct {
  38. Filename string `json:"filename"`
  39. ExternalLink string `json:"externalLink"`
  40. Type string `json:"type"`
  41. }
  42. type FindResourceRequest struct {
  43. ID *int32 `json:"id"`
  44. CreatorID *int32 `json:"creatorId"`
  45. Filename *string `json:"filename"`
  46. }
  47. type UpdateResourceRequest struct {
  48. Filename *string `json:"filename"`
  49. }
  50. const (
  51. // The upload memory buffer is 32 MiB.
  52. // It should be kept low, so RAM usage doesn't get out of control.
  53. // This is unrelated to maximum upload size limit, which is now set through system setting.
  54. maxUploadBufferSizeBytes = 32 << 20
  55. MebiByte = 1024 * 1024
  56. )
  57. var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
  58. func (s *APIV1Service) registerResourceRoutes(g *echo.Group) {
  59. g.GET("/resource", s.GetResourceList)
  60. g.POST("/resource", s.CreateResource)
  61. g.POST("/resource/blob", s.UploadResource)
  62. g.PATCH("/resource/:resourceId", s.UpdateResource)
  63. g.DELETE("/resource/:resourceId", s.DeleteResource)
  64. }
  65. // GetResourceList godoc
  66. //
  67. // @Summary Get a list of resources
  68. // @Tags resource
  69. // @Produce json
  70. // @Param limit query int false "Limit"
  71. // @Param offset query int false "Offset"
  72. // @Success 200 {object} []store.Resource "Resource list"
  73. // @Failure 401 {object} nil "Missing user in session"
  74. // @Failure 500 {object} nil "Failed to fetch resource list"
  75. // @Router /api/v1/resource [GET]
  76. func (s *APIV1Service) GetResourceList(c echo.Context) error {
  77. ctx := c.Request().Context()
  78. userID, ok := c.Get(userIDContextKey).(int32)
  79. if !ok {
  80. return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
  81. }
  82. find := &store.FindResource{
  83. CreatorID: &userID,
  84. }
  85. if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
  86. find.Limit = &limit
  87. }
  88. if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
  89. find.Offset = &offset
  90. }
  91. list, err := s.Store.ListResources(ctx, find)
  92. if err != nil {
  93. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
  94. }
  95. resourceMessageList := []*Resource{}
  96. for _, resource := range list {
  97. resourceMessageList = append(resourceMessageList, convertResourceFromStore(resource))
  98. }
  99. return c.JSON(http.StatusOK, resourceMessageList)
  100. }
  101. // CreateResource godoc
  102. //
  103. // @Summary Create resource
  104. // @Tags resource
  105. // @Accept json
  106. // @Produce json
  107. // @Param body body CreateResourceRequest true "Request object."
  108. // @Success 200 {object} store.Resource "Created resource"
  109. // @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"
  110. // @Failure 401 {object} nil "Missing user in session"
  111. // @Failure 500 {object} nil "Failed to save resource | Failed to create resource | Failed to create activity"
  112. // @Router /api/v1/resource [POST]
  113. func (s *APIV1Service) CreateResource(c echo.Context) error {
  114. ctx := c.Request().Context()
  115. userID, ok := c.Get(userIDContextKey).(int32)
  116. if !ok {
  117. return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
  118. }
  119. request := &CreateResourceRequest{}
  120. if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
  121. return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post resource request").SetInternal(err)
  122. }
  123. create := &store.Resource{
  124. CreatorID: userID,
  125. Filename: request.Filename,
  126. ExternalLink: request.ExternalLink,
  127. Type: request.Type,
  128. }
  129. if request.ExternalLink != "" {
  130. // Only allow those external links scheme with http/https
  131. linkURL, err := url.Parse(request.ExternalLink)
  132. if err != nil {
  133. return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link").SetInternal(err)
  134. }
  135. if linkURL.Scheme != "http" && linkURL.Scheme != "https" {
  136. return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link scheme")
  137. }
  138. }
  139. resource, err := s.Store.CreateResource(ctx, create)
  140. if err != nil {
  141. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
  142. }
  143. return c.JSON(http.StatusOK, convertResourceFromStore(resource))
  144. }
  145. // UploadResource godoc
  146. //
  147. // @Summary Upload resource
  148. // @Tags resource
  149. // @Accept multipart/form-data
  150. // @Produce json
  151. // @Param file formData file true "File to upload"
  152. // @Success 200 {object} store.Resource "Created resource"
  153. // @Failure 400 {object} nil "Upload file not found | File size exceeds allowed limit of %d MiB | Failed to parse upload data"
  154. // @Failure 401 {object} nil "Missing user in session"
  155. // @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"
  156. // @Router /api/v1/resource/blob [POST]
  157. func (s *APIV1Service) UploadResource(c echo.Context) error {
  158. ctx := c.Request().Context()
  159. userID, ok := c.Get(userIDContextKey).(int32)
  160. if !ok {
  161. return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
  162. }
  163. // This is the backend default max upload size limit.
  164. maxUploadSetting := s.Store.GetSystemSettingValueWithDefault(ctx, SystemSettingMaxUploadSizeMiBName.String(), "32")
  165. var settingMaxUploadSizeBytes int
  166. if settingMaxUploadSizeMiB, err := strconv.Atoi(maxUploadSetting); err == nil {
  167. settingMaxUploadSizeBytes = settingMaxUploadSizeMiB * MebiByte
  168. } else {
  169. log.Warn("Failed to parse max upload size", zap.Error(err))
  170. settingMaxUploadSizeBytes = 0
  171. }
  172. file, err := c.FormFile("file")
  173. if err != nil {
  174. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get uploading file").SetInternal(err)
  175. }
  176. if file == nil {
  177. return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err)
  178. }
  179. if file.Size > int64(settingMaxUploadSizeBytes) {
  180. message := fmt.Sprintf("File size exceeds allowed limit of %d MiB", settingMaxUploadSizeBytes/MebiByte)
  181. return echo.NewHTTPError(http.StatusBadRequest, message).SetInternal(err)
  182. }
  183. if err := c.Request().ParseMultipartForm(maxUploadBufferSizeBytes); err != nil {
  184. return echo.NewHTTPError(http.StatusBadRequest, "Failed to parse upload data").SetInternal(err)
  185. }
  186. sourceFile, err := file.Open()
  187. if err != nil {
  188. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err)
  189. }
  190. defer sourceFile.Close()
  191. create := &store.Resource{
  192. CreatorID: userID,
  193. Filename: file.Filename,
  194. Type: file.Header.Get("Content-Type"),
  195. Size: file.Size,
  196. }
  197. err = SaveResourceBlob(ctx, s.Store, create, sourceFile)
  198. if err != nil {
  199. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save resource").SetInternal(err)
  200. }
  201. resource, err := s.Store.CreateResource(ctx, create)
  202. if err != nil {
  203. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
  204. }
  205. return c.JSON(http.StatusOK, convertResourceFromStore(resource))
  206. }
  207. // DeleteResource godoc
  208. //
  209. // @Summary Delete a resource
  210. // @Tags resource
  211. // @Produce json
  212. // @Param resourceId path int true "Resource ID"
  213. // @Success 200 {boolean} true "Resource deleted"
  214. // @Failure 400 {object} nil "ID is not a number: %s"
  215. // @Failure 401 {object} nil "Missing user in session"
  216. // @Failure 404 {object} nil "Resource not found: %d"
  217. // @Failure 500 {object} nil "Failed to find resource | Failed to delete resource"
  218. // @Router /api/v1/resource/{resourceId} [DELETE]
  219. func (s *APIV1Service) DeleteResource(c echo.Context) error {
  220. ctx := c.Request().Context()
  221. userID, ok := c.Get(userIDContextKey).(int32)
  222. if !ok {
  223. return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
  224. }
  225. resourceID, err := util.ConvertStringToInt32(c.Param("resourceId"))
  226. if err != nil {
  227. return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
  228. }
  229. resource, err := s.Store.GetResource(ctx, &store.FindResource{
  230. ID: &resourceID,
  231. CreatorID: &userID,
  232. })
  233. if err != nil {
  234. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
  235. }
  236. if resource == nil {
  237. return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
  238. }
  239. if err := s.Store.DeleteResource(ctx, &store.DeleteResource{
  240. ID: resourceID,
  241. }); err != nil {
  242. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
  243. }
  244. return c.JSON(http.StatusOK, true)
  245. }
  246. // UpdateResource godoc
  247. //
  248. // @Summary Update a resource
  249. // @Tags resource
  250. // @Produce json
  251. // @Param resourceId path int true "Resource ID"
  252. // @Param patch body UpdateResourceRequest true "Patch resource request"
  253. // @Success 200 {object} store.Resource "Updated resource"
  254. // @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch resource request"
  255. // @Failure 401 {object} nil "Missing user in session | Unauthorized"
  256. // @Failure 404 {object} nil "Resource not found: %d"
  257. // @Failure 500 {object} nil "Failed to find resource | Failed to patch resource"
  258. // @Router /api/v1/resource/{resourceId} [PATCH]
  259. func (s *APIV1Service) UpdateResource(c echo.Context) error {
  260. ctx := c.Request().Context()
  261. userID, ok := c.Get(userIDContextKey).(int32)
  262. if !ok {
  263. return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
  264. }
  265. resourceID, err := util.ConvertStringToInt32(c.Param("resourceId"))
  266. if err != nil {
  267. return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
  268. }
  269. resource, err := s.Store.GetResource(ctx, &store.FindResource{
  270. ID: &resourceID,
  271. })
  272. if err != nil {
  273. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
  274. }
  275. if resource == nil {
  276. return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
  277. }
  278. if resource.CreatorID != userID {
  279. return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
  280. }
  281. request := &UpdateResourceRequest{}
  282. if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
  283. return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
  284. }
  285. currentTs := time.Now().Unix()
  286. update := &store.UpdateResource{
  287. ID: resourceID,
  288. UpdatedTs: &currentTs,
  289. }
  290. if request.Filename != nil && *request.Filename != "" {
  291. update.Filename = request.Filename
  292. }
  293. resource, err = s.Store.UpdateResource(ctx, update)
  294. if err != nil {
  295. return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch resource").SetInternal(err)
  296. }
  297. return c.JSON(http.StatusOK, convertResourceFromStore(resource))
  298. }
  299. func replacePathTemplate(path, filename string) string {
  300. t := time.Now()
  301. path = fileKeyPattern.ReplaceAllStringFunc(path, func(s string) string {
  302. switch s {
  303. case "{filename}":
  304. return filename
  305. case "{timestamp}":
  306. return fmt.Sprintf("%d", t.Unix())
  307. case "{year}":
  308. return fmt.Sprintf("%d", t.Year())
  309. case "{month}":
  310. return fmt.Sprintf("%02d", t.Month())
  311. case "{day}":
  312. return fmt.Sprintf("%02d", t.Day())
  313. case "{hour}":
  314. return fmt.Sprintf("%02d", t.Hour())
  315. case "{minute}":
  316. return fmt.Sprintf("%02d", t.Minute())
  317. case "{second}":
  318. return fmt.Sprintf("%02d", t.Second())
  319. }
  320. return s
  321. })
  322. return path
  323. }
  324. func convertResourceFromStore(resource *store.Resource) *Resource {
  325. return &Resource{
  326. ID: resource.ID,
  327. CreatorID: resource.CreatorID,
  328. CreatedTs: resource.CreatedTs,
  329. UpdatedTs: resource.UpdatedTs,
  330. Filename: resource.Filename,
  331. Blob: resource.Blob,
  332. InternalPath: resource.InternalPath,
  333. ExternalLink: resource.ExternalLink,
  334. Type: resource.Type,
  335. Size: resource.Size,
  336. }
  337. }
  338. // SaveResourceBlob save the blob of resource based on the storage config
  339. //
  340. // Depend on the storage config, some fields of *store.ResourceCreate will be changed:
  341. // 1. *DatabaseStorage*: `create.Blob`.
  342. // 2. *LocalStorage*: `create.InternalPath`.
  343. // 3. Others( external service): `create.ExternalLink`.
  344. func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resource, r io.Reader) error {
  345. systemSettingStorageServiceID, err := s.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingStorageServiceIDName.String()})
  346. if err != nil {
  347. return errors.Wrap(err, "Failed to find SystemSettingStorageServiceIDName")
  348. }
  349. storageServiceID := DefaultStorage
  350. if systemSettingStorageServiceID != nil {
  351. err = json.Unmarshal([]byte(systemSettingStorageServiceID.Value), &storageServiceID)
  352. if err != nil {
  353. return errors.Wrap(err, "Failed to unmarshal storage service id")
  354. }
  355. }
  356. // `DatabaseStorage` means store blob into database
  357. if storageServiceID == DatabaseStorage {
  358. fileBytes, err := io.ReadAll(r)
  359. if err != nil {
  360. return errors.Wrap(err, "Failed to read file")
  361. }
  362. create.Blob = fileBytes
  363. return nil
  364. } else if storageServiceID == LocalStorage {
  365. // `LocalStorage` means save blob into local disk
  366. systemSettingLocalStoragePath, err := s.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingLocalStoragePathName.String()})
  367. if err != nil {
  368. return errors.Wrap(err, "Failed to find SystemSettingLocalStoragePathName")
  369. }
  370. localStoragePath := "assets/{timestamp}_{filename}"
  371. if systemSettingLocalStoragePath != nil && systemSettingLocalStoragePath.Value != "" {
  372. err = json.Unmarshal([]byte(systemSettingLocalStoragePath.Value), &localStoragePath)
  373. if err != nil {
  374. return errors.Wrap(err, "Failed to unmarshal SystemSettingLocalStoragePathName")
  375. }
  376. }
  377. filePath := filepath.FromSlash(localStoragePath)
  378. if !strings.Contains(filePath, "{filename}") {
  379. filePath = filepath.Join(filePath, "{filename}")
  380. }
  381. filePath = filepath.Join(s.Profile.Data, replacePathTemplate(filePath, create.Filename))
  382. dir := filepath.Dir(filePath)
  383. if err = os.MkdirAll(dir, os.ModePerm); err != nil {
  384. return errors.Wrap(err, "Failed to create directory")
  385. }
  386. dst, err := os.Create(filePath)
  387. if err != nil {
  388. return errors.Wrap(err, "Failed to create file")
  389. }
  390. defer dst.Close()
  391. _, err = io.Copy(dst, r)
  392. if err != nil {
  393. return errors.Wrap(err, "Failed to copy file")
  394. }
  395. create.InternalPath = filePath
  396. return nil
  397. }
  398. // Others: store blob into external service, such as S3
  399. storage, err := s.GetStorage(ctx, &store.FindStorage{ID: &storageServiceID})
  400. if err != nil {
  401. return errors.Wrap(err, "Failed to find StorageServiceID")
  402. }
  403. if storage == nil {
  404. return errors.Errorf("Storage %d not found", storageServiceID)
  405. }
  406. storageMessage, err := ConvertStorageFromStore(storage)
  407. if err != nil {
  408. return errors.Wrap(err, "Failed to ConvertStorageFromStore")
  409. }
  410. if storageMessage.Type != StorageS3 {
  411. return errors.Errorf("Unsupported storage type: %s", storageMessage.Type)
  412. }
  413. s3Config := storageMessage.Config.S3Config
  414. s3Client, err := s3.NewClient(ctx, &s3.Config{
  415. AccessKey: s3Config.AccessKey,
  416. SecretKey: s3Config.SecretKey,
  417. EndPoint: s3Config.EndPoint,
  418. Region: s3Config.Region,
  419. Bucket: s3Config.Bucket,
  420. URLPrefix: s3Config.URLPrefix,
  421. URLSuffix: s3Config.URLSuffix,
  422. })
  423. if err != nil {
  424. return errors.Wrap(err, "Failed to create s3 client")
  425. }
  426. filePath := s3Config.Path
  427. if !strings.Contains(filePath, "{filename}") {
  428. filePath = filepath.Join(filePath, "{filename}")
  429. }
  430. filePath = replacePathTemplate(filePath, create.Filename)
  431. link, err := s3Client.UploadFile(ctx, filePath, create.Type, r)
  432. if err != nil {
  433. return errors.Wrap(err, "Failed to upload via s3 client")
  434. }
  435. create.ExternalLink = link
  436. return nil
  437. }