Edit online

Integrate Oxygen XML Web Author with a File Storage Service with a Custom Plugin

The bridge between Oxygen XML Web Author and File Storage Servers (e.g. GitHub, Drupal, Dropbox, etc.) is generally called a connector. This is basically a plugin whose purpose is to enable a connection to a file storage service.

Oxygen XML Web Author operates documents generically. It does not have built-in business logic for a specific File Storage Service. It has a few extension points, APIs, and interfaces that a connector must implement to make the actual connection to the file storage service. This generic approach is achieved by relying internally on java.net.URL objects.

When it needs to read a document, it opens an HTTP connection the document URL by using java.net.URL.openConnection(), and from the returned java.net.URLConnection, it reads the document from the java.io.InputStream returned by java.net.URLConnection.getInputStream() method call.

When it needs to write a document, it does the same as above but uses the java.io.OutputStream from java.net.URLConnection.

When it needs to list a directory, it checks that java.net.URLConnection implements the ro.sync.net.protocol.FileBrowsingConnection interface, and if it does, it calls ro.sync.net.protocol.FileBrowsingConnection.listFolder().

To enable the file browsing from the browser, after implementing FileBrowsingConnection, the connector has to initialize the UrlChooser in the browser by executing the following code from a plugin.js file:
workspace.setUrlChooser(new sync.api.FileBrowsingDialog());

The connector has to contribute a ro.sync.ecss.extensions.api.webapp.plugin.URLStreamHandlerWithContext implementation that returns java.net.URLConnection objects that the Oxygen XML Web Author server can use. To do so, in the plugin.xml file, the connector has to define an extension of the type "URLHandler" with the class attribute pointing to the class that implements the ro.sync.exml.plugin.urlstreamhandler.URLStreamHandlerPluginExtension interface.

The URLStreamHandlerWithContext interface extends the standard java.net.URLStreamHandler interface, but it enables knowing what browser session to return a connection for.

The part from plugin.xml where the "URLHandler" extension is declared may look like this:
  <extension type="URLHandler" 
    class="com.example.CustomUrlHandlerPluginExtension"/>
And the com.example.CustomUrlHandlerPluginExtension may look like this:
public class CustomUrlStreamHandlerPluginExtension implements URLStreamHandlerPluginExtension {
  @Override
  public URLStreamHandler getURLStreamHandler(String protocol) {
    if ("customprotocol".equals(protocol)) {
      return new CustomUrlStreamHandler(protocol);
    } else {
      return null;
    }
  }
}
And the CustomUrlStreamHandler may look like this:
public class CustomURLStreamHandlerWithContext extends URLStreamHandlerWithContext {
  @Override
  protected URLConnection openConnectionInContext(String contextId, URL oxyUri, Proxy proxy) throws IOException {
    return newCustomUrlConnection(contextId, oxyUri, proxy);
  }
}

Note that the contextId argument of URLStreamHandlerWithContext.openConnectionInContext is actually the user session ID.

And the CustomUrlConnection may look like this:
  public class CustomUrlConnection extends URLConnection implements FileBrowsingConnection {

    public CustomUrlConnection(String contextId, URL oxyUri, Proxy proxy) {
      super(oxyUri);
    }

    @Override
    public void connect() throws IOException {
      // TODO: implement me.
    }
    
    @Override
    public InputStream getInputStream() throws IOException {
      // TODO: implement me.
    }
    
    @Override
    public OutputStream getOutputStream() throws IOException {
      // TODO: implement me.
    }
    
    @Override
    public List<FolderEntryDescriptor> listFolder() throws IOException, UserActionRequiredException {
      // TODO: implement me.
    }
  }

The CustomUrlStreamHandler from the above example will handle URLs with the "customprotocol" protocol (for example: "customprotocol://hostname/path/to/file/doc.xml"). This kind of URL is called an OXY-URL and it is used internally by the application. The user does not deal with this format. The URL of the Oxygen XML Web Author application that opens the above example URL would look like this: "http://localhost:8081/oxygen-xml-web-author/app/oxygen.html?url=customprotocol%3A%2F%2Fhostname%2Fpath%2Fto%2Ffile%2Fdoc.xml". Note that the document URL is specified in the "url" query parameter. You can see all the available query parameters in the Passing URL Parameters to the Editor topic.

The CustomUrlConnection receives the OXY-URL and based on that information, it has to make a request to the File Storage Service to read a document, write a document, or list a directory.

If the File Storage Service rejects the request because of authentication, the URLConnection must signal this to Oxygen XML Web Author by throwing UserActionRequiredException with WebappMessage.MESSAGE_TYPE_CUSTOM like this:
throw new UserActionRequiredException(
        new WebappMessage(
          WebappMessage.MESSAGE_TYPE_CUSTOM,
          "Authentication Required",
          "Please authenticate.",
          true));
Note:
The default configuration of application's internal Firewall may block requests to the File Storage Service. The configuration can be relaxed from the Firewall section from the Administration page.

Authentication

The connector, when making requests to the File Storage Service, has to attach the required authentication information for a particular user (for a particular contextId). For example, the Authorization header that contains the user's credentials or a JWT. The connector typically stores the authentication information onto the session using the ro.sync.ecss.extensions.api.webapp.SessionStore API.

