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 .DOMException ;
12+ import org .w3c .dom .Document ;
13+ import org .w3c .dom .Node ;
14+ import org .w3c .dom .NodeList ;
15+ import org .xml .sax .InputSource ;
916import org .xml .sax .SAXException ;
1017
1118import javax .xml .XMLConstants ;
19+ import javax .xml .namespace .QName ;
20+ import javax .xml .parsers .DocumentBuilder ;
21+ import javax .xml .parsers .DocumentBuilderFactory ;
22+ import javax .xml .parsers .ParserConfigurationException ;
23+ import javax .xml .transform .OutputKeys ;
1224import javax .xml .transform .Source ;
25+ import javax .xml .transform .Transformer ;
26+ import javax .xml .transform .TransformerException ;
27+ import javax .xml .transform .TransformerFactory ;
28+ import javax .xml .transform .dom .DOMSource ;
29+ import javax .xml .transform .stream .StreamResult ;
1330import javax .xml .transform .stream .StreamSource ;
1431import javax .xml .validation .SchemaFactory ;
1532import javax .xml .validation .Validator ;
33+ import javax .xml .xpath .XPathConstants ;
34+ import javax .xml .xpath .XPathExpressionException ;
35+ import javax .xml .xpath .XPathFactory ;
36+ import java .io .ByteArrayInputStream ;
1637import java .io .IOException ;
1738import java .io .StringReader ;
39+ import java .io .StringWriter ;
40+ import java .nio .charset .StandardCharsets ;
1841import java .nio .file .Files ;
1942import java .nio .file .Path ;
20- import java .util .List ;
2143import java .util .UUID ;
2244
2345/**
2951@ Priority (90 )
3052public class DolphinPlaces extends FileConfiguredQuickAccess implements QuickAccessService {
3153
54+ private static final Logger LOG = LoggerFactory .getLogger (DolphinPlaces .class );
55+
56+ private static final String XBEL_NAMESPACE = "http://www.freedesktop.org/standards/desktop-bookmarks" ;
3257 private static final int MAX_FILE_SIZE = 1 << 20 ; //1MiB, xml is quite verbose
3358 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>""" ;
4659
4760 private static final Validator XML_VALIDATOR ;
4861
@@ -61,29 +74,157 @@ public DolphinPlaces() {
6174 super (PLACES_FILE , MAX_FILE_SIZE );
6275 }
6376
77+ public DolphinPlaces (Path configFilePath ) {
78+ super (configFilePath , MAX_FILE_SIZE );
79+ }
80+
6481 @ Override
6582 EntryAndConfig addEntryToConfig (String config , Path target , String displayName ) throws QuickAccessServiceException {
6683 try {
67- String id = UUID .randomUUID ().toString ();
68- //validate
84+ var id = UUID .randomUUID ().toString ();
85+ LOG . trace ( "Adding bookmark for target: '{}', displayName: '{}', id: '{}'" , target , displayName , id );
6986 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 );
87+ var xmlDocument = loadXmlDocument (config );
88+ var nodeList = extractBookmarksByPath (target , xmlDocument );
89+ removeStaleBookmarks (nodeList );
90+ createBookmark (target , displayName , id , xmlDocument );
91+ var changedConfig = documentToString (xmlDocument );
92+ XML_VALIDATOR .validate (new StreamSource (new StringReader (changedConfig )));
93+ return new EntryAndConfig (new DolphinPlacesEntry (id ), changedConfig );
94+ } catch (SAXException e ) {
95+ throw new QuickAccessServiceException ("Invalid structure in xbel bookmark file" , e );
96+ } catch (IOException e ) {
97+ throw new QuickAccessServiceException ("Failed reading/writing the xbel bookmark file" , e );
8098 }
8199 }
82100
83- private String escapeXML (String s ) {
84- return s .replace ("&" ,"&" ) //
85- .replace ("<" ,"<" ) //
86- .replace (">" ,">" );
101+ private void removeStaleBookmarks (NodeList nodeList ) {
102+ for (int i = nodeList .getLength () - 1 ; i >= 0 ; i --) {
103+ Node node = nodeList .item (i );
104+ node .getParentNode ().removeChild (node );
105+ }
106+ }
107+
108+ private NodeList extractBookmarksByPath (Path target , Document xmlDocument ) throws QuickAccessServiceException {
109+ try {
110+ var xpathFactory = XPathFactory .newInstance ();
111+ var xpath = xpathFactory .newXPath ();
112+ xpath .setXPathVariableResolver (v -> {
113+ if (v .equals (new QName ("uri" ))) {
114+ return target .toUri ().toString ();
115+ }
116+ throw new IllegalArgumentException ();
117+ });
118+ var expression = "/xbel/bookmark[info/metadata[@owner='https://cryptomator.org']][@href=$uri]" ;
119+ return (NodeList ) xpath .compile (expression ).evaluate (xmlDocument , XPathConstants .NODESET );
120+ } catch (XPathExpressionException xee ) {
121+ throw new QuickAccessServiceException ("Invalid XPath expression" , xee );
122+ }
123+ }
124+
125+ private NodeList extractBookmarksById (String id , Document xmlDocument ) throws QuickAccessServiceException {
126+ try {
127+ var xpathFactory = XPathFactory .newInstance ();
128+ var xpath = xpathFactory .newXPath ();
129+ xpath .setXPathVariableResolver (v -> {
130+ if (v .equals (new QName ("id" ))) {
131+ return id ;
132+ }
133+ throw new IllegalArgumentException ();
134+ });
135+ var expression = "/xbel/bookmark[info/metadata[@owner='https://cryptomator.org']][info/metadata/id[text()=$id]]" ;
136+ return (NodeList ) xpath .compile (expression ).evaluate (xmlDocument , XPathConstants .NODESET );
137+ } catch (XPathExpressionException xee ) {
138+ throw new QuickAccessServiceException ("Invalid XPath expression" , xee );
139+ }
140+ }
141+
142+ private Document loadXmlDocument (String config ) throws QuickAccessServiceException {
143+ try {
144+ var builderFactory = DocumentBuilderFactory .newInstance ();
145+ builderFactory .setFeature (XMLConstants .FEATURE_SECURE_PROCESSING , true );
146+ builderFactory .setXIncludeAware (false );
147+ builderFactory .setExpandEntityReferences (false );
148+ builderFactory .setAttribute (XMLConstants .ACCESS_EXTERNAL_DTD , "" );
149+ builderFactory .setAttribute (XMLConstants .ACCESS_EXTERNAL_SCHEMA , "" );
150+ builderFactory .setNamespaceAware (true );
151+ DocumentBuilder builder = builderFactory .newDocumentBuilder ();
152+ // Prevent external entities from being resolved
153+ builder .setEntityResolver ((publicId , systemId ) -> new InputSource (new StringReader ("" )));
154+ return builder .parse (new ByteArrayInputStream (config .getBytes (StandardCharsets .UTF_8 )));
155+ } catch (IOException | SAXException | ParserConfigurationException e ) {
156+ throw new QuickAccessServiceException ("Error while loading xml file" , e );
157+ }
158+ }
159+
160+ private String documentToString (Document xmlDocument ) throws QuickAccessServiceException {
161+ try {
162+ var buf = new StringWriter ();
163+ Transformer transformer = TransformerFactory .newInstance ().newTransformer ();
164+ transformer .setOutputProperty (OutputKeys .DOCTYPE_PUBLIC , "" );
165+ transformer .setOutputProperty (OutputKeys .DOCTYPE_SYSTEM , "" );
166+ transformer .setOutputProperty (OutputKeys .OMIT_XML_DECLARATION , "no" );
167+ transformer .setOutputProperty (OutputKeys .INDENT , "yes" );
168+ transformer .setOutputProperty (OutputKeys .ENCODING , StandardCharsets .UTF_8 .name ());
169+ transformer .transform (new DOMSource (xmlDocument ), new StreamResult (buf ));
170+ var content = buf .toString ();
171+ content = content .replaceFirst ("\\ s*standalone=\" (yes|no)\" " , "" );
172+ content = content .replaceFirst ("<!DOCTYPE xbel PUBLIC \" \" \" \" >" ,"<!DOCTYPE xbel>" );
173+ return content ;
174+ } catch (TransformerException e ) {
175+ throw new QuickAccessServiceException ("Error while serializing document to string" , e );
176+ }
177+ }
178+
179+ /**
180+ *
181+ * Adds a xml bookmark element to the specified xml document
182+ *
183+ * <pre>{@code
184+ * <bookmark href="file:///home/someuser/folder1/">
185+ * <title>integrations-linux</title>
186+ * <info>
187+ * <metadata owner="http://freedesktop.org">
188+ * <bookmark:icon name="drive-harddisk-encrypted"/>
189+ * </metadata>
190+ * <metadata owner="https://cryptomator.org">
191+ * <id>sldkf-sadf-sadf-sadf</id>
192+ * </metadata>
193+ * </info>
194+ * </bookmark>
195+ * }</pre>
196+ *
197+ * @param target The mount point of the vault
198+ * @param displayName Caption of the vault link in dolphin
199+ * @param xmlDocument The xbel document to which the bookmark should be added
200+ *
201+ * @throws QuickAccessServiceException if the bookmark could not be created
202+ */
203+ private void createBookmark (Path target , String displayName , String id , Document xmlDocument ) throws QuickAccessServiceException {
204+ try {
205+ var bookmark = xmlDocument .createElement ("bookmark" );
206+ var title = xmlDocument .createElement ("title" );
207+ var info = xmlDocument .createElement ("info" );
208+ var metadataBookmark = xmlDocument .createElement ("metadata" );
209+ var metadataOwner = xmlDocument .createElement ("metadata" );
210+ var bookmarkIcon = xmlDocument .createElementNS (XBEL_NAMESPACE , "bookmark:icon" );
211+ var idElem = xmlDocument .createElement ("id" );
212+ bookmark .setAttribute ("href" , target .toUri ().toString ());
213+ title .setTextContent (displayName );
214+ bookmark .appendChild (title );
215+ bookmark .appendChild (info );
216+ info .appendChild (metadataBookmark );
217+ info .appendChild (metadataOwner );
218+ metadataBookmark .appendChild (bookmarkIcon );
219+ metadataOwner .appendChild (idElem );
220+ metadataBookmark .setAttribute ("owner" , "http://freedesktop.org" );
221+ bookmarkIcon .setAttribute ("name" ,"drive-harddisk-encrypted" );
222+ metadataOwner .setAttribute ("owner" , "https://cryptomator.org" );
223+ idElem .setTextContent (id );
224+ xmlDocument .getDocumentElement ().appendChild (bookmark );
225+ } catch (DOMException | IllegalArgumentException e ) {
226+ throw new QuickAccessServiceException ("Error while creating bookmark for target: " + target , e );
227+ }
87228 }
88229
89230 private class DolphinPlacesEntry extends FileConfiguredQuickAccessEntry implements QuickAccessEntry {
@@ -97,46 +238,20 @@ private class DolphinPlacesEntry extends FileConfiguredQuickAccessEntry implemen
97238 @ Override
98239 public String removeEntryFromConfig (String config ) throws QuickAccessServiceException {
99240 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
105- XML_VALIDATOR .validate (new StreamSource (new StringReader (config )));
106- //modify
107- int openingTagIndex = indexOfEntryOpeningTag (config , idIndex );
108- var contentToWrite1 = config .substring (0 , openingTagIndex ).stripTrailing ();
109-
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 ];
113-
114- return contentToWrite1 + "\n " + contentToWrite2 ;
241+ var xmlDocument = loadXmlDocument (config );
242+ var nodeList = extractBookmarksById (id , xmlDocument );
243+ removeStaleBookmarks (nodeList );
244+ var changedConfig = documentToString (xmlDocument );
245+ XML_VALIDATOR .validate (new StreamSource (new StringReader (changedConfig )));
246+ return changedConfig ;
115247 } catch (IOException | SAXException | IllegalStateException e ) {
116248 throw new QuickAccessServiceException ("Removing entry from KDE places file failed." , e );
117249 }
118250 }
119-
120- /**
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
125- */
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." );
135- }
136251 }
137252
138253 @ CheckAvailability
139254 public static boolean isSupported () {
140255 return Files .exists (PLACES_FILE );
141256 }
142- }
257+ }
0 commit comments