6
6
import org .cryptomator .integrations .common .Priority ;
7
7
import org .cryptomator .integrations .quickaccess .QuickAccessService ;
8
8
import 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 ;
9
16
import org .xml .sax .SAXException ;
10
17
11
18
import 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 ;
12
24
import 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 ;
13
30
import javax .xml .transform .stream .StreamSource ;
14
31
import javax .xml .validation .SchemaFactory ;
15
32
import 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 ;
16
37
import java .io .IOException ;
17
38
import java .io .StringReader ;
39
+ import java .io .StringWriter ;
40
+ import java .nio .charset .StandardCharsets ;
18
41
import java .nio .file .Files ;
19
42
import java .nio .file .Path ;
20
- import java .util .List ;
21
43
import java .util .UUID ;
22
44
23
45
/**
29
51
@ Priority (90 )
30
52
public class DolphinPlaces extends FileConfiguredQuickAccess implements QuickAccessService {
31
53
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" ;
32
57
private static final int MAX_FILE_SIZE = 1 << 20 ; //1MiB, xml is quite verbose
33
58
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>""" ;
46
59
47
60
private static final Validator XML_VALIDATOR ;
48
61
@@ -61,29 +74,157 @@ public DolphinPlaces() {
61
74
super (PLACES_FILE , MAX_FILE_SIZE );
62
75
}
63
76
77
+ public DolphinPlaces (Path configFilePath ) {
78
+ super (configFilePath , MAX_FILE_SIZE );
79
+ }
80
+
64
81
@ Override
65
82
EntryAndConfig addEntryToConfig (String config , Path target , String displayName ) throws QuickAccessServiceException {
66
83
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 );
69
86
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 );
80
98
}
81
99
}
82
100
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
+ }
87
228
}
88
229
89
230
private class DolphinPlacesEntry extends FileConfiguredQuickAccessEntry implements QuickAccessEntry {
@@ -97,46 +238,20 @@ private class DolphinPlacesEntry extends FileConfiguredQuickAccessEntry implemen
97
238
@ Override
98
239
public String removeEntryFromConfig (String config ) throws QuickAccessServiceException {
99
240
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 ;
115
247
} catch (IOException | SAXException | IllegalStateException e ) {
116
248
throw new QuickAccessServiceException ("Removing entry from KDE places file failed." , e );
117
249
}
118
250
}
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
- }
136
251
}
137
252
138
253
@ CheckAvailability
139
254
public static boolean isSupported () {
140
255
return Files .exists (PLACES_FILE );
141
256
}
142
- }
257
+ }
0 commit comments