This is how storing the authentication information would look:
// Store authentication secret in the session store.
WebappPluginWorkspace pluginWorkspace = (WebappPluginWorkspace) pluginWorkspaceAccess;
SessionStore sessionStore = pluginWorkspace.getSessionStore();
sessionStore.put(sessionId, "custom-connector-auth", authenticationSecret);
This is how retrieving the authentication information from CustomUrlConnection would look:
// Get authentication secret from the session store.
WebappPluginWorkspace pluginWorkspace = (WebappPluginWorkspace) pluginWorkspaceAccess;
SessionStore sessionStore = pluginWorkspace.getSessionStore();
String authenticationSecret = sessionStore.get(contextId, "custom-connector-auth");

The connector may have to implement an OAuth flow, or to show username and password form, or even receive an authentication token from the user. In all cases, it has to store the authentication info into the SessionStore. For this, it can define a WebappServlet extension by extending the ro.sync.ecss.extensions.api.webapp.plugin.ServletPluginExtension API class. This allows the connector to register a servlet on a particular path (for e.g. "custom-connector-auth") where it can receive requests from the plugin's JavaScript code, allowing it to send data from the browser to the server. The servlet is supposed to receive the authentication info from the client, validate it, and then store it in SessionStore. The URL that the servlet handles may be like this: "http://localhost:8080/oxygen-xml-web-author/plugins-dispatcher/custom-connector-auth/". The servlet can obtain the session id like this: "req.getSession().getId()".

The client-side part of the connector can show a username-password form. For this, it has to use the JavaScript API and register a sync.api.FileServer implementation to sync.api.FileServersManager, somewhat like this:
/**
 * See sync.api.FileServer.
 */
class CustomFileServer {

  /** @override */
  login(serverUrl, loginCallback) {
    // Show a dialog with username and password inputs and 
sends them to the connector servlet.

    let loginDialog = workspace.createDialog();
    loginDialog.setTitle("Login");
    loginDialog.setPreferredSize(400, 500);

    loginDialog.getElement().innerHTML = `
      <label for="name">Username:</label>
      <input type="text" id="name" name="name"><br><br>
      
      <label for="password">Password:</label>
      <input type="password" id="password" name="password"><br><br>
    `;

    loginDialog.onSelect(key => {
      if (key === 'ok') {
        let username = containerElement.querySelector("#name");
        let password = containerElement.querySelector("#password");

        // Sent the credentials to the connector servlet.
        fetch('../plugins-dispatcher/custom-connector-auth', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
          },
          body: `username=${encodeURIComponent(username.getValue())}&password=
${encodeURIComponent(password.getValue())}`
        })
          .then(response => {
            if (response.ok) {
              loginDialog.setVisible(false);
              logoutCallback();
            } else {
              workspace.getNotificationManager().showError("Authentication failed");
            }
          })
          .catch(() => {
            workspace.getNotificationManager().showError("An error occurred");
          });
      }
    })

    loginDialog.show();
  }

  /** @override */
  logout(logoutCallback) {
    //TODO Implement me.
  }

  /** @override */
  getUserName() {
    //TODO Implement me.
  }

  /** @override */
  setUserChangedCallback(userChangedCallback) {
    //TODO Implement me.
  }

  /** @override */
  createRootUrlComponent(rootUrl, rootURLChangedCallback, readOnly) {
    //TODO Implement me.
  }

  /** @override */
  getUrlInfo(url, urlInfoCallback, showErrorMessageCallback) {
    //TODO Implement me.
  }
}

/** @type {sync.api.FileServer} */
let customFileServer = new CustomFileServer();

/** @type {sync.api.FileServerDescriptor} */
let customFileServerDescriptor =  {
  'id' : 'custom-connector',
  'name' : 'Custom Connector',
  'icon' : null,
  'matches' : function matches(url) {
    return url.match(/^customprotocol?:/);
  },
  'fileServer' : customFileServer
};
workspace.getFileServersManager().registerFileServerConnector(customFileServerDescriptor);
The sync.api.FileServer.login method is called when authentication is requested. The implementation can show a dialog box that displays a username-password form and then submits the credentials to the connector servlet. To show a dialog box in the browser, the workspace.createDialog API can be used.

The username is important to be returned by sync.api.FileServer.getUserName because otherwise, when adding comments or editing the document with change tracking, the user will appear as "Anonymous".

The ServletPluginExtension that serves "/plugins-dispatcher/custom-connector-auth/" may look like this:
public class CustomConnectorAuthenticationServlet extends ServletPluginExtension {
  
  @Override
  public void doPost(HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws 
ServletException, IOException {
    String sessionId = httpRequest.getSession().getId();
    
    String user = httpRequest.getParameter("user");
    String passwd = httpRequest.getParameter("password");
    
    if (areCredentialsValid(user, passwd)) {
      WebappPluginWorkspace pluginWorkspace = (WebappPluginWorkspace) 
PluginWorkspaceProvider.getPluginWorkspace();
      SessionStore sessionStore = pluginWorkspace.getSessionStore();
      sessionStore.put(sessionId, "custom-connector-auth", user + ":" + passwd);
    } else {
      httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    }
  }
      
  private boolean areCredentialsValid(String user, String passwd) {
    // TODO Implement me.
    return false;
  }

  @Override
  public String getPath() {
    return "custom-connector-auth";
  }
}

Troubleshooting

To troubleshoot the connector code, enable network logs by following the steps from the Enabling HTTP Request Logging for Debugging topic.