package com.nutomic.syncthingandroid.util;
import android.content.Context;
import android.os.Build;
import android.os.Environment;
import android.preference.PreferenceManager;
import android.util.Log;
import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.service.SyncthingRunnable;
import org.mindrot.jbcrypt.BCrypt;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.SecureRandom;
import java.util.Locale;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
/**
* Provides direct access to the config.xml file in the file system.
*
* This class should only be used if the syncthing API is not available (usually during startup).
*/
public class ConfigXml {
public class OpenConfigException extends RuntimeException {
}
private static final String TAG = "ConfigXml";
/**
* File in the config folder that contains configuration.
*/
public static final String CONFIG_FILE = "config.xml";
private final Context mContext;
private final File mConfigFile;
private Document mConfig;
public ConfigXml(Context context) throws OpenConfigException {
mContext = context;
mConfigFile = getConfigFile(context);
boolean isFirstStart = !mConfigFile.exists();
if (isFirstStart) {
Log.i(TAG, "App started for the first time. Generating keys and config.");
generateKeysConfig(context);
}
try {
DocumentBuilder db = DocumentBuilderFactory.newInstance().newDocumentBuilder();
mConfig = db.parse(mConfigFile);
} catch (SAXException | ParserConfigurationException | IOException e) {
throw new OpenConfigException();
}
if (isFirstStart) {
changeLocalDeviceName();
changeDefaultFolder();
generateLoginInfo();
}
updateIfNeeded();
}
private void generateKeysConfig(Context context) {
new SyncthingRunnable(context, SyncthingRunnable.Command.generate).run();
}
public static File getConfigFile(Context context) {
return new File(context.getFilesDir(), CONFIG_FILE);
}
public URL getWebGuiUrl() {
try {
return new URL("https://" + getGuiElement().getElementsByTagName("address").item(0).getTextContent());
} catch (MalformedURLException e) {
throw new RuntimeException("Failed to parse web interface URL", e);
}
}
public String getApiKey() {
return getGuiElement().getElementsByTagName("apikey").item(0).getTextContent();
}
public String getUserName() {
return getGuiElement().getElementsByTagName("user").item(0).getTextContent();
}
/**
* Updates the config file.
* <p/>
* Sets ignorePerms flag to true on every folder.
*/
@SuppressWarnings("SdCardPath")
private void updateIfNeeded() {
Log.i(TAG, "Checking for needed config updates");
boolean changed = false;
NodeList folders = mConfig.getDocumentElement().getElementsByTagName("folder");
for (int i = 0; i < folders.getLength(); i++) {
Element r = (Element) folders.item(i);
// Set ignorePerms attribute.
if (!r.hasAttribute("ignorePerms") ||
!Boolean.parseBoolean(r.getAttribute("ignorePerms"))) {
Log.i(TAG, "Set 'ignorePerms' on folder " + r.getAttribute("id"));
r.setAttribute("ignorePerms", Boolean.toString(true));
changed = true;
}
if (applyHashers(r)) {
changed = true;
}
}
// Enforce TLS.
Element gui = (Element) mConfig.getDocumentElement()
.getElementsByTagName("gui").item(0);
boolean tls = Boolean.parseBoolean(gui.getAttribute("tls"));
if (!tls) {
Log.i(TAG, "Enforce TLS");
gui.setAttribute("tls", Boolean.toString(true));
changed = true;
}
if (changed) {
saveChanges();
}
}
/**
* Set 'hashers' (see https://github.com/syncthing/syncthing-android/issues/384) on the
* given folder.
*
* @return True if the XML was changed.
*/
private boolean applyHashers(Element folder) {
NodeList childs = folder.getChildNodes();
for (int i = 0; i < childs.getLength(); i++) {
Node item = childs.item(i);
if (item.getNodeName().equals("hashers")) {
if (item.getTextContent().equals(Integer.toString(0))) {
item.setTextContent(Integer.toString(1));
return true;
}
return false;
}
}
// XML tag does not exist, create it.
Log.i(TAG, "Set 'hashers' on folder " + folder.getAttribute("id"));
Element newElem = mConfig.createElement("hashers");
newElem.setTextContent(Integer.toString(1));
folder.appendChild(newElem);
return true;
}
private Element getGuiElement() {
return (Element) mConfig.getDocumentElement()
.getElementsByTagName("gui").item(0);
}
/**
* Set model name as device name for Syncthing.
*
* We need to iterate through XML nodes manually, as mConfig.getDocumentElement() will also
* return nested elements inside folder element.
*/
private void changeLocalDeviceName() {
NodeList childNodes = mConfig.getDocumentElement().getChildNodes();
for (int i = 0; i < childNodes.getLength(); i++) {
Node node = childNodes.item(i);
if (node.getNodeName().equals("device")) {
((Element) node).setAttribute("name", Build.MODEL);
}
}
saveChanges();
}
/**
* Change default folder id to camera and path to camera folder path.
*/
private void changeDefaultFolder() {
Element folder = (Element) mConfig.getDocumentElement()
.getElementsByTagName("folder").item(0);
String model = Build.MODEL
.replace(" ", "_")
.toLowerCase(Locale.US)
.replaceAll("[^a-z0-9_-]", "");
folder.setAttribute("label", mContext.getString(R.string.default_folder_label));
folder.setAttribute("id", mContext.getString(R.string.default_folder_id, model));
folder.setAttribute("path", Environment
.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath());
folder.setAttribute("type", "readonly");
saveChanges();
}
/**
* Generates username and config, stores them in config and preferences.
*
* We have to store the plaintext password in preferences, because we need it in
* WebGuiActivity. The password in the config is hashed, so we can't use it directly.
*/
private void generateLoginInfo() {
char[] chars =
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray();
StringBuilder password = new StringBuilder();
SecureRandom random = new SecureRandom();
for (int i = 0; i < 20; i++)
password.append(chars[random.nextInt(chars.length)]);
String user = Build.MODEL.replaceAll("[^a-zA-Z0-9 ]", "");
Log.i(TAG, "Generated GUI username and password (username is " + user + ")");
Node userNode = mConfig.createElement("user");
getGuiElement().appendChild(userNode);
userNode.setTextContent(user);
Node passwordNode = mConfig.createElement("password");
getGuiElement().appendChild(passwordNode);
String hashed = BCrypt.hashpw(password.toString(), BCrypt.gensalt());
passwordNode.setTextContent(hashed);
PreferenceManager.getDefaultSharedPreferences(mContext).edit()
.putString("web_gui_password", password.toString())
.apply();
}
/**
* Writes updated mConfig back to file.
*/
private void saveChanges() {
try {
Log.i(TAG, "Writing updated config back to file");
TransformerFactory transformerFactory = TransformerFactory.newInstance();
Transformer transformer = transformerFactory.newTransformer();
DOMSource domSource = new DOMSource(mConfig);
StreamResult streamResult = new StreamResult(mConfigFile);
transformer.transform(domSource, streamResult);
} catch (TransformerException e) {
Log.w(TAG, "Failed to save updated config", e);
}
}
}