package connector import ( "encoding/json" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" "github.com/qsfera/server/services/collaboration/pkg/config" "github.com/qsfera/server/services/collaboration/pkg/connector/utf7" "github.com/qsfera/server/services/collaboration/pkg/locks" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" "github.com/rs/zerolog" "go-micro.dev/v4/selector" microstore "go-micro.dev/v4/store" ) const ( HeaderWopiLock string = "X-WOPI-Lock" HeaderWopiOldLock string = "X-WOPI-OldLock" HeaderWopiLockFailureReason string = "X-WOPI-LockFailureReason" HeaderWopiST string = "X-WOPI-SuggestedTarget" HeaderWopiRT string = "X-WOPI-RelativeTarget" HeaderWopiOverwriteRT string = "X-WOPI-OverwriteRelativeTarget" HeaderWopiSize string = "X-WOPI-Size" HeaderWopiValidRT string = "X-WOPI-ValidRelativeTarget" HeaderWopiRequestedName string = "X-WOPI-RequestedName" HeaderContentLength string = "Content-Length" HeaderContentType string = "Content-Type" HeaderWopiVersion string = "X-WOPI-ItemVersion" ) // HttpAdapter will adapt the responses from the connector to HTTP. // // The adapter will use the request's context for the connector operations, // this means that the request MUST have a valid WOPI context and a // pre-configured logger. This should have been prepared in the routing. // // All operations are expected to follow the definitions found in // https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/endpoints type HttpAdapter struct { con ConnectorService locks locks.LockParser } // NewHttpAdapter will create a new HTTP adapter. A new connector using the // provided gateway API client and configuration will be used in the adapter func NewHttpAdapter(gws pool.Selectable[gatewayv1beta1.GatewayAPIClient], cfg *config.Config, st microstore.Store, graphSelector selector.Selector) *HttpAdapter { httpAdapter := &HttpAdapter{ con: NewConnector( NewFileConnector(gws, cfg, st, graphSelector), NewContentConnector(gws, cfg), ), } // TODO: check if we can get rid of custom log parsing completely httpAdapter.locks = &locks.NoopLockParser{} return httpAdapter } // NewHttpAdapterWithConnector will create a new HTTP adapter that will use // the provided connector service func NewHttpAdapterWithConnector(con ConnectorService, l locks.LockParser) *HttpAdapter { return &HttpAdapter{ con: con, locks: l, } } // GetLock adapts the "GetLock" operation for WOPI. // Only the request's context is needed in order to extract the WOPI context. // The operation's response will be sent through the response writer and // the headers according to the spec func (h *HttpAdapter) GetLock(w http.ResponseWriter, r *http.Request) { fileCon := h.con.GetFileConnector() response, err := fileCon.GetLock(r.Context()) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } h.writeConnectorResponse(w, r, response) } // Lock adapts the "Lock" and "UnlockAndRelock" operations for WOPI. // The request's context is needed in order to extract the WOPI context. In // addition, the "X-WOPI-Lock" and "X-WOPI-OldLock" headers might be needed" // (check spec) // The operation's response will be sent through the response writer and // the headers according to the spec func (h *HttpAdapter) Lock(w http.ResponseWriter, r *http.Request) { oldLockID := h.locks.ParseLock(r.Header.Get(HeaderWopiOldLock)) lockID := h.locks.ParseLock(r.Header.Get(HeaderWopiLock)) fileCon := h.con.GetFileConnector() response, err := fileCon.Lock(r.Context(), lockID, oldLockID) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } h.writeConnectorResponse(w, r, response) } // RefreshLock adapts the "RefreshLock" operation for WOPI // The request's context is needed in order to extract the WOPI context. In // addition, the "X-WOPI-Lock" header is needed (check spec). // The lock will be refreshed to last another 30 minutes. The value is // hardcoded // The operation's response will be sent through the response writer and // the headers according to the spec func (h *HttpAdapter) RefreshLock(w http.ResponseWriter, r *http.Request) { lockID := h.locks.ParseLock(r.Header.Get(HeaderWopiLock)) fileCon := h.con.GetFileConnector() response, err := fileCon.RefreshLock(r.Context(), lockID) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } h.writeConnectorResponse(w, r, response) } // UnLock adapts the "Unlock" operation for WOPI // The request's context is needed in order to extract the WOPI context. In // addition, the "X-WOPI-Lock" header is needed (check spec). // The operation's response will be sent through the response writer and // the headers according to the spec func (h *HttpAdapter) UnLock(w http.ResponseWriter, r *http.Request) { lockID := h.locks.ParseLock(r.Header.Get(HeaderWopiLock)) fileCon := h.con.GetFileConnector() response, err := fileCon.UnLock(r.Context(), lockID) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } h.writeConnectorResponse(w, r, response) } // CheckFileInfo will retrieve the information of the file in json format // Only the request's context is needed in order to extract the WOPI context. // The operation's response will be sent through the response writer and // the headers according to the spec func (h *HttpAdapter) CheckFileInfo(w http.ResponseWriter, r *http.Request) { fileCon := h.con.GetFileConnector() response, err := fileCon.CheckFileInfo(r.Context()) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } h.writeConnectorResponse(w, r, response) } // GetFile will download the file // Only the request's context is needed in order to extract the WOPI context. // The file's content will be written in the response writer func (h *HttpAdapter) GetFile(w http.ResponseWriter, r *http.Request) { contentCon := h.con.GetContentConnector() err := contentCon.GetFile(r.Context(), w) if err != nil { var conError *ConnectorError if errors.As(err, &conError) { http.Error(w, http.StatusText(conError.HttpCodeOut), conError.HttpCodeOut) } else { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } // status might have been already sent if the file is big enough, but for // small files, the content might still be buffered. w.WriteHeader(http.StatusOK) } // PutFile will upload the file // The request's context and its body are needed (content length is also // needed) // The operation's response will be sent through the response writer and // the headers according to the spec func (h *HttpAdapter) PutFile(w http.ResponseWriter, r *http.Request) { lockID := h.locks.ParseLock(r.Header.Get(HeaderWopiLock)) contentCon := h.con.GetContentConnector() response, err := contentCon.PutFile(r.Context(), r.Body, r.ContentLength, lockID) if err != nil { var connErr *ConnectorError if errors.As(err, &connErr) && connErr.HttpCodeOut != 0 { response = NewResponse(connErr.HttpCodeOut) } else { response = NewResponse(http.StatusInternalServerError) } } h.writeConnectorResponse(w, r, response) } // PutRelativeFile will upload the file with a specific name. The name might be // automatically adjusted depending on the request headers. // Note that this method will also send a json body in the response. // It has 2 mutually exclusive operation methods that are used based on the // provided headers in the request. // Note that this method won't used locks (not documented). // // The file name must be encoded in utf7. This method will decode the utf7 name // into utf8. The utf8 (not utf7) name must have less than 512 bytes, otherwise // the request will fail. func (h *HttpAdapter) PutRelativeFile(w http.ResponseWriter, r *http.Request) { relativeTarget := r.Header.Get(HeaderWopiRT) suggestedTarget := r.Header.Get(HeaderWopiST) if relativeTarget != "" && suggestedTarget != "" { // headers are mutually exclusive http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } var response *ConnectorResponse var putErr error fileCon := h.con.GetFileConnector() if suggestedTarget != "" { utf8Target, decErr := utf7.DecodeString(suggestedTarget) if decErr != nil || len(utf8Target) > 512 { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } response, putErr = fileCon.PutRelativeFileSuggested(r.Context(), h.con.GetContentConnector(), r.Body, r.ContentLength, utf8Target) } if relativeTarget != "" { utf8Target, decErr := utf7.DecodeString(relativeTarget) if decErr != nil || len(utf8Target) > 512 { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } response, putErr = fileCon.PutRelativeFileRelative(r.Context(), h.con.GetContentConnector(), r.Body, r.ContentLength, utf8Target) } if putErr != nil { var connErr *ConnectorError if errors.As(putErr, &connErr) && connErr.HttpCodeOut != 0 { response = NewResponse(connErr.HttpCodeOut) } else { response = NewResponse(http.StatusInternalServerError) } } h.writeConnectorResponse(w, r, response) } // DeleteFile will delete the provided file. If the file is locked and can't // be deleted, a 409 conflict error will be returned with its corresponding // lock. func (h *HttpAdapter) DeleteFile(w http.ResponseWriter, r *http.Request) { lockID := r.Header.Get(HeaderWopiLock) fileCon := h.con.GetFileConnector() response, err := fileCon.DeleteFile(r.Context(), lockID) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } h.writeConnectorResponse(w, r, response) } // RenameFile will rename the file. The name might be automatically adjusted. // Note that this method will also send a json body in the response. The // adjusted file name will be returned in the body. // // The file name must be encoded in utf7. This method will decode the utf7 name // into utf8. The utf8 (not utf7) name must have less than 495 bytes, otherwise // the request will fail. func (h *HttpAdapter) RenameFile(w http.ResponseWriter, r *http.Request) { lockID := r.Header.Get(HeaderWopiLock) requestedName := r.Header.Get(HeaderWopiRequestedName) utf8Target, decErr := utf7.DecodeString(requestedName) if decErr != nil || len(utf8Target) > 495 { // need space for the possible prefix and the extension w.Header().Set("X-WOPI-InvalidFileNameError", "Filename too long") http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } fileCon := h.con.GetFileConnector() response, err := fileCon.RenameFile(r.Context(), lockID, utf8Target) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } h.writeConnectorResponse(w, r, response) } // GetAvatar proxies the user's avatar from the Graph API. // The WOPI token in the query string provides authentication (validated by // the WopiContextAuthMiddleware). Collabora loads avatars via img.src which // is a plain browser GET — it cannot send auth headers. func (h *HttpAdapter) GetAvatar(w http.ResponseWriter, r *http.Request) { logger := zerolog.Ctx(r.Context()) userID := chi.URLParam(r, "userID") if userID == "" { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } fileCon := h.con.GetFileConnector() response, err := fileCon.GetAvatar(r.Context(), userID) if err != nil { var connErr *ConnectorError if errors.As(err, &connErr) { http.Error(w, http.StatusText(connErr.HttpCodeOut), connErr.HttpCodeOut) } else { logger.Error().Err(err).Msg("GetAvatar: failed to fetch avatar") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } return } h.writeConnectorAvatarResponse(w, r, response) } func (h *HttpAdapter) writeConnectorAvatarResponse(w http.ResponseWriter, r *http.Request, response *ConnectorResponse) { data, _ := response.Body.([]byte) for key, value := range response.Headers { w.Header().Set(key, value) } w.WriteHeader(response.Status) written, err := w.Write(data) if err != nil { logger := zerolog.Ctx(r.Context()) logger.Error(). Err(err). Int("TotalBytes", len(data)). Int("WrittenBytes", written). Msg("failed to write avatar contents in the HTTP response") } } func (h *HttpAdapter) writeConnectorResponse(w http.ResponseWriter, r *http.Request, response *ConnectorResponse) { jsonBody := []byte{} if response.Body != nil { var err error jsonBody, err = json.Marshal(response.Body) if err != nil { logger := zerolog.Ctx(r.Context()) logger.Error().Err(err).Msg("failed to marshal response") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } w.Header().Set(HeaderContentType, "application/json") w.Header().Set(HeaderContentLength, strconv.Itoa(len(jsonBody))) } for key, value := range response.Headers { w.Header().Set(key, value) } w.WriteHeader(response.Status) bytes, err := w.Write(jsonBody) if err != nil { logger := zerolog.Ctx(r.Context()) logger.Error(). Err(err). Int("TotalBytes", len(jsonBody)). Int("WrittenBytes", bytes). Msg("failed to write contents in the HTTP response") } }