diff --git a/internal/api/drive/handlers.go b/internal/api/drive/handlers.go index 5fd5a43..f6c8775 100644 --- a/internal/api/drive/handlers.go +++ b/internal/api/drive/handlers.go @@ -80,6 +80,8 @@ func (h *Handler) Routes() chi.Router { r.With(read).Get("/shares/recipients/lookup", h.LookupShareRecipient) r.With(read).Get("/download/*", h.Download) r.With(read).Get("/preview/*", h.Preview) + r.With(read).Get("/files/id/{fileId}", h.GetFileByID) + r.With(read).Get("/files/info/*", h.GetFileInfo) r.With(read).Get("/files/*", h.ListFiles) r.With(write).Post("/files/*", h.Upload) r.With(write).Post("/files/new", h.CreateNewFile) @@ -123,6 +125,44 @@ func (h *Handler) ListFiles(w http.ResponseWriter, r *http.Request) { apiresponse.WriteJSON(w, http.StatusOK, result) } +func (h *Handler) GetFileByID(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } + fileID, err := nextcloud.ParseDriveFileID(chi.URLParam(r, "fileId")) + if err != nil { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "fileId", Message: "invalid"}, + )) + return + } + file, err := h.svc.GetFileByID(r.Context(), ncUser, fileID) + if err != nil { + writeDriveError(w, r, err) + return + } + h.svc.EnrichSources(r.Context(), claims.Sub, []nextcloud.FileInfo{file}) + apiresponse.WriteJSON(w, http.StatusOK, file) +} + +func (h *Handler) GetFileInfo(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } + path := nextcloud.NormalizeClientPath(chi.URLParam(r, "*")) + file, err := h.svc.StatFile(r.Context(), ncUser, path) + if err != nil { + writeDriveError(w, r, err) + return + } + h.svc.EnrichSources(r.Context(), claims.Sub, []nextcloud.FileInfo{file}) + apiresponse.WriteJSON(w, http.StatusOK, file) +} + func (h *Handler) ListFilterCorpus(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) ncUser, ok := h.nextcloudUser(w, r, claims) @@ -723,13 +763,16 @@ func (h *Handler) CreateNewFile(w http.ResponseWriter, r *http.Request) { return } kind := NewFileKind(strings.TrimSpace(strings.ToLower(req.Kind))) - target, err := h.svc.CreateNewFile(r.Context(), ncUser, req.ParentPath, req.Name, kind) + target, fileID, err := h.svc.CreateNewFile(r.Context(), ncUser, req.ParentPath, req.Name, kind) if err != nil { writeDriveError(w, r, err) return } h.svc.afterDriveFileEvent(r.Context(), claims.Sub, rules.TriggerDriveFileCreated, target, false) - apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"path": target}) + apiresponse.WriteJSON(w, http.StatusCreated, map[string]any{ + "path": target, + "file_id": fileID, + }) } func writeDriveError(w http.ResponseWriter, r *http.Request, err error) { diff --git a/internal/api/drive/service.go b/internal/api/drive/service.go index 9b7fe8a..904f741 100644 --- a/internal/api/drive/service.go +++ b/internal/api/drive/service.go @@ -266,6 +266,26 @@ func (s *Service) Preview(ctx context.Context, userID, filePath string, width, h return body, contentType, nil } +func (s *Service) StatFile(ctx context.Context, userID, filePath string) (nextcloud.FileInfo, error) { + filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath)) + file, err := s.nc.StatFile(ctx, userID, filePath) + if err != nil { + return nextcloud.FileInfo{}, mapDriveError(err) + } + return file, nil +} + +func (s *Service) GetFileByID(ctx context.Context, userID string, fileID int64) (nextcloud.FileInfo, error) { + file, err := s.nc.FindFileByID(ctx, userID, fileID) + if err != nil { + if strings.Contains(err.Error(), "file not found") { + return nextcloud.FileInfo{}, ErrNotFound + } + return nextcloud.FileInfo{}, mapDriveError(err) + } + return file, nil +} + func (s *Service) Delete(ctx context.Context, userID, path string) error { return mapDriveError(s.nc.Delete(ctx, userID, path)) } @@ -550,19 +570,23 @@ const ( NewFilePresentation NewFileKind = "presentation" ) -func (s *Service) CreateNewFile(ctx context.Context, userID, parentPath, name string, kind NewFileKind) (string, error) { +func (s *Service) CreateNewFile(ctx context.Context, userID, parentPath, name string, kind NewFileKind) (string, int64, error) { if strings.TrimSpace(name) == "" { - return "", ErrInvalid + return "", 0, ErrInvalid } content, contentType := blankOfficeFile(kind) if content == nil { - return "", ErrInvalid + return "", 0, ErrInvalid } target := path.Join(strings.TrimSuffix(parentPath, "/"), name) if err := mapDriveError(s.nc.Upload(ctx, userID, target, bytes.NewReader(content), contentType)); err != nil { - return "", err + return "", 0, err } - return target, nil + fileID, err := s.nc.FileID(ctx, userID, target) + if err != nil { + return target, 0, nil + } + return target, fileID, nil } func filterFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo { diff --git a/internal/api/office/handlers.go b/internal/api/office/handlers.go index bcd5533..ca581e2 100644 --- a/internal/api/office/handlers.go +++ b/internal/api/office/handlers.go @@ -126,12 +126,15 @@ func (h *Handler) CreateDocument(w http.ResponseWriter, r *http.Request) { return } kind := drive.NewFileKind(strings.TrimSpace(strings.ToLower(req.Kind))) - target, err := h.drive.CreateNewFile(r.Context(), ncUser, req.ParentPath, req.Name, kind) + target, fileID, err := h.drive.CreateNewFile(r.Context(), ncUser, req.ParentPath, req.Name, kind) if err != nil { apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) return } - apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"path": target}) + apiresponse.WriteJSON(w, http.StatusCreated, map[string]any{ + "path": target, + "file_id": fileID, + }) } func (h *Handler) ServeDocument(w http.ResponseWriter, r *http.Request) { diff --git a/internal/nextcloud/file_stat.go b/internal/nextcloud/file_stat.go new file mode 100644 index 0000000..b6b6eca --- /dev/null +++ b/internal/nextcloud/file_stat.go @@ -0,0 +1,153 @@ +package nextcloud + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +const propfindFileProps = ` + + + + + + + + + + + + + +` + +func (c *Client) StatFile(ctx context.Context, userID, filePath string) (FileInfo, error) { + filePath = NormalizeClientFilePath(userID, filePath) + davPath := c.WebDAVPath(userID, filePath) + + resp, err := c.DoAsUser(ctx, "PROPFIND", davPath, strings.NewReader(propfindFileProps), userID, map[string]string{ + "Depth": "0", + "Content-Type": "application/xml", + }) + if err != nil { + return FileInfo{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK { + return FileInfo{}, &HTTPStatusError{Operation: "propfind stat file", StatusCode: resp.StatusCode} + } + + return parseSinglePropfindResponse(resp.Body) +} + +func (c *Client) FindFileByID(ctx context.Context, userID string, fileID int64) (FileInfo, error) { + if fileID <= 0 { + return FileInfo{}, fmt.Errorf("invalid file id") + } + userSeg := strings.TrimSpace(userID) + searchBody := fmt.Sprintf(` + + + + + + + + + + + + + + + + + + + /files/%s + infinity + + + + + + %d + + + + 1 + +`, userSeg, fileID) + + resp, err := c.DoAsUser(ctx, "SEARCH", "/remote.php/dav/", strings.NewReader(searchBody), userID, map[string]string{ + "Content-Type": "text/xml", + }) + if err != nil { + return FileInfo{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK { + return FileInfo{}, &HTTPStatusError{Operation: "search file by id", StatusCode: resp.StatusCode} + } + + return parseSinglePropfindResponse(resp.Body) +} + +func parseSinglePropfindResponse(body io.Reader) (FileInfo, error) { + var ms multistatus + if err := xml.NewDecoder(body).Decode(&ms); err != nil { + return FileInfo{}, err + } + if len(ms.Responses) == 0 { + return FileInfo{}, fmt.Errorf("file not found") + } + return fileInfoFromDAVResponse(ms.Responses[0]), nil +} + +func fileInfoFromDAVResponse(r response) FileInfo { + name := fileNameFromDAVProp(r.Propstat.Prop.DisplayName, r.Href) + clientPath := clientPathFromDAVHref(r.Href) + name = SyncFileDisplayName(clientPath, name) + + fileType := "file" + if r.Propstat.Prop.ResourceType.Collection != nil { + fileType = "directory" + } + + size := r.Propstat.Prop.ContentLength + if r.Propstat.Prop.Size > 0 { + size = r.Propstat.Prop.Size + } + + return FileInfo{ + Path: clientPath, + Name: name, + Type: fileType, + Size: size, + MimeType: r.Propstat.Prop.ContentType, + LastModified: r.Propstat.Prop.LastModified, + ETag: strings.Trim(r.Propstat.Prop.ETag, "\""), + FileID: parseFileID(r.Propstat.Prop.FileID), + IsFavorite: r.Propstat.Prop.Favorite == 1, + IsShared: len(r.Propstat.Prop.ShareTypes.ShareType) > 0, + } +} + +func ParseDriveFileID(raw string) (int64, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, fmt.Errorf("empty file id") + } + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id <= 0 { + return 0, fmt.Errorf("invalid file id %q", raw) + } + return id, nil +}