66import org .cryptomator .integrations .common .Priority ;
77import org .cryptomator .integrations .quickaccess .QuickAccessService ;
88import org .cryptomator .integrations .quickaccess .QuickAccessServiceException ;
9+ import org .slf4j .Logger ;
10+ import org .slf4j .LoggerFactory ;
11+ import org .w3c .dom .Document ;
12+ import org .w3c .dom .Node ;
13+ import org .w3c .dom .NodeList ;
14+ import org .xml .sax .InputSource ;
915import org .xml .sax .SAXException ;
1016
1117import javax .xml .XMLConstants ;
18+ import javax .xml .namespace .QName ;
19+ import javax .xml .parsers .DocumentBuilder ;
20+ import javax .xml .parsers .DocumentBuilderFactory ;
21+ import javax .xml .transform .OutputKeys ;
1222import javax .xml .transform .Source ;
23+ import javax .xml .transform .Transformer ;
24+ import javax .xml .transform .TransformerFactory ;
25+ import javax .xml .transform .dom .DOMSource ;
26+ import javax .xml .transform .stream .StreamResult ;
1327import javax .xml .transform .stream .StreamSource ;
1428import javax .xml .validation .SchemaFactory ;
1529import javax .xml .validation .Validator ;
30+ import javax .xml .xpath .XPath ;
31+ import javax .xml .xpath .XPathConstants ;
32+ import javax .xml .xpath .XPathFactory ;
33+ import javax .xml .xpath .XPathVariableResolver ;
34+ import java .io .ByteArrayInputStream ;
1635import java .io .IOException ;
1736import java .io .StringReader ;
37+ import java .io .StringWriter ;
38+ import java .nio .charset .StandardCharsets ;
1839import java .nio .file .Files ;
1940import java .nio .file .Path ;
20- import java .util .List ;
41+ import java .util .ArrayList ;
42+ import java .util .HashMap ;
43+ import java .util .Map ;
2144import java .util .UUID ;
2245
2346/**
2952@ Priority (90 )
3053public class DolphinPlaces extends FileConfiguredQuickAccess implements QuickAccessService {
3154
55+ private static final Logger LOG = LoggerFactory .getLogger (DolphinPlaces .class );
56+
57+ private static final String XBEL_NAMESPACE = "http://www.freedesktop.org/standards/desktop-bookmarks" ;
3258 private static final int MAX_FILE_SIZE = 1 << 20 ; //1MiB, xml is quite verbose
33- private static final Path PLACES_FILE = Path .of (System .getProperty ("user.home" ), ".local/share/user-places.xbel" );
34- private static final String ENTRY_TEMPLATE = """
35- <bookmark href=\" %s\" >
36- <title>%s</title>
37- <info>
38- <metadata owner=\" http://freedesktop.org\" >
39- <bookmark:icon name="drive-harddisk-encrypted"/>
40- </metadata>
41- <metadata owner=\"https://cryptomator.org\">
42- <id>%s</id>
43- </metadata>
44- </info>
45- </bookmark>""" ;
59+ private static final String HOME_DIR = System .getProperty ("user.home" );
60+ private static final String CONFIG_PATH_IN_HOME = ".local/share" ;
61+ private static final String CONFIG_FILE_NAME = "user-places.xbel" ;
62+ private static final Path PLACES_FILE = Path .of (HOME_DIR ,CONFIG_PATH_IN_HOME , CONFIG_FILE_NAME );
4663
4764 private static final Validator XML_VALIDATOR ;
4865
4966 static {
50- SchemaFactory factory = SchemaFactory . newInstance ( XMLConstants . W3C_XML_SCHEMA_NS_URI );
67+
5168 try (var schemaDefinition = DolphinPlaces .class .getResourceAsStream ("/xbel-1.0.xsd" )) {
69+
70+ SchemaFactory factory = SchemaFactory .newInstance (XMLConstants .W3C_XML_SCHEMA_NS_URI );
71+
72+ factory .setProperty (XMLConstants .ACCESS_EXTERNAL_DTD , "" );
73+ factory .setProperty (XMLConstants .ACCESS_EXTERNAL_SCHEMA , "" );
74+
5275 Source schemaFile = new StreamSource (schemaDefinition );
5376 XML_VALIDATOR = factory .newSchema (schemaFile ).newValidator ();
77+
5478 } catch (IOException | SAXException e ) {
5579 throw new IllegalStateException ("Failed to load included XBEL schema definition file." , e );
5680 }
@@ -61,29 +85,200 @@ public DolphinPlaces() {
6185 super (PLACES_FILE , MAX_FILE_SIZE );
6286 }
6387
88+ public DolphinPlaces (Path configFilePath ) {
89+ super (configFilePath .resolve (CONFIG_FILE_NAME ), MAX_FILE_SIZE );
90+ }
91+
6492 @ Override
6593 EntryAndConfig addEntryToConfig (String config , Path target , String displayName ) throws QuickAccessServiceException {
94+
6695 try {
96+
6797 String id = UUID .randomUUID ().toString ();
68- //validate
98+
99+ LOG .trace ("Adding bookmark for target: '{}', displayName: '{}', id: '{}'" , target , displayName , id );
100+
101+ // Validate the existing config before modifying it, if it is invalid
102+ // we should not modify it.
69103 XML_VALIDATOR .validate (new StreamSource (new StringReader (config )));
70- // modify
71- int insertIndex = config .lastIndexOf ("</xbel" ); //cannot be -1 due to validation; we do not match the whole end tag, since between tag name and closing bracket can be whitespaces
72- var adjustedConfig = config .substring (0 , insertIndex ) //
73- + "\n " //
74- + ENTRY_TEMPLATE .formatted (target .toUri (), escapeXML (displayName ), id ).indent (1 ) //
75- + "\n " //
76- + config .substring (insertIndex );
77- return new EntryAndConfig (new DolphinPlacesEntry (id ), adjustedConfig );
78- } catch (SAXException | IOException e ) {
79- throw new QuickAccessServiceException ("Adding entry to KDE places file failed." , e );
104+
105+ Document xmlDocument = loadXmlDocument (config );
106+
107+ NodeList nodeList = extractBookmarksByPath (target , xmlDocument );
108+
109+ removeStaleBookmarks (nodeList );
110+
111+ createBookmark (target , displayName , id , xmlDocument );
112+
113+ XML_VALIDATOR .validate (new DOMSource (xmlDocument ));
114+
115+ return new EntryAndConfig (new DolphinPlacesEntry (id ), documentToString (xmlDocument ));
116+
117+ } catch (SAXException e ) {
118+ throw new QuickAccessServiceException ("Invalid structure in xbel bookmark file" , e );
119+ } catch (IOException e ) {
120+ throw new QuickAccessServiceException ("Failed reading/writing the xbel bookmark file" , e );
121+ }
122+ }
123+
124+ private void removeStaleBookmarks (NodeList nodeList ) {
125+
126+ for (int i = nodeList .getLength () - 1 ; i >= 0 ; i --) {
127+ Node node = nodeList .item (i );
128+ node .getParentNode ().removeChild (node );
129+ }
130+ }
131+
132+ private NodeList extractBookmarksByPath (Path target , Document xmlDocument ) throws QuickAccessServiceException {
133+
134+ try {
135+
136+ XPathFactory xpathFactory = XPathFactory .newInstance ();
137+ XPath xpath = xpathFactory .newXPath ();
138+
139+ SimpleVariableResolver variableResolver = new SimpleVariableResolver ();
140+
141+ variableResolver .addVariable (new QName ("uri" ), target .toUri ().toString ());
142+
143+ xpath .setXPathVariableResolver (variableResolver );
144+
145+ String expression = "/xbel/bookmark[info/metadata[@owner='https://cryptomator.org']][@href=$uri]" ;
146+
147+ return (NodeList ) xpath .compile (expression ).evaluate (xmlDocument , XPathConstants .NODESET );
148+
149+ } catch (Exception e ) {
150+ throw new QuickAccessServiceException ("Failed to extract bookmarks by path" , e );
151+ }
152+ }
153+
154+ private NodeList extractBookmarksById (String id , Document xmlDocument ) throws QuickAccessServiceException {
155+
156+ try {
157+
158+ XPathFactory xpathFactory = XPathFactory .newInstance ();
159+ XPath xpath = xpathFactory .newXPath ();
160+
161+ SimpleVariableResolver variableResolver = new SimpleVariableResolver ();
162+
163+ variableResolver .addVariable (new QName ("id" ), id );
164+
165+ xpath .setXPathVariableResolver (variableResolver );
166+
167+ String expression = "/xbel/bookmark[info/metadata[@owner='https://cryptomator.org']][info/metadata/id[text()=$id]]" ;
168+
169+ return (NodeList ) xpath .compile (expression ).evaluate (xmlDocument , XPathConstants .NODESET );
170+
171+ } catch (Exception e ) {
172+ throw new QuickAccessServiceException ("Failed to extract bookmarks by id" , e );
80173 }
81174 }
82175
83- private String escapeXML (String s ) {
84- return s .replace ("&" ,"&" ) //
85- .replace ("<" ,"<" ) //
86- .replace (">" ,">" );
176+ private Document loadXmlDocument (String config ) throws QuickAccessServiceException {
177+
178+ try {
179+
180+ DocumentBuilderFactory builderFactory = DocumentBuilderFactory .newInstance ();
181+
182+ builderFactory .setFeature (XMLConstants .FEATURE_SECURE_PROCESSING , true );
183+ builderFactory .setFeature ("http://xml.org/sax/features/external-general-entities" , false );
184+ builderFactory .setFeature ("http://xml.org/sax/features/external-parameter-entities" , false );
185+ builderFactory .setXIncludeAware (false );
186+ builderFactory .setExpandEntityReferences (false );
187+ builderFactory .setAttribute (XMLConstants .ACCESS_EXTERNAL_DTD , "" );
188+ builderFactory .setAttribute (XMLConstants .ACCESS_EXTERNAL_SCHEMA , "" );
189+ builderFactory .setNamespaceAware (true );
190+
191+ DocumentBuilder builder = builderFactory .newDocumentBuilder ();
192+
193+ // Prevent external entities from being resolved
194+ builder .setEntityResolver ((publicId , systemId ) -> new InputSource (new StringReader ("" )));
195+
196+ return builder .parse (new ByteArrayInputStream (config .getBytes (StandardCharsets .UTF_8 )));
197+
198+ } catch (Exception e ) {
199+ throw new QuickAccessServiceException ("Failed to parse the xbel bookmark file" , e );
200+ }
201+ }
202+
203+ private String documentToString (Document xmlDocument ) throws QuickAccessServiceException {
204+
205+ try {
206+
207+ StringWriter buf = new StringWriter ();
208+
209+ Transformer xform = TransformerFactory .newInstance ().newTransformer ();
210+ xform .setOutputProperty (OutputKeys .OMIT_XML_DECLARATION , "yes" );
211+ xform .setOutputProperty (OutputKeys .INDENT , "yes" );
212+ xform .setOutputProperty (OutputKeys .ENCODING , StandardCharsets .UTF_8 .name ());
213+ xform .transform (new DOMSource (xmlDocument ), new StreamResult (buf ));
214+
215+ return buf .toString ();
216+
217+ } catch (Exception e ) {
218+ throw new QuickAccessServiceException ("Failed to read document into string" , e );
219+ }
220+ }
221+
222+ /**
223+ *
224+ * Adds a xml bookmark element to the specified xml document
225+ *
226+ * <pre>{@code
227+ * <bookmark href="file:///home/someuser/folder1/">
228+ * <title>integrations-linux</title>
229+ * <info>
230+ * <metadata owner="http://freedesktop.org">
231+ * <bookmark:icon name="drive-harddisk-encrypted"/>
232+ * </metadata>
233+ * <metadata owner="https://cryptomator.org">
234+ * <id>sldkf-sadf-sadf-sadf</id>
235+ * </metadata>
236+ * </info>
237+ * </bookmark>
238+ * }</pre>
239+ *
240+ * @param target The mount point of the vault
241+ * @param displayName Caption of the vault link in dolphin
242+ * @param xmlDocument The xbel document to which the bookmark should be added
243+ *
244+ * @throws QuickAccessServiceException if the bookmark could not be created
245+ */
246+ private void createBookmark (Path target , String displayName , String id , Document xmlDocument ) throws QuickAccessServiceException {
247+
248+ try {
249+ var bookmark = xmlDocument .createElement ("bookmark" );
250+ var title = xmlDocument .createElement ("title" );
251+ var info = xmlDocument .createElement ("info" );
252+ var metadataBookmark = xmlDocument .createElement ("metadata" );
253+ var metadataOwner = xmlDocument .createElement ("metadata" );
254+ var bookmarkIcon = xmlDocument .createElementNS (XBEL_NAMESPACE , "bookmark:icon" );
255+ var idElem = xmlDocument .createElement ("id" );
256+
257+ bookmark .setAttribute ("href" , target .toUri ().toString ());
258+
259+ title .setTextContent (displayName );
260+
261+ bookmark .appendChild (title );
262+ bookmark .appendChild (info );
263+
264+ info .appendChild (metadataBookmark );
265+ info .appendChild (metadataOwner );
266+
267+ metadataBookmark .appendChild (bookmarkIcon );
268+ metadataOwner .appendChild (idElem );
269+
270+ metadataBookmark .setAttribute ("owner" , "http://freedesktop.org" );
271+
272+ bookmarkIcon .setAttribute ("name" ,"drive-harddisk-encrypted" );
273+
274+ metadataOwner .setAttribute ("owner" , "https://cryptomator.org" );
275+
276+ idElem .setTextContent (id );
277+ xmlDocument .getDocumentElement ().appendChild (bookmark );
278+
279+ } catch (Exception e ) {
280+ throw new QuickAccessServiceException ("Failed to insert bookmark for target: " + target , e );
281+ }
87282 }
88283
89284 private class DolphinPlacesEntry extends FileConfiguredQuickAccessEntry implements QuickAccessEntry {
@@ -96,42 +291,52 @@ private class DolphinPlacesEntry extends FileConfiguredQuickAccessEntry implemen
96291
97292 @ Override
98293 public String removeEntryFromConfig (String config ) throws QuickAccessServiceException {
294+
99295 try {
100- int idIndex = config .lastIndexOf (id );
101- if (idIndex == -1 ) {
102- return config ; //assume someone has removed our entry, nothing to do
103- }
104- //validate
296+
105297 XML_VALIDATOR .validate (new StreamSource (new StringReader (config )));
106- //modify
107- int openingTagIndex = indexOfEntryOpeningTag (config , idIndex );
108- var contentToWrite1 = config .substring (0 , openingTagIndex ).stripTrailing ();
109298
110- int closingTagEndIndex = config .indexOf ('>' , config .indexOf ("</bookmark" , idIndex ));
111- var part2Tmp = config .substring (closingTagEndIndex + 1 ).split ("\\ A\\ v+" , 2 ); //removing leading vertical whitespaces, but no indentation
112- var contentToWrite2 = part2Tmp [part2Tmp .length - 1 ];
299+ Document xmlDocument = loadXmlDocument (config );
300+
301+ NodeList nodeList = extractBookmarksById (id , xmlDocument );
302+
303+ removeStaleBookmarks (nodeList );
304+
305+ XML_VALIDATOR .validate (new DOMSource (xmlDocument ));
306+
307+ return documentToString (xmlDocument );
113308
114- return contentToWrite1 + "\n " + contentToWrite2 ;
115309 } catch (IOException | SAXException | IllegalStateException e ) {
116310 throw new QuickAccessServiceException ("Removing entry from KDE places file failed." , e );
117311 }
118312 }
313+ }
314+
315+ /**
316+ * Resolver in order to define parameter for XPATH expression.
317+ */
318+ private class SimpleVariableResolver implements XPathVariableResolver {
319+
320+ private final Map <QName , Object > vars = new HashMap <>();
119321
120322 /**
121- * Returns the start index (inclusive) of the {@link DolphinPlaces#ENTRY_TEMPLATE} entry
122- * @param placesContent the content of the XBEL places file
123- * @param idIndex start index (inclusive) of the entrys id tag value
124- * @return start index of the first bookmark tag, searching backwards from idIndex
323+ * Adds a variable to the resolver.
324+ *
325+ * @param name The name of the variable
326+ * @param value The value of the variable
125327 */
126- private int indexOfEntryOpeningTag (String placesContent , int idIndex ) {
127- var xmlWhitespaceChars = List .of (' ' , '\t' , '\n' );
128- for (char c : xmlWhitespaceChars ) {
129- int idx = placesContent .lastIndexOf ("<bookmark" + c , idIndex ); //with the whitespace we ensure, that no tags starting with "bookmark" (e.g. bookmarkz) are selected
130- if (idx != -1 ) {
131- return idx ;
132- }
133- }
134- throw new IllegalStateException ("Found entry id " + id + " in " + PLACES_FILE + ", but it is not a child of <bookmark> tag." );
328+ public void addVariable (QName name , Object value ) {
329+ vars .put (name , value );
330+ }
331+
332+ /**
333+ * Resolves a variable by its name.
334+ *
335+ * @param variableName The name of the variable to resolve
336+ * @return The value of the variable, or null if not found
337+ */
338+ public Object resolveVariable (QName variableName ) {
339+ return vars .get (variableName );
135340 }
136341 }
137342
0 commit comments