ncDocConverter/internal/nextcloud/nextcloud.go

253 lines
7.2 KiB
Go

package nextcloud
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"text/template"
"time"
"git.rpjosh.de/RPJosh/go-logger"
"git.rpjosh.de/ncDocConverter/internal/models"
"git.rpjosh.de/ncDocConverter/web"
)
// The internal representation of a nextcloud file
type NcFile struct {
// File extension: txt
Extension string
// Relative path of the file to the nextcloud root: /folder/file.txt
Path string
LastModified time.Time
ContentType string
// Size in Bytes
Size int
// The unique file ID of the nextcloud server
Fileid int
// The Webdav URL for file reference
WebdavURL string
}
type searchTemplateData struct {
Username string
Directory string
ContentType []string
}
type searchResult struct {
XMLName xml.Name `xml:"multistatus"`
Text string `xml:",chardata"`
D string `xml:"d,attr"`
S string `xml:"s,attr"`
Oc string `xml:"oc,attr"`
Nc string `xml:"nc,attr"`
Response []searchResultResponse `xml:"response"`
}
type searchResultResponse struct {
Text string `xml:",chardata"`
Href string `xml:"href"`
Propstat struct {
Text string `xml:",chardata"`
Prop struct {
Text string `xml:",chardata"`
Getcontenttype string `xml:"getcontenttype"`
Getlastmodified string `xml:"getlastmodified"`
Size string `xml:"size"`
Fileid int `xml:"fileid"`
} `xml:"prop"`
Status string `xml:"status"`
} `xml:"propstat"`
}
func (r *searchResultResponse) GetLastModified() time.Time {
// Time format: Fri, 23 Sep 2022 05:46:31 GMT
rtc, err := time.Parse("Mon, 02 Jan 2006 15:04:05 GMT", r.Propstat.Prop.Getlastmodified)
if err != nil {
logger.Warning("%s", err)
rtc = time.Unix(0, 1)
}
return rtc
}
// Returns a new request to the Nexcloud API.
// The path beginning AFTER /dav/ should be given (e.g.: myUser/folder/file.txt)
func getRequest(method string, path string, body io.Reader, ncUser *models.NextcloudUser) *http.Request {
req, err := http.NewRequest(method, ncUser.NextcloudBaseUrl+"/remote.php/dav/"+path, body)
if err != nil {
logger.Error("%s", err)
}
req.SetBasicAuth(ncUser.Username, ncUser.Password)
return req
}
// Searches for all files of the given content type starting in the given directory.
func SearchInDirectory(ncUser *models.NextcloudUser, directory string, contentType []string) (*searchResult, error) {
client := http.Client{Timeout: 5 * time.Second}
template, err := template.ParseFS(web.ApiTemplateFiles, "apitemplate/ncsearch.tmpl.xml")
if err != nil {
return nil, err
}
var buf bytes.Buffer
templateData := searchTemplateData{
Username: ncUser.Username,
Directory: directory,
ContentType: contentType,
}
if err = template.Execute(&buf, templateData); err != nil {
return nil, err
}
// Status code 207
req := getRequest("SEARCH", "", &buf, ncUser)
req.Header.Set("Content-Type", "application/xml")
res, err := client.Do(req)
if err != nil {
return nil, err
}
// Decody body first before checking status code to print in error message
defer res.Body.Close()
resBody, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
// Create folder if not existing
if res.StatusCode == 404 {
logger.Info("Creating directory '%s' because it does not exist", "/"+directory)
CreateFoldersRecursively(ncUser, "/"+directory+"notExisting.pdf")
return &searchResult{}, nil
}
if res.StatusCode != 207 {
return nil, fmt.Errorf("status code %d: %s", res.StatusCode, resBody)
}
var result searchResult
if err = xml.Unmarshal(resBody, &result); err != nil {
return nil, err
}
return &result, nil
}
// Parses the response from the given search format to an NcFile.
// A map with the relative path based on the source Directory ("someFolder/file.txt")
// and the mathing NcFile will be returned. Therefore, also the source Directory has to be given.
//
// To determine the path without the prefix "/remote.php/dav/user/" it has to be given.
func ParseSearchResult(result *searchResult, prefix string, sourceDir string) map[string]NcFile {
preCount := len(prefix)
rtc := make(map[string]NcFile)
for _, file := range result.Response {
href, _ := url.QueryUnescape(file.Href)
path := href[preCount:]
var extension = filepath.Ext(path)
var name = path[0 : len(path)-len(extension)][len(sourceDir):]
time := file.GetLastModified()
size, err := strconv.Atoi(file.Propstat.Prop.Size)
if err != nil {
logger.Error("Failed to parse the file size '%s' to an integer: %s", file.Propstat.Prop.Size, err)
continue
}
rtc[name] = NcFile{
Extension: extension,
Path: path,
LastModified: time,
Size: size,
ContentType: file.Propstat.Prop.Getcontenttype,
Fileid: file.Propstat.Prop.Fileid,
WebdavURL: file.Href,
}
}
return rtc
}
// Delets a file with the given path.
// The path has to start at the root level: Ebook/myFolder/file.txt
func DeleteFile(ncUser *models.NextcloudUser, filePath string) error {
return deleteFile(ncUser, filePath, true)
}
func deleteFile(ncUser *models.NextcloudUser, filePath string, retry bool) error {
client := http.Client{Timeout: 5 * time.Second}
req := getRequest(http.MethodDelete, "files/"+ncUser.Username+"/"+filePath, nil, ncUser)
res, err := client.Do(req)
if err != nil {
logger.Error("%s", err)
}
if res.StatusCode != 204 {
return fmt.Errorf("failed to delete file %s (%d)", filePath, res.StatusCode)
}
// If the server is locked try to delete it again
if res.StatusCode == 423 && retry {
logger.Debug("Trying to delete the file again (it was locked previously)")
time.Sleep(10 * time.Millisecond)
return deleteFile(ncUser, filePath, false)
}
return nil
}
// Creates all required directorys to create the destination file recursively.
// The path should be relative to the root: ebook/folder1/folder2/file.txt
func CreateFoldersRecursively(ncUser *models.NextcloudUser, destinationFile string) {
s := strings.Split(destinationFile, "/")
folderTree := ""
// Webdav doesn't have a function to create directories recursively → iterate
for _, folder := range s[:len(s)-1] {
folderTree += folder + "/"
client := http.Client{Timeout: 5 * time.Second}
req := getRequest("MKCOL", "files/"+ncUser.Username+"/"+folderTree, nil, ncUser)
res, err := client.Do(req)
if err != nil {
logger.Error("%s", err)
}
if res.StatusCode != 201 && res.StatusCode != 405 {
logger.Error("Failed to create directory '%s'", folderTree)
}
}
}
// Uploads a file to the nextcloud server.
// It will be saved to the destination as a relative path to the nextcloud root (ebook/file.txt).
func UploadFile(ncUser *models.NextcloudUser, destination string, content io.ReadCloser) error {
client := http.Client{Timeout: 10 * time.Second}
cnt, err := io.ReadAll(content)
if err != nil {
return fmt.Errorf("failed to read body: %s", err)
}
req := getRequest(http.MethodPut, "files/"+ncUser.Username+"/"+destination, bytes.NewBuffer(cnt), ncUser)
res, err := client.Do(req)
if err != nil {
return err
}
if res.StatusCode != 201 && res.StatusCode != 204 {
return fmt.Errorf("expected status code 201 or 204 but got %d", res.StatusCode)
}
return nil
}