Browse Source

WIP Webdav integration

Guénaël Muller 7 years ago
parent
commit
df85d0a977

+ 4 - 0
setup.py View File

28
     'marshmallow <3.0.0a1,>2.0.0',
28
     'marshmallow <3.0.0a1,>2.0.0',
29
     # CLI
29
     # CLI
30
     'cliff',
30
     'cliff',
31
+    # Webdav
32
+    'wsgidav',
33
+    'PyYAML',
31
     # others
34
     # others
32
     'filedepot',
35
     'filedepot',
33
     'babel',
36
     'babel',
96
             'user_create = tracim.command.user:CreateUserCommand',
99
             'user_create = tracim.command.user:CreateUserCommand',
97
             'user_update = tracim.command.user:UpdateUserCommand',
100
             'user_update = tracim.command.user:UpdateUserCommand',
98
             'db_init = tracim.command.initializedb:InitializeDBCommand',
101
             'db_init = tracim.command.initializedb:InitializeDBCommand',
102
+            'webdav start = tracim.command.webdav:WebdavRunnerCommand',
99
         ]
103
         ]
100
     },
104
     },
101
 )
105
 )

+ 5 - 2
tracim/command/__init__.py View File

10
 from pyramid.paster import bootstrap
10
 from pyramid.paster import bootstrap
11
 from pyramid.scripting import AppEnvironment
11
 from pyramid.scripting import AppEnvironment
12
 from tracim.exceptions import CommandAbortedError
12
 from tracim.exceptions import CommandAbortedError
13
+from tracim.lib.utils.utils import DEFAULT_TRACIM_CONFIG_FILE
13
 
14
 
14
 
15
 
15
 class TracimCLI(App):
16
 class TracimCLI(App):
61
         parser.add_argument(
62
         parser.add_argument(
62
             "-c",
63
             "-c",
63
             "--config",
64
             "--config",
64
-            help='application config file to read (default: development.ini)',
65
+            help='application config file to read (default: {})'.format(
66
+                DEFAULT_TRACIM_CONFIG_FILE
67
+            ),
65
             dest='config_file',
68
             dest='config_file',
66
-            default="development.ini"
69
+            default=DEFAULT_TRACIM_CONFIG_FILE,
67
         )
70
         )
68
         return parser
71
         return parser
69
 
72
 

+ 29 - 0
tracim/command/webdav.py View File

1
+# -*- coding: utf-8 -*-
2
+import argparse
3
+
4
+import plaster_pastedeploy
5
+from waitress import serve
6
+
7
+from tracim.command import AppContextCommand
8
+from tracim.lib.webdav import WebdavAppFactory
9
+
10
+
11
+class WebdavRunnerCommand(AppContextCommand):
12
+    auto_setup_context = False
13
+
14
+    def get_description(self) -> str:
15
+        return "run webdav server"
16
+
17
+    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
18
+        parser = super().get_parser(prog_name)
19
+        return parser
20
+
21
+    def take_action(self, parsed_args: argparse.Namespace) -> None:
22
+        super(WebdavRunnerCommand, self).take_action(parsed_args)
23
+        tracim_config = parsed_args.config_file
24
+        # TODO - G.M - 16-04-2018 - Allow specific webdav config file
25
+        app_factory = WebdavAppFactory(
26
+            tracim_config_file_path=tracim_config,
27
+        )
28
+        app = app_factory.get_wsgi_app()
29
+        serve(app)

+ 4 - 0
tracim/exceptions.py View File

79
 
79
 
80
 class ImmutableAttribute(TracimException):
80
 class ImmutableAttribute(TracimException):
81
     pass
81
     pass
82
+
83
+
84
+class DigestAuthNotImplemented(Exception):
85
+    pass

+ 3 - 0
tracim/lib/utils/utils.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 import datetime
2
 import datetime
3
 
3
 
4
+DEFAULT_WEBDAV_CONFIG_FILE = "wsgidav.conf"
5
+DEFAULT_TRACIM_CONFIG_FILE = "development.ini"
6
+
4
 
7
 
5
 def cmp_to_key(mycmp):
8
 def cmp_to_key(mycmp):
6
     """
9
     """

+ 145 - 0
tracim/lib/webdav/__init__.py View File

1
+import json
2
+import sys
3
+import os
4
+from pyramid.paster import get_appsettings
5
+from waitress import serve
6
+from wsgidav.wsgidav_app import DEFAULT_CONFIG
7
+from wsgidav.xml_tools import useLxml
8
+from wsgidav.wsgidav_app import WsgiDAVApp
9
+
10
+from tracim import CFG
11
+from tracim.lib.utils.utils import DEFAULT_TRACIM_CONFIG_FILE, \
12
+    DEFAULT_WEBDAV_CONFIG_FILE
13
+from tracim.lib.webdav.dav_provider import Provider
14
+from tracim.lib.webdav.authentification import TracimDomainController
15
+from wsgidav.dir_browser import WsgiDavDirBrowser
16
+from wsgidav.http_authenticator import HTTPAuthenticator
17
+from wsgidav.error_printer import ErrorPrinter
18
+from tracim.lib.webdav.middlewares import TracimWsgiDavDebugFilter, \
19
+    TracimEnforceHTTPS, TracimEnv, TracimUserSession
20
+
21
+from inspect import isfunction
22
+import traceback
23
+
24
+from tracim.models import get_engine, get_session_factory
25
+
26
+
27
+class WebdavAppFactory(object):
28
+
29
+    def __init__(self,
30
+                 webdav_config_file_path: str = None,
31
+                 tracim_config_file_path: str = None,
32
+                 ):
33
+        self.config = self._initConfig(
34
+            webdav_config_file_path,
35
+            tracim_config_file_path
36
+        )
37
+
38
+    def _initConfig(self,
39
+                    webdav_config_file_path: str = None,
40
+                    tracim_config_file_path: str = None
41
+                    ):
42
+        """Setup configuration dictionary from default,
43
+         command line and configuration file."""
44
+        if not webdav_config_file_path:
45
+            webdav_config_file_path = DEFAULT_WEBDAV_CONFIG_FILE
46
+        if not tracim_config_file_path:
47
+            tracim_config_file_path = DEFAULT_TRACIM_CONFIG_FILE
48
+
49
+        # Set config defaults
50
+        config = DEFAULT_CONFIG.copy()
51
+        temp_verbose = config["verbose"]
52
+
53
+        default_config_file = os.path.abspath(webdav_config_file_path)
54
+        webdav_config_file = self._readConfigFile(
55
+            webdav_config_file_path,
56
+            temp_verbose
57
+            )
58
+        # Configuration file overrides defaults
59
+        config.update(webdav_config_file)
60
+
61
+        # Get pyramid Env
62
+        tracim_config_file_path = os.path.abspath(tracim_config_file_path)
63
+        config['tracim_config'] = tracim_config_file_path
64
+        settings = get_appsettings(config['tracim_config'])
65
+        app_config = CFG(settings)
66
+
67
+        if not useLxml and config["verbose"] >= 1:
68
+            print(
69
+                "WARNING: Could not import lxml: using xml instead (slower). "
70
+                "consider installing lxml from http://codespeak.net/lxml/."
71
+            )
72
+
73
+        config['middleware_stack'] = [
74
+            TracimEnforceHTTPS,
75
+            WsgiDavDirBrowser,
76
+            TracimUserSession,
77
+            HTTPAuthenticator,
78
+            TracimEnv,
79
+            ErrorPrinter,
80
+            TracimWsgiDavDebugFilter,
81
+
82
+        ]
83
+        config['provider_mapping'] = {
84
+            config['root_path']: Provider(
85
+                # TODO: Test to Re enabme archived and deleted
86
+                show_archived=False,  # config['show_archived'],
87
+                show_deleted=False,  # config['show_deleted'],
88
+                show_history=False,  # config['show_history'],
89
+                app_config=app_config,
90
+            )
91
+        }
92
+
93
+        config['domaincontroller'] = TracimDomainController(
94
+            presetdomain=None,
95
+            presetserver=None,
96
+            app_config = app_config,
97
+        )
98
+        return config
99
+
100
+    # INFO - G.M - 13-04-2018 - Copy from
101
+    # wsgidav.server.run_server._readConfigFile
102
+    def _readConfigFile(self, config_file, verbose):
103
+        """Read configuration file options into a dictionary."""
104
+
105
+        if not os.path.exists(config_file):
106
+            raise RuntimeError("Couldn't open configuration file '%s'." % config_file)
107
+
108
+        if config_file.endswith(".json"):
109
+            with open(config_file, mode="r", encoding="utf-8") as json_file:
110
+                return json.load(json_file)
111
+
112
+        try:
113
+            import imp
114
+            conf = {}
115
+            configmodule = imp.load_source("configuration_module", config_file)
116
+
117
+            for k, v in vars(configmodule).items():
118
+                if k.startswith("__"):
119
+                    continue
120
+                elif isfunction(v):
121
+                    continue
122
+                conf[k] = v
123
+        except Exception as e:
124
+            # if verbose >= 1:
125
+            #    traceback.print_exc()
126
+            exceptioninfo = traceback.format_exception_only(sys.exc_type, sys.exc_value)
127
+            exceptiontext = ""
128
+            for einfo in exceptioninfo:
129
+                exceptiontext += einfo + "\n"
130
+    #        raise RuntimeError("Failed to read configuration file: " + config_file + "\nDue to "
131
+    #            + exceptiontext)
132
+            print("Failed to read configuration file: " + config_file +
133
+                  "\nDue to " + exceptiontext, file=sys.stderr)
134
+            raise
135
+
136
+        return conf
137
+
138
+    def get_wsgi_app(self):
139
+        return WsgiDAVApp(self.config)
140
+
141
+
142
+if __name__ == '__main__':
143
+    app_factory = WebdavAppFactory()
144
+    app = app_factory.get_wsgi_app()
145
+    serve(app)

+ 49 - 0
tracim/lib/webdav/authentification.py View File

1
+# coding: utf8
2
+from tracim.exceptions import DigestAuthNotImplemented
3
+from tracim.lib.core.user import UserApi
4
+
5
+DEFAULT_TRACIM_WEBDAV_REALM = '/'
6
+
7
+
8
+class TracimDomainController(object):
9
+    """
10
+    The domain controller is used by http_authenticator to authenticate the user every time a request is
11
+    sent
12
+    """
13
+    def __init__(self, app_config, presetdomain=None, presetserver=None):
14
+        self.app_config = app_config
15
+
16
+    def getDomainRealm(self, inputURL, environ):
17
+        return DEFAULT_TRACIM_WEBDAV_REALM
18
+
19
+    def getRealmUserPassword(self, realmname, username, environ):
20
+        """
21
+        This method is normally only use for digest auth. wsgidav need
22
+        plain password to deal with it. as we didn't
23
+        provide support for this kind of auth, this method raise an exception.
24
+        """
25
+        raise DigestAuthNotImplemented
26
+
27
+    def requireAuthentication(self, realmname, environ):
28
+        return True
29
+
30
+    def isRealmUser(self, realmname, username, environ):
31
+        """
32
+        Called to check if for a given root, the username exists (though here we don't make difference between
33
+        root as we're always starting at tracim's root
34
+        """
35
+        api = UserApi(None, environ['tracim_dbsession'], self.app_config)
36
+        try:
37
+             api.get_one_by_email(username)
38
+             return True
39
+        except:
40
+             return False
41
+
42
+    def authDomainUser(self, realmname, username, password, environ):
43
+        """
44
+        If you ever feel the need to send a request al-mano with a curl, this is the function that'll be called by
45
+        http_authenticator to validate the password sent
46
+        """
47
+        api = UserApi(None, environ['tracim_dbsession'], self.app_config)
48
+        return self.isRealmUser(realmname, username, environ) and \
49
+             api.get_one_by_email(username).validate_password(password)

+ 320 - 0
tracim/lib/webdav/dav_provider.py View File

1
+# coding: utf8
2
+
3
+import re
4
+from os.path import basename, dirname
5
+
6
+from sqlalchemy.orm.exc import NoResultFound
7
+
8
+from tracim import CFG
9
+from tracim.lib.webdav.utils import transform_to_bdd, HistoryType, \
10
+    SpecialFolderExtension
11
+
12
+from wsgidav.dav_provider import DAVProvider
13
+from wsgidav.lock_manager import LockManager
14
+
15
+
16
+from tracim.lib.webdav.lock_storage import LockStorage
17
+from tracim.lib.core.content import ContentApi
18
+from tracim.lib.core.content import ContentRevisionRO
19
+from tracim.lib.core.workspace import WorkspaceApi
20
+from tracim.lib.webdav import resources
21
+from tracim.lib.webdav.utils import normpath
22
+from tracim.models.data import ContentType, Content, Workspace
23
+
24
+
25
+class Provider(DAVProvider):
26
+    """
27
+    This class' role is to provide to wsgidav _DAVResource. Wsgidav will then use them to execute action and send
28
+    informations to the client
29
+    """
30
+
31
+    def __init__(
32
+            self,
33
+            app_config: CFG,
34
+            show_history=True,
35
+            show_deleted=True,
36
+            show_archived=True,
37
+            manage_locks=True,
38
+    ):
39
+        super(Provider, self).__init__()
40
+
41
+        if manage_locks:
42
+            self.lockManager = LockManager(LockStorage())
43
+
44
+        self.app_config = app_config
45
+        self._show_archive = show_archived
46
+        self._show_delete = show_deleted
47
+        self._show_history = show_history
48
+
49
+    def show_history(self):
50
+        return self._show_history
51
+
52
+    def show_delete(self):
53
+        return self._show_delete
54
+
55
+    def show_archive(self):
56
+        return self._show_archive
57
+
58
+    #########################################################
59
+    # Everything override from DAVProvider
60
+    def getResourceInst(self, path: str, environ: dict):
61
+        """
62
+        Called by wsgidav whenever a request is called to get the _DAVResource corresponding to the path
63
+        """
64
+        user = environ['tracim_user']
65
+        session = environ['tracim_dbsession']
66
+        if not self.exists(path, environ):
67
+            return None
68
+        path = normpath(path)
69
+        root_path = environ['http_authenticator.realm']
70
+
71
+        # If the requested path is the root, then we return a Root resource
72
+        if path == root_path:
73
+            return resources.Root(path, environ, user=user, session=session)
74
+
75
+        workspace_api = WorkspaceApi(current_user=user, session=session)
76
+        workspace = self.get_workspace_from_path(path, workspace_api)
77
+
78
+        # If the request path is in the form root/name, then we return a Workspace resource
79
+        parent_path = dirname(path)
80
+        if parent_path == root_path:
81
+            if not workspace:
82
+                return None
83
+            return resources.Workspace(
84
+                path=path,
85
+                environ=environ,
86
+                workspace=workspace,
87
+                user=user,
88
+                session=session,
89
+            )
90
+
91
+        # And now we'll work on the path to establish which type or resource is requested
92
+
93
+        content_api = ContentApi(
94
+            current_user=user,
95
+            session=session,
96
+            config=self.app_config,
97
+            show_archived=False,  # self._show_archive,
98
+            show_deleted=False,  # self._show_delete
99
+        )
100
+
101
+        content = self.get_content_from_path(
102
+            path=path,
103
+            content_api=content_api,
104
+            workspace=workspace
105
+        )
106
+
107
+
108
+        # Easy cases : path either end with /.deleted, /.archived or /.history, then we return corresponding resources
109
+        if path.endswith(SpecialFolderExtension.Archived) and self._show_archive:
110
+            return resources.ArchivedFolder(path, environ, workspace, content)
111
+
112
+        if path.endswith(SpecialFolderExtension.Deleted) and self._show_delete:
113
+            return resources.DeletedFolder(path, environ, workspace, content)
114
+
115
+        if path.endswith(SpecialFolderExtension.History) and self._show_history:
116
+            is_deleted_folder = re.search(r'/\.deleted/\.history$', path) is not None
117
+            is_archived_folder = re.search(r'/\.archived/\.history$', path) is not None
118
+
119
+            type = HistoryType.Deleted if is_deleted_folder \
120
+                else HistoryType.Archived if is_archived_folder \
121
+                else HistoryType.Standard
122
+
123
+            return resources.HistoryFolder(path, environ, workspace, content, type)
124
+
125
+        # Now that's more complicated, we're trying to find out if the path end with /.history/file_name
126
+        is_history_file_folder = re.search(r'/\.history/([^/]+)$', path) is not None
127
+
128
+        if is_history_file_folder and self._show_history:
129
+            return resources.HistoryFileFolder(
130
+                path=path,
131
+                environ=environ,
132
+                content=content
133
+            )
134
+
135
+        # And here next step :
136
+        is_history_file = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) .+', path) is not None
137
+
138
+        if self._show_history and is_history_file:
139
+
140
+            revision_id = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) ([^/].+)$', path).group(1)
141
+
142
+            content_revision = content_api.get_one_revision(revision_id)
143
+            content = self.get_content_from_revision(content_revision, content_api)
144
+
145
+            if content.type == ContentType.File:
146
+                return resources.HistoryFile(path, environ, content, content_revision)
147
+            else:
148
+                return resources.HistoryOtherFile(path, environ, content, content_revision)
149
+
150
+        # And if we're still going, the client is asking for a standard Folder/File/Page/Thread so we check the type7
151
+        # and return the corresponding resource
152
+
153
+        if content is None:
154
+            return None
155
+        if content.type == ContentType.Folder:
156
+            return resources.Folder(
157
+                path=path,
158
+                environ=environ,
159
+                workspace=content.workspace,
160
+                content=content,
161
+                session=session,
162
+                user=user,
163
+            )
164
+        elif content.type == ContentType.File:
165
+            return resources.File(
166
+                path=path,
167
+                environ=environ,
168
+                content=content,
169
+                session=session,
170
+                user=user
171
+            )
172
+        else:
173
+            return resources.OtherFile(
174
+                path=path,
175
+                environ=environ,
176
+                content=content,
177
+                session=session,
178
+                user=user,
179
+            )
180
+
181
+    def exists(self, path, environ) -> bool:
182
+        """
183
+        Called by wsgidav to check if a certain path is linked to a _DAVResource
184
+        """
185
+
186
+        path = normpath(path)
187
+        working_path = self.reduce_path(path)
188
+        root_path = environ['http_authenticator.realm']
189
+        parent_path = dirname(working_path)
190
+        user = environ['tracim_user']
191
+        session = environ['tracim_dbsession']
192
+        if path == root_path:
193
+            return True
194
+
195
+        workspace = self.get_workspace_from_path(
196
+            path,
197
+            WorkspaceApi(current_user=user, session=session)
198
+        )
199
+
200
+        if parent_path == root_path or workspace is None:
201
+            return workspace is not None
202
+
203
+        # TODO bastien: Arnaud avait mis a True, verif le comportement
204
+        # lorsque l'on explore les dossiers archive et deleted
205
+        content_api = ContentApi(
206
+            current_user=user,
207
+            session=session,
208
+            config=self.app_config,
209
+            show_archived=False,
210
+            show_deleted=False
211
+        )
212
+
213
+        revision_id = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) ([^/].+)$', path)
214
+
215
+        is_archived = self.is_path_archive(path)
216
+
217
+        is_deleted = self.is_path_delete(path)
218
+
219
+        if revision_id:
220
+            revision_id = revision_id.group(1)
221
+            content = content_api.get_one_revision(revision_id)
222
+        else:
223
+            content = self.get_content_from_path(working_path, content_api, workspace)
224
+
225
+        return content is not None \
226
+            and content.is_deleted == is_deleted \
227
+            and content.is_archived == is_archived
228
+
229
+    def is_path_archive(self, path):
230
+        """
231
+        This function will check if a given path is linked to a file that's archived or not. We're checking if the
232
+        given path end with one of these string :
233
+
234
+        ex:
235
+            - /a/b/.archived/my_file
236
+            - /a/b/.archived/.history/my_file
237
+            - /a/b/.archived/.history/my_file/(3615 - edition) my_file
238
+        """
239
+
240
+        return re.search(
241
+            r'/\.archived/(\.history/)?(?!\.history)[^/]*(/\.)?(history|deleted|archived)?$',
242
+            path
243
+        ) is not None
244
+
245
+    def is_path_delete(self, path):
246
+        """
247
+        This function will check if a given path is linked to a file that's deleted or not. We're checking if the
248
+        given path end with one of these string :
249
+
250
+        ex:
251
+            - /a/b/.deleted/my_file
252
+            - /a/b/.deleted/.history/my_file
253
+            - /a/b/.deleted/.history/my_file/(3615 - edition) my_file
254
+        """
255
+
256
+        return re.search(
257
+            r'/\.deleted/(\.history/)?(?!\.history)[^/]*(/\.)?(history|deleted|archived)?$',
258
+            path
259
+        ) is not None
260
+
261
+    def reduce_path(self, path: str) -> str:
262
+        """
263
+        As we use the given path to request the database
264
+
265
+        ex: if the path is /a/b/.deleted/c/.archived, we're trying to get the archived content of the 'c' resource,
266
+        we need to keep the path /a/b/c
267
+
268
+        ex: if the path is /a/b/.history/my_file, we're trying to get the history of the file my_file, thus we need
269
+        the path /a/b/my_file
270
+
271
+        ex: if the path is /a/b/.history/my_file/(1985 - edition) my_old_name, we're looking for,
272
+        thus we remove all useless information
273
+        """
274
+        path = re.sub(r'/\.archived', r'', path)
275
+        path = re.sub(r'/\.deleted', r'', path)
276
+        path = re.sub(r'/\.history/[^/]+/(\d+)-.+', r'/\1', path)
277
+        path = re.sub(r'/\.history/([^/]+)', r'/\1', path)
278
+        path = re.sub(r'/\.history', r'', path)
279
+
280
+        return path
281
+
282
+    def get_content_from_path(self, path, content_api: ContentApi, workspace: Workspace) -> Content:
283
+        """
284
+        Called whenever we want to get the Content item from the database for a given path
285
+        """
286
+        path = self.reduce_path(path)
287
+        parent_path = dirname(path)
288
+
289
+        relative_parents_path = parent_path[len(workspace.label)+1:]
290
+        parents = relative_parents_path.split('/')
291
+
292
+        try:
293
+            parents.remove('')
294
+        except ValueError:
295
+            pass
296
+        parents = [transform_to_bdd(x) for x in parents]
297
+
298
+        try:
299
+            return content_api.get_one_by_label_and_parent_labels(
300
+                content_label=transform_to_bdd(basename(path)),
301
+                content_parent_labels=parents,
302
+                workspace=workspace,
303
+            )
304
+        except NoResultFound:
305
+            return None
306
+
307
+    def get_content_from_revision(self, revision: ContentRevisionRO, api: ContentApi) -> Content:
308
+        try:
309
+            return api.get_one(revision.content_id, ContentType.Any)
310
+        except NoResultFound:
311
+            return None
312
+
313
+    def get_parent_from_path(self, path, api: ContentApi, workspace) -> Content:
314
+        return self.get_content_from_path(dirname(path), api, workspace)
315
+
316
+    def get_workspace_from_path(self, path: str, api: WorkspaceApi) -> Workspace:
317
+        try:
318
+            return api.get_one_by_label(transform_to_bdd(path.split('/')[1]))
319
+        except NoResultFound:
320
+            return None

+ 385 - 0
tracim/lib/webdav/design.py View File

1
+#coding: utf8
2
+from datetime import datetime
3
+
4
+from tracim.models.data import VirtualEvent
5
+from tracim.models.data import ContentType
6
+from tracim.models import data
7
+
8
+# FIXME: fix temporaire ...
9
+style = """
10
+.title {
11
+	background:#F5F5F5;
12
+	padding-right:15px;
13
+	padding-left:15px;
14
+	padding-top:10px;
15
+	border-bottom:1px solid #CCCCCC;
16
+	overflow:auto;
17
+} .title h1 { margin-top:0; }
18
+
19
+.content {
20
+	padding: 15px;
21
+}
22
+
23
+#left{ padding:0; }
24
+
25
+#right {
26
+	background:#F5F5F5;
27
+	border-left:1px solid #CCCCCC;
28
+	border-bottom: 1px solid #CCCCCC;
29
+	padding-top:15px;
30
+}
31
+@media (max-width: 1200px) {
32
+	#right {
33
+		border-top:1px solid #CCCCCC;
34
+		border-left: none;
35
+		border-bottom: none;
36
+	}
37
+}
38
+
39
+body { overflow:auto; }
40
+
41
+.btn {
42
+	text-align: left;
43
+}
44
+
45
+.table tbody tr .my-align {
46
+	vertical-align:middle;
47
+}
48
+
49
+.title-icon {
50
+	font-size:2.5em;
51
+	float:left;
52
+	margin-right:10px;
53
+}
54
+.title.page, .title-icon.page { color:#00CC00; }
55
+.title.thread, .title-icon.thread { color:#428BCA; }
56
+
57
+/* ****************************** */
58
+.description-icon {
59
+	color:#999;
60
+	font-size:3em;
61
+}
62
+
63
+.description {
64
+	border-left: 5px solid #999;
65
+	padding-left: 10px;
66
+	margin-left: 10px;
67
+	margin-bottom:10px;
68
+}
69
+
70
+.description-text {
71
+	display:block;
72
+	overflow:hidden;
73
+	color:#999;
74
+}
75
+
76
+.comment-row:nth-child(2n) {
77
+	background-color:#F5F5F5;
78
+}
79
+
80
+.comment-row:nth-child(2n+1) {
81
+	background-color:#FFF;
82
+}
83
+
84
+.comment-icon {
85
+	color:#CCC;
86
+	font-size:3em;
87
+	display:inline-block;
88
+	margin-right: 10px;
89
+	float:left;
90
+}
91
+
92
+.comment-content {
93
+	display:block;
94
+	overflow:hidden;
95
+}
96
+
97
+.comment, .comment-revision {
98
+	padding:10px;
99
+	border-top: 1px solid #999;
100
+}
101
+
102
+.comment-revision-icon {
103
+	color:#777;
104
+	margin-right: 10px;
105
+}
106
+
107
+.title-text {
108
+	display: inline-block;
109
+}
110
+"""
111
+
112
+_LABELS = {
113
+    'archiving': 'Item archived',
114
+    'content-comment': 'Item commented',
115
+    'creation': 'Item created',
116
+    'deletion': 'Item deleted',
117
+    'edition': 'item modified',
118
+    'revision': 'New revision',
119
+    'status-update': 'New status',
120
+    'unarchiving': 'Item unarchived',
121
+    'undeletion': 'Item undeleted',
122
+    'move': 'Item moved',
123
+    'comment': 'Comment',
124
+    'copy' : 'Item copied',
125
+}
126
+
127
+
128
+def create_readable_date(created, delta_from_datetime: datetime = None):
129
+    if not delta_from_datetime:
130
+        delta_from_datetime = datetime.now()
131
+
132
+    delta = delta_from_datetime - created
133
+
134
+    if delta.days > 0:
135
+        if delta.days >= 365:
136
+            aff = '%d year%s ago' % (delta.days / 365, 's' if delta.days / 365 >= 2 else '')
137
+        elif delta.days >= 30:
138
+            aff = '%d month%s ago' % (delta.days / 30, 's' if delta.days / 30 >= 2 else '')
139
+        else:
140
+            aff = '%d day%s ago' % (delta.days, 's' if delta.days >= 2 else '')
141
+    else:
142
+        if delta.seconds < 60:
143
+            aff = '%d second%s ago' % (delta.seconds, 's' if delta.seconds > 1 else '')
144
+        elif delta.seconds / 60 < 60:
145
+            aff = '%d minute%s ago' % (delta.seconds / 60, 's' if delta.seconds / 60 >= 2 else '')
146
+        else:
147
+            aff = '%d hour%s ago' % (delta.seconds / 3600, 's' if delta.seconds / 3600 >= 2 else '')
148
+
149
+    return aff
150
+
151
+def designPage(content: data.Content, content_revision: data.ContentRevisionRO) -> str:
152
+    hist = content.get_history(drop_empty_revision=False)
153
+    histHTML = '<table class="table table-striped table-hover">'
154
+    for event in hist:
155
+        if isinstance(event, VirtualEvent):
156
+            date = event.create_readable_date()
157
+            label = _LABELS[event.type.id]
158
+
159
+            histHTML += '''
160
+                <tr class="%s">
161
+                    <td class="my-align"><span class="label label-default"><i class="fa %s"></i> %s</span></td>
162
+                    <td>%s</td>
163
+                    <td>%s</td>
164
+                    <td>%s</td>
165
+                </tr>
166
+                ''' % ('warning' if event.id == content_revision.revision_id else '',
167
+                       event.type.icon,
168
+                       label,
169
+                       date,
170
+                       event.owner.display_name,
171
+                       # NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
172
+                       '<i class="fa fa-caret-left"></i> shown'  if event.id == content_revision.revision_id else '' # '''<span><a class="revision-link" href="/.history/%s/(%s - %s) %s.html">(View revision)</a></span>''' % (
173
+                       # content.label, event.id, event.type.id, event.ref_object.label) if event.type.id in ['revision', 'creation', 'edition'] else '')
174
+                   )
175
+    histHTML += '</table>'
176
+
177
+    page = '''
178
+<html>
179
+<head>
180
+	<meta charset="utf-8" />
181
+	<title>%s</title>
182
+	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
183
+	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css">
184
+	<style>%s</style>
185
+	<script type="text/javascript" src="/home/arnaud/Documents/css/script.js"></script>
186
+	<script
187
+			  src="https://code.jquery.com/jquery-3.1.0.min.js"
188
+			  integrity="sha256-cCueBR6CsyA4/9szpPfrX3s49M9vUU5BgtiJj06wt/s="
189
+			  crossorigin="anonymous"></script>
190
+</head>
191
+<body>
192
+    <div id="left" class="col-lg-8 col-md-12 col-sm-12 col-xs-12">
193
+        <div class="title page">
194
+            <div class="title-text">
195
+                <i class="fa fa-file-text-o title-icon page"></i>
196
+                <h1>%s</h1>
197
+                <h6>page created on <b>%s</b> by <b>%s</b></h6>
198
+            </div>
199
+            <div class="pull-right">
200
+                <div class="btn-group btn-group-vertical">
201
+                    <!-- NOTE: Not omplemented yet, don't display not working link
202
+                     <a class="btn btn-default">
203
+                         <i class="fa fa-external-link"></i> View in tracim</a>
204
+                     </a>-->
205
+                </div>
206
+            </div>
207
+        </div>
208
+        <div class="content col-xs-12 col-sm-12 col-md-12 col-lg-12">
209
+            %s
210
+        </div>
211
+    </div>
212
+    <div id="right" class="col-lg-4 col-md-12 col-sm-12 col-xs-12">
213
+        <h4>History</h4>
214
+        %s
215
+    </div>
216
+    <script type="text/javascript">
217
+        window.onload = function() {
218
+            file_location = window.location.href
219
+            file_location = file_location.replace(/\/[^/]*$/, '')
220
+            file_location = file_location.replace(/\/.history\/[^/]*$/, '')
221
+
222
+            // NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
223
+            // $('.revision-link').each(function() {
224
+            //    $(this).attr('href', file_location + $(this).attr('href'))
225
+            // });
226
+        }
227
+    </script>
228
+</body>
229
+</html>
230
+        ''' % (content_revision.label,
231
+               style,
232
+               content_revision.label,
233
+               content.created.strftime("%B %d, %Y at %H:%m"),
234
+               content.owner.display_name,
235
+               content_revision.description,
236
+               histHTML)
237
+
238
+    return page
239
+
240
+def designThread(content: data.Content, content_revision: data.ContentRevisionRO, comments) -> str:
241
+        hist = content.get_history(drop_empty_revision=False)
242
+
243
+        allT = []
244
+        allT += comments
245
+        allT += hist
246
+        allT.sort(key=lambda x: x.created, reverse=True)
247
+
248
+        disc = ''
249
+        participants = {}
250
+        for t in allT:
251
+            if t.type == ContentType.Comment:
252
+                disc += '''
253
+                    <div class="row comment comment-row">
254
+                        <i class="fa fa-comment-o comment-icon"></i>
255
+                            <div class="comment-content">
256
+                            <h5>
257
+                                <span class="comment-author"><b>%s</b> wrote :</span>
258
+                                <div class="pull-right text-right">%s</div>
259
+                            </h5>
260
+                            %s
261
+                        </div>
262
+                    </div>
263
+                    ''' % (t.owner.display_name, create_readable_date(t.created), t.description)
264
+
265
+                if t.owner.display_name not in participants:
266
+                    participants[t.owner.display_name] = [1, t.created]
267
+                else:
268
+                    participants[t.owner.display_name][0] += 1
269
+            else:
270
+                if isinstance(t, VirtualEvent) and t.type.id != 'comment':
271
+                    label = _LABELS[t.type.id]
272
+
273
+                    disc += '''
274
+                    <div class="%s row comment comment-row to-hide">
275
+                        <i class="fa %s comment-icon"></i>
276
+                            <div class="comment-content">
277
+                            <h5>
278
+                                <span class="comment-author"><b>%s</b></span>
279
+                                <div class="pull-right text-right">%s</div>
280
+                            </h5>
281
+                            %s %s
282
+                        </div>
283
+                    </div>
284
+                    ''' % ('warning' if t.id == content_revision.revision_id else '',
285
+                           t.type.icon,
286
+                           t.owner.display_name,
287
+                           t.create_readable_date(),
288
+                           label,
289
+                            # NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
290
+                            '<i class="fa fa-caret-left"></i> shown' if t.id == content_revision.revision_id else '' # else '''<span><a class="revision-link" href="/.history/%s/%s-%s">(View revision)</a></span>''' % (
291
+                               # content.label,
292
+                               # t.id,
293
+                               # t.ref_object.label) if t.type.id in ['revision', 'creation', 'edition'] else '')
294
+                           )
295
+
296
+        thread = '''
297
+<html>
298
+<head>
299
+	<meta charset="utf-8" />
300
+	<title>%s</title>
301
+	<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js"></script>
302
+	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
303
+	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css">
304
+	<style>%s</style>
305
+	<script type="text/javascript" src="/home/arnaud/Documents/css/script.js"></script>
306
+</head>
307
+<body>
308
+    <div id="left" class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
309
+        <div class="title thread">
310
+            <div class="title-text">
311
+                <i class="fa fa-comments-o title-icon thread"></i>
312
+                <h1>%s</h1>
313
+                <h6>thread created on <b>%s</b> by <b>%s</b></h6>
314
+            </div>
315
+            <div class="pull-right">
316
+                <div class="btn-group btn-group-vertical">
317
+                    <!-- NOTE: Not omplemented yet, don't display not working link
318
+                    <a class="btn btn-default" onclick="hide_elements()">
319
+                       <i id="hideshow" class="fa fa-eye-slash"></i> <span id="hideshowtxt" >Hide history</span></a>
320
+                    </a>-->
321
+                    <a class="btn btn-default">
322
+                        <i class="fa fa-external-link"></i> View in tracim</a>
323
+                    </a>
324
+                </div>
325
+            </div>
326
+        </div>
327
+        <div class="content col-xs-12 col-sm-12 col-md-12 col-lg-12">
328
+            <div class="description">
329
+                <span class="description-text">%s</span>
330
+            </div>
331
+            %s
332
+        </div>
333
+    </div>
334
+    <script type="text/javascript">
335
+        window.onload = function() {
336
+            file_location = window.location.href
337
+            file_location = file_location.replace(/\/[^/]*$/, '')
338
+            file_location = file_location.replace(/\/.history\/[^/]*$/, '')
339
+
340
+            // NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
341
+            // $('.revision-link').each(function() {
342
+            //     $(this).attr('href', file_location + $(this).attr('href'))
343
+            // });
344
+        }
345
+
346
+        function hide_elements() {
347
+            elems = document.getElementsByClassName('to-hide');
348
+            if (elems.length > 0) {
349
+                for(var i = 0; i < elems.length; i++) {
350
+                    $(elems[i]).addClass('to-show')
351
+                    $(elems[i]).hide();
352
+                }
353
+                while (elems.length>0) {
354
+                    $(elems[0]).removeClass('comment-row');
355
+                    $(elems[0]).removeClass('to-hide');
356
+                }
357
+                $('#hideshow').addClass('fa-eye').removeClass('fa-eye-slash');
358
+                $('#hideshowtxt').html('Show history');
359
+            }
360
+            else {
361
+                elems = document.getElementsByClassName('to-show');
362
+                for(var i = 0; i<elems.length; i++) {
363
+                    $(elems[0]).addClass('comment-row');
364
+                    $(elems[i]).addClass('to-hide');
365
+                    $(elems[i]).show();
366
+                }
367
+                while (elems.length>0) {
368
+                    $(elems[0]).removeClass('to-show');
369
+                }
370
+                $('#hideshow').removeClass('fa-eye').addClass('fa-eye-slash');
371
+                $('#hideshowtxt').html('Hide history');
372
+            }
373
+        }
374
+    </script>
375
+</body>
376
+</html>
377
+        ''' % (content_revision.label,
378
+               style,
379
+               content_revision.label,
380
+               content.created.strftime("%B %d, %Y at %H:%m"),
381
+               content.owner.display_name,
382
+               content_revision.description,
383
+               disc)
384
+
385
+        return thread

+ 275 - 0
tracim/lib/webdav/lock_storage.py View File

1
+import time
2
+
3
+from tracim.lib.webdav.model import Lock, Url2Token
4
+from wsgidav import util
5
+from wsgidav.lock_manager import normalizeLockRoot, lockString, generateLockToken, validateLock
6
+from wsgidav.rw_lock import ReadWriteLock
7
+
8
+_logger = util.getModuleLogger(__name__)
9
+
10
+
11
+def from_dict_to_base(lock):
12
+    return Lock(
13
+        token=lock["token"],
14
+        depth=lock["depth"],
15
+        root=lock["root"],
16
+        type=lock["type"],
17
+        scopre=lock["scope"],
18
+        owner=lock["owner"],
19
+        timeout=lock["timeout"],
20
+        principal=lock["principal"],
21
+        expire=lock["expire"]
22
+    )
23
+
24
+
25
+def from_base_to_dict(lock):
26
+    return {
27
+        'token': lock.token,
28
+        'depth': lock.depth,
29
+        'root': lock.root,
30
+        'type': lock.type,
31
+        'scope': lock.scope,
32
+        'owner': lock.owner,
33
+        'timeout': lock.timeout,
34
+        'principal': lock.principal,
35
+        'expire': lock.expire
36
+    }
37
+
38
+
39
+class LockStorage(object):
40
+    LOCK_TIME_OUT_DEFAULT = 604800  # 1 week, in seconds
41
+    LOCK_TIME_OUT_MAX = 4 * 604800  # 1 month, in seconds
42
+
43
+    def __init__(self):
44
+        self._session = None# todo Session()
45
+        self._lock = ReadWriteLock()
46
+
47
+    def __repr__(self):
48
+        return "C'est bien mon verrou..."
49
+
50
+    def __del__(self):
51
+        pass
52
+
53
+    def get_lock_db_from_token(self, token):
54
+        return self._session.query(Lock).filter(Lock.token == token).one_or_none()
55
+
56
+    def _flush(self):
57
+        """Overloaded by Shelve implementation."""
58
+        pass
59
+
60
+    def open(self):
61
+        """Called before first use.
62
+
63
+        May be implemented to initialize a storage.
64
+        """
65
+        pass
66
+
67
+    def close(self):
68
+        """Called on shutdown."""
69
+        pass
70
+
71
+    def cleanup(self):
72
+        """Purge expired locks (optional)."""
73
+        pass
74
+
75
+    def clear(self):
76
+        """Delete all entries."""
77
+        self._session.query(Lock).all().delete(synchronize_session=False)
78
+        self._session.commit()
79
+
80
+    def get(self, token):
81
+        """Return a lock dictionary for a token.
82
+
83
+        If the lock does not exist or is expired, None is returned.
84
+
85
+        token:
86
+            lock token
87
+        Returns:
88
+            Lock dictionary or <None>
89
+
90
+        Side effect: if lock is expired, it will be purged and None is returned.
91
+        """
92
+        self._lock.acquireRead()
93
+        try:
94
+            lock_base = self._session.query(Lock).filter(Lock.token == token).one_or_none()
95
+            if lock_base is None:
96
+                # Lock not found: purge dangling URL2TOKEN entries
97
+                _logger.debug("Lock purged dangling: %s" % token)
98
+                self.delete(token)
99
+                return None
100
+            expire = float(lock_base.expire)
101
+            if 0 <= expire < time.time():
102
+                _logger.debug("Lock timed-out(%s): %s" % (expire, lockString(from_base_to_dict(lock_base))))
103
+                self.delete(token)
104
+                return None
105
+            return from_base_to_dict(lock_base)
106
+        finally:
107
+            self._lock.release()
108
+
109
+    def create(self, path, lock):
110
+        """Create a direct lock for a resource path.
111
+
112
+        path:
113
+            Normalized path (utf8 encoded string, no trailing '/')
114
+        lock:
115
+            lock dictionary, without a token entry
116
+        Returns:
117
+            New unique lock token.: <lock
118
+
119
+        **Note:** the lock dictionary may be modified on return:
120
+
121
+        - lock['root'] is ignored and set to the normalized <path>
122
+        - lock['timeout'] may be normalized and shorter than requested
123
+        - lock['token'] is added
124
+        """
125
+        self._lock.acquireWrite()
126
+        try:
127
+            # We expect only a lock definition, not an existing lock
128
+            assert lock.get("token") is None
129
+            assert lock.get("expire") is None, "Use timeout instead of expire"
130
+            assert path and "/" in path
131
+
132
+            # Normalize root: /foo/bar
133
+            org_path = path
134
+            path = normalizeLockRoot(path)
135
+            lock["root"] = path
136
+
137
+            # Normalize timeout from ttl to expire-date
138
+            timeout = float(lock.get("timeout"))
139
+            if timeout is None:
140
+                timeout = LockStorage.LOCK_TIME_OUT_DEFAULT
141
+            elif timeout < 0 or timeout > LockStorage.LOCK_TIME_OUT_MAX:
142
+                timeout = LockStorage.LOCK_TIME_OUT_MAX
143
+
144
+            lock["timeout"] = timeout
145
+            lock["expire"] = time.time() + timeout
146
+
147
+            validateLock(lock)
148
+
149
+            token = generateLockToken()
150
+            lock["token"] = token
151
+
152
+            # Store lock
153
+            lock_db = from_dict_to_base(lock)
154
+
155
+            self._session.add(lock_db)
156
+
157
+            # Store locked path reference
158
+            url2token = Url2Token(
159
+                path=path,
160
+                token=token
161
+            )
162
+
163
+            self._session.add(url2token)
164
+            self._session.commit()
165
+
166
+            self._flush()
167
+            _logger.debug("LockStorageDict.set(%r): %s" % (org_path, lockString(lock)))
168
+            #            print("LockStorageDict.set(%r): %s" % (org_path, lockString(lock)))
169
+            return lock
170
+        finally:
171
+            self._lock.release()
172
+
173
+    def refresh(self, token, timeout):
174
+        """Modify an existing lock's timeout.
175
+
176
+        token:
177
+            Valid lock token.
178
+        timeout:
179
+            Suggested lifetime in seconds (-1 for infinite).
180
+            The real expiration time may be shorter than requested!
181
+        Returns:
182
+            Lock dictionary.
183
+            Raises ValueError, if token is invalid.
184
+        """
185
+        lock_db = self._session.query(Lock).filter(Lock.token == token).one_or_none()
186
+        assert lock_db is not None, "Lock must exist"
187
+        assert timeout == -1 or timeout > 0
188
+        if timeout < 0 or timeout > LockStorage.LOCK_TIME_OUT_MAX:
189
+            timeout = LockStorage.LOCK_TIME_OUT_MAX
190
+
191
+        self._lock.acquireWrite()
192
+        try:
193
+            # Note: shelve dictionary returns copies, so we must reassign values:
194
+            lock_db.timeout = timeout
195
+            lock_db.expire = time.time() + timeout
196
+            self._session.commit()
197
+            self._flush()
198
+        finally:
199
+            self._lock.release()
200
+        return from_base_to_dict(lock_db)
201
+
202
+    def delete(self, token):
203
+        """Delete lock.
204
+
205
+        Returns True on success. False, if token does not exist, or is expired.
206
+        """
207
+        self._lock.acquireWrite()
208
+        try:
209
+            lock_db = self._session.query(Lock).filter(Lock.token == token).one_or_none()
210
+            _logger.debug("delete %s" % lockString(from_base_to_dict(lock_db)))
211
+            if lock_db is None:
212
+                return False
213
+            # Remove url to lock mapping
214
+            url2token = self._session.query(Url2Token).filter(
215
+                Url2Token.path == lock_db.root,
216
+                Url2Token.token == token).one_or_none()
217
+            if url2token is not None:
218
+                self._session.delete(url2token)
219
+            # Remove the lock
220
+            self._session.delete(lock_db)
221
+            self._session.commit()
222
+
223
+            self._flush()
224
+        finally:
225
+            self._lock.release()
226
+        return True
227
+
228
+    def getLockList(self, path, includeRoot, includeChildren, tokenOnly):
229
+        """Return a list of direct locks for <path>.
230
+
231
+        Expired locks are *not* returned (but may be purged).
232
+
233
+        path:
234
+            Normalized path (utf8 encoded string, no trailing '/')
235
+        includeRoot:
236
+            False: don't add <path> lock (only makes sense, when includeChildren
237
+            is True).
238
+        includeChildren:
239
+            True: Also check all sub-paths for existing locks.
240
+        tokenOnly:
241
+            True: only a list of token is returned. This may be implemented
242
+            more efficiently by some providers.
243
+        Returns:
244
+            List of valid lock dictionaries (may be empty).
245
+        """
246
+        assert path and path.startswith("/")
247
+        assert includeRoot or includeChildren
248
+
249
+        def __appendLocks(toklist):
250
+            # Since we can do this quickly, we use self.get() even if
251
+            # tokenOnly is set, so expired locks are purged.
252
+            for token in toklist:
253
+                lock_db = self.get_lock_db_from_token(token)
254
+                if lock_db:
255
+                    if tokenOnly:
256
+                        lockList.append(lock_db.token)
257
+                    else:
258
+                        lockList.append(from_base_to_dict(lock_db))
259
+
260
+        path = normalizeLockRoot(path)
261
+        self._lock.acquireRead()
262
+        try:
263
+            tokList = self._session.query(Url2Token.token).filter(Url2Token.path == path).all()
264
+            lockList = []
265
+            if includeRoot:
266
+                __appendLocks(tokList)
267
+
268
+            if includeChildren:
269
+                for url, in self._session.query(Url2Token.path).group_by(Url2Token.path):
270
+                    if util.isChildUri(path, url):
271
+                        __appendLocks(self._session.query(Url2Token.token).filter(Url2Token.path == url))
272
+
273
+            return lockList
274
+        finally:
275
+            self._lock.release()

+ 288 - 0
tracim/lib/webdav/middlewares.py View File

1
+import os
2
+import sys
3
+import threading
4
+import time
5
+from datetime import datetime
6
+from xml.etree import ElementTree
7
+
8
+import transaction
9
+import yaml
10
+from pyramid.paster import get_appsettings
11
+from wsgidav import util, compat
12
+from wsgidav.middleware import BaseMiddleware
13
+
14
+from tracim import CFG
15
+from tracim.lib.core.user import UserApi
16
+from tracim.models import get_engine, get_session_factory, get_tm_session
17
+
18
+
19
+class TracimWsgiDavDebugFilter(BaseMiddleware):
20
+    """
21
+    COPY PASTE OF wsgidav.debug_filter.WsgiDavDebugFilter
22
+    WITH ADD OF DUMP RESPONSE & REQUEST
23
+    """
24
+    def __init__(self, application, config):
25
+        self._application = application
26
+        self._config = config
27
+        #        self.out = sys.stderr
28
+        self.out = sys.stdout
29
+        self.passedLitmus = {}
30
+        # These methods boost verbose=2 to verbose=3
31
+        self.debug_methods = config.get("debug_methods", [])
32
+        # Litmus tests containing these string boost verbose=2 to verbose=3
33
+        self.debug_litmus = config.get("debug_litmus", [])
34
+        # Exit server, as soon as this litmus test has finished
35
+        self.break_after_litmus = [
36
+            #                                   "locks: 15",
37
+        ]
38
+
39
+        self.last_request_time = '__NOT_SET__'
40
+
41
+        # We disable request content dump for moment
42
+        # if self._config.get('dump_requests'):
43
+        #     # Monkey patching
44
+        #     old_parseXmlBody = util.parseXmlBody
45
+        #     def new_parseXmlBody(environ, allowEmpty=False):
46
+        #         xml = old_parseXmlBody(environ, allowEmpty)
47
+        #         self._dump_request(environ, xml)
48
+        #         return xml
49
+        #     util.parseXmlBody = new_parseXmlBody
50
+
51
+    def __call__(self, environ, start_response):
52
+        """"""
53
+        #        srvcfg = environ["wsgidav.config"]
54
+        verbose = self._config.get("verbose", 2)
55
+        self.last_request_time = '{0}_{1}'.format(
56
+            datetime.utcnow().strftime('%Y-%m-%d_%H-%M-%S'),
57
+            int(round(time.time() * 1000)),
58
+        )
59
+
60
+        method = environ["REQUEST_METHOD"]
61
+
62
+        debugBreak = False
63
+        dumpRequest = False
64
+        dumpResponse = False
65
+
66
+        if verbose >= 3 or self._config.get("dump_requests"):
67
+            dumpRequest = dumpResponse = True
68
+
69
+        # Process URL commands
70
+        if "dump_storage" in environ.get("QUERY_STRING"):
71
+            dav = environ.get("wsgidav.provider")
72
+            if dav.lockManager:
73
+                dav.lockManager._dump()
74
+            if dav.propManager:
75
+                dav.propManager._dump()
76
+
77
+        # Turn on max. debugging for selected litmus tests
78
+        litmusTag = environ.get("HTTP_X_LITMUS",
79
+                                environ.get("HTTP_X_LITMUS_SECOND"))
80
+        if litmusTag and verbose >= 2:
81
+            print("----\nRunning litmus test '%s'..." % litmusTag,
82
+                  file=self.out)
83
+            for litmusSubstring in self.debug_litmus:
84
+                if litmusSubstring in litmusTag:
85
+                    verbose = 3
86
+                    debugBreak = True
87
+                    dumpRequest = True
88
+                    dumpResponse = True
89
+                    break
90
+            for litmusSubstring in self.break_after_litmus:
91
+                if litmusSubstring in self.passedLitmus and litmusSubstring not in litmusTag:
92
+                    print(" *** break after litmus %s" % litmusTag,
93
+                          file=self.out)
94
+                    sys.exit(-1)
95
+                if litmusSubstring in litmusTag:
96
+                    self.passedLitmus[litmusSubstring] = True
97
+
98
+        # Turn on max. debugging for selected request methods
99
+        if verbose >= 2 and method in self.debug_methods:
100
+            verbose = 3
101
+            debugBreak = True
102
+            dumpRequest = True
103
+            dumpResponse = True
104
+
105
+        # Set debug options to environment
106
+        environ["wsgidav.verbose"] = verbose
107
+        #        environ["wsgidav.debug_methods"] = self.debug_methods
108
+        environ["wsgidav.debug_break"] = debugBreak
109
+        environ["wsgidav.dump_request_body"] = dumpRequest
110
+        environ["wsgidav.dump_response_body"] = dumpResponse
111
+
112
+        # Dump request headers
113
+        if dumpRequest:
114
+            print("<%s> --- %s Request ---" % (
115
+            threading.currentThread().ident, method), file=self.out)
116
+            for k, v in environ.items():
117
+                if k == k.upper():
118
+                    print("%20s: '%s'" % (k, v), file=self.out)
119
+            print("\n", file=self.out)
120
+            self._dump_request(environ, xml=None)
121
+
122
+        # Intercept start_response
123
+        #
124
+        sub_app_start_response = util.SubAppStartResponse()
125
+
126
+        nbytes = 0
127
+        first_yield = True
128
+        app_iter = self._application(environ, sub_app_start_response)
129
+
130
+        for v in app_iter:
131
+            # Start response (the first time)
132
+            if first_yield:
133
+                # Success!
134
+                start_response(sub_app_start_response.status,
135
+                               sub_app_start_response.response_headers,
136
+                               sub_app_start_response.exc_info)
137
+
138
+            # Dump response headers
139
+            if first_yield and dumpResponse:
140
+                print("<%s> --- %s Response(%s): ---" % (
141
+                threading.currentThread().ident,
142
+                method,
143
+                sub_app_start_response.status),
144
+                      file=self.out)
145
+                headersdict = dict(sub_app_start_response.response_headers)
146
+                for envitem in headersdict.keys():
147
+                    print("%s: %s" % (envitem, repr(headersdict[envitem])),
148
+                          file=self.out)
149
+                print("", file=self.out)
150
+
151
+            # Check, if response is a binary string, otherwise we probably have
152
+            # calculated a wrong content-length
153
+            assert compat.is_bytes(v), v
154
+
155
+            # Dump response body
156
+            drb = environ.get("wsgidav.dump_response_body")
157
+            if compat.is_basestring(drb):
158
+                # Middleware provided a formatted body representation
159
+                print(drb, file=self.out)
160
+            elif drb is True:
161
+                # Else dump what we get, (except for long GET responses)
162
+                if method == "GET":
163
+                    if first_yield:
164
+                        print(v[:50], "...", file=self.out)
165
+                elif len(v) > 0:
166
+                    print(v, file=self.out)
167
+
168
+            if dumpResponse:
169
+                self._dump_response(sub_app_start_response, drb)
170
+
171
+            drb = environ["wsgidav.dump_response_body"] = None
172
+
173
+            nbytes += len(v)
174
+            first_yield = False
175
+            yield v
176
+        if hasattr(app_iter, "close"):
177
+            app_iter.close()
178
+
179
+        # Start response (if it hasn't been done yet)
180
+        if first_yield:
181
+            # Success!
182
+            start_response(sub_app_start_response.status,
183
+                           sub_app_start_response.response_headers,
184
+                           sub_app_start_response.exc_info)
185
+
186
+        if dumpResponse:
187
+            print("\n<%s> --- End of %s Response (%i bytes) ---" % (
188
+            threading.currentThread().ident, method, nbytes), file=self.out)
189
+        return
190
+
191
+    def _dump_response(self, sub_app_start_response, drb):
192
+        dump_to_path = self._config.get(
193
+            'dump_requests_path',
194
+            '/tmp/wsgidav_dumps',
195
+        )
196
+        os.makedirs(dump_to_path, exist_ok=True)
197
+        dump_file = '{0}/{1}_RESPONSE_{2}.yml'.format(
198
+            dump_to_path,
199
+            self.last_request_time,
200
+            sub_app_start_response.status[0:3],
201
+        )
202
+        with open(dump_file, 'w+') as f:
203
+            dump_content = dict()
204
+            headers = {}
205
+            for header_tuple in sub_app_start_response.response_headers:
206
+                headers[header_tuple[0]] = header_tuple[1]
207
+            dump_content['headers'] = headers
208
+            if isinstance(drb, str):
209
+                dump_content['content'] = drb.replace('PROPFIND XML response body:\n', '')
210
+
211
+            f.write(yaml.dump(dump_content, default_flow_style=False))
212
+
213
+    def _dump_request(self, environ, xml):
214
+        dump_to_path = self._config.get(
215
+            'dump_requests_path',
216
+            '/tmp/wsgidav_dumps',
217
+        )
218
+        os.makedirs(dump_to_path, exist_ok=True)
219
+        dump_file = '{0}/{1}_REQUEST_{2}.yml'.format(
220
+            dump_to_path,
221
+            self.last_request_time,
222
+            environ['REQUEST_METHOD'],
223
+        )
224
+        with open(dump_file, 'w+') as f:
225
+            dump_content = dict()
226
+            dump_content['path'] = environ.get('PATH_INFO', '')
227
+            dump_content['Authorization'] = environ.get('HTTP_AUTHORIZATION', '')
228
+            if xml:
229
+                dump_content['content'] = ElementTree.tostring(xml, 'utf-8')
230
+
231
+            f.write(yaml.dump(dump_content, default_flow_style=False))
232
+
233
+
234
+class TracimEnforceHTTPS(BaseMiddleware):
235
+
236
+    def __init__(self, application, config):
237
+        super().__init__(application, config)
238
+        self._application = application
239
+        self._config = config
240
+
241
+    def __call__(self, environ, start_response):
242
+        # TODO - G.M - 06-03-2018 - Check protocol from http header first
243
+        # see http://www.bortzmeyer.org/7239.html
244
+        # if this params doesn't exist, rely on tracim config
245
+        # from tracim.config.app_cfg import CFG
246
+        # cfg = CFG.get_instance()
247
+        #
248
+        # if cfg.WEBSITE_BASE_URL.startswith('https'):
249
+        #     environ['wsgi.url_scheme'] = 'https'
250
+        return self._application(environ, start_response)
251
+
252
+
253
+class TracimEnv(BaseMiddleware):
254
+
255
+    def __init__(self, application, config):
256
+        super().__init__(application, config)
257
+        self._application = application
258
+        self._config = config
259
+        self.settings = get_appsettings(config['tracim_config'])
260
+        self.engine = get_engine(self.settings)
261
+        self.session_factory = get_session_factory(self.engine)
262
+        self.app_config = CFG(self.settings)
263
+        self.app_config.configure_filedepot()
264
+
265
+    def __call__(self, environ, start_response):
266
+        with transaction.manager as tm:
267
+            dbsession = get_tm_session(self.session_factory, transaction.manager)
268
+            environ['tracim_tm'] = tm
269
+            environ['tracim_dbsession'] = dbsession
270
+            environ['tracim_cfg'] = self.app_config
271
+
272
+            return self._application(environ, start_response)
273
+
274
+
275
+class TracimUserSession(BaseMiddleware):
276
+
277
+    def __init__(self, application, config):
278
+        super().__init__(application, config)
279
+        self._application = application
280
+        self._config = config
281
+
282
+    def __call__(self, environ, start_response):
283
+        environ['tracim_user'] = UserApi(
284
+            None,
285
+            session=environ['tracim_dbsession'],
286
+            config=environ['tracim_cfg'],
287
+        ).get_one_by_email(environ['http_authenticator.username'])
288
+        return self._application(environ, start_response)

+ 28 - 0
tracim/lib/webdav/model.py View File

1
+#coding: utf8
2
+
3
+from sqlalchemy import Column
4
+from sqlalchemy import ForeignKey
5
+from sqlalchemy.types import Unicode, UnicodeText, Float
6
+
7
+from wsgidav.compat import to_unicode
8
+
9
+
10
+class Lock(object):
11
+    __tablename__ = 'my_locks'
12
+
13
+    token = Column(UnicodeText, primary_key=True, unique=True, nullable=False)
14
+    depth = Column(Unicode(32), unique=False, nullable=False, default=to_unicode('infinity'))
15
+    root = Column(UnicodeText, unique=False, nullable=False)
16
+    type = Column(Unicode(32), unique=False, nullable=False, default=to_unicode('write'))
17
+    scope = Column(Unicode(32), unique=False, nullable=False, default=to_unicode('exclusive'))
18
+    owner = Column(UnicodeText, unique=False, nullable=False)
19
+    expire = Column(Float, unique=False, nullable=False)
20
+    principal = Column(Unicode(255), ForeignKey('my_users.display_name', ondelete="CASCADE"))
21
+    timeout = Column(Float, unique=False, nullable=False)
22
+
23
+
24
+class Url2Token(object):
25
+    __tablename__ = 'my_url2token'
26
+
27
+    token = Column(UnicodeText, primary_key=True, unique=True, nullable=False)
28
+    path = Column(UnicodeText, primary_key=True, unique=False, nullable=False)

File diff suppressed because it is too large
+ 1340 - 0
tracim/lib/webdav/resources.py


+ 204 - 0
tracim/lib/webdav/utils.py View File

1
+# -*- coding: utf-8 -*-
2
+
3
+import transaction
4
+from os.path import normpath as base_normpath
5
+from wsgidav import util
6
+from wsgidav import compat
7
+
8
+from tracim.lib.core.content import ContentApi
9
+from tracim.models.data import Workspace, Content, ContentType, \
10
+    ActionDescription
11
+from tracim.models.revision_protection import new_revision
12
+
13
+
14
+def transform_to_display(string: str) -> str:
15
+    """
16
+    As characters that Windows does not support may have been inserted
17
+    through Tracim in names, before displaying information we update path
18
+    so that all these forbidden characters are replaced with similar
19
+    shape character that are allowed so that the user isn't trouble and
20
+    isn't limited in his naming choice
21
+    """
22
+    _TO_DISPLAY = {
23
+        '/': '⧸',
24
+        '\\': '⧹',
25
+        ':': '∶',
26
+        '*': '∗',
27
+        '?': 'ʔ',
28
+        '"': 'ʺ',
29
+        '<': '❮',
30
+        '>': '❯',
31
+        '|': '∣'
32
+    }
33
+
34
+    for key, value in _TO_DISPLAY.items():
35
+        string = string.replace(key, value)
36
+
37
+    return string
38
+
39
+
40
+def transform_to_bdd(string: str) -> str:
41
+    """
42
+    Called before sending request to the database to recover the right names
43
+    """
44
+    _TO_BDD = {
45
+        '⧸': '/',
46
+        '⧹': '\\',
47
+        '∶': ':',
48
+        '∗': '*',
49
+        'ʔ': '?',
50
+        'ʺ': '"',
51
+        '❮': '<',
52
+        '❯': '>',
53
+        '∣': '|'
54
+    }
55
+
56
+    for key, value in _TO_BDD.items():
57
+        string = string.replace(key, value)
58
+
59
+    return string
60
+
61
+
62
+def normpath(path):
63
+    if path == b'':
64
+        path = b'/'
65
+    elif path == '':
66
+        path = '/'
67
+    return base_normpath(path)
68
+
69
+
70
+class HistoryType(object):
71
+    Deleted = 'deleted'
72
+    Archived = 'archived'
73
+    Standard = 'standard'
74
+    All = 'all'
75
+
76
+
77
+class SpecialFolderExtension(object):
78
+    Deleted = '/.deleted'
79
+    Archived = '/.archived'
80
+    History = '/.history'
81
+
82
+
83
+class FakeFileStream(object):
84
+    """
85
+    Fake a FileStream that we're giving to wsgidav to receive data and create files / new revisions
86
+
87
+    There's two scenarios :
88
+    - when a new file is created, wsgidav will call the method createEmptyResource and except to get a _DAVResource
89
+    which should have both 'beginWrite' and 'endWrite' method implemented
90
+    - when a file which already exists is updated, he's going to call the 'beginWrite' function of the _DAVResource
91
+    to get a filestream and write content in it
92
+
93
+    In the first case scenario, the transfer takes two part : it first create the resource (createEmptyResource)
94
+    then add its content (beginWrite, write, close..). If we went without this class, we would create two revision
95
+    of the file upon creating a new file, which is not what we want.
96
+    """
97
+
98
+    def __init__(
99
+            self,
100
+            content_api: ContentApi,
101
+            workspace: Workspace,
102
+            path: str,
103
+            file_name: str='',
104
+            content: Content=None,
105
+            parent: Content=None
106
+    ):
107
+        """
108
+
109
+        :param content_api:
110
+        :param workspace:
111
+        :param path:
112
+        :param file_name:
113
+        :param content:
114
+        :param parent:
115
+        """
116
+        self._file_stream = compat.BytesIO()
117
+
118
+        self._file_name = file_name if file_name != '' else self._content.file_name
119
+        self._content = content
120
+        self._api = content_api
121
+        self._workspace = workspace
122
+        self._parent = parent
123
+        self._path = path
124
+
125
+    def getRefUrl(self) -> str:
126
+        """
127
+        As wsgidav expect to receive a _DAVResource upon creating a new resource, this method's result is used
128
+        by Windows client to establish both file's path and file's name
129
+        """
130
+        return self._path
131
+
132
+    def beginWrite(self, contentType) -> 'FakeFileStream':
133
+        """
134
+        Called by wsgidav, it expect a filestream which possess both 'write' and 'close' operation to write
135
+        the file content.
136
+        """
137
+        return self
138
+
139
+    def endWrite(self, withErrors: bool):
140
+        """
141
+        Called by request_server when finished writing everything.
142
+        As we call operation to create new content or revision in the close operation, called before endWrite, there
143
+        is nothing to do here.
144
+        """
145
+        pass
146
+
147
+    def write(self, s: str):
148
+        """
149
+        Called by request_server when writing content to files, we put it inside a filestream
150
+        """
151
+        self._file_stream.write(s)
152
+
153
+    def close(self):
154
+        """
155
+        Called by request_server when the file content has been written. We either add a new content or create
156
+        a new revision
157
+        """
158
+
159
+        self._file_stream.seek(0)
160
+
161
+        if self._content is None:
162
+            self.create_file()
163
+        else:
164
+            self.update_file()
165
+
166
+        transaction.commit()
167
+
168
+    def create_file(self):
169
+        """
170
+        Called when this is a new file; will create a new Content initialized with the correct content
171
+        """
172
+
173
+        is_temporary = self._file_name.startswith('.~') or self._file_name.startswith('~')
174
+
175
+        file = self._api.create(
176
+            content_type=ContentType.File,
177
+            workspace=self._workspace,
178
+            parent=self._parent,
179
+            is_temporary=is_temporary
180
+        )
181
+
182
+        self._api.update_file_data(
183
+            file,
184
+            self._file_name,
185
+            util.guessMimeType(self._file_name),
186
+            self._file_stream.read()
187
+        )
188
+
189
+        self._api.save(file, ActionDescription.CREATION)
190
+
191
+    def update_file(self):
192
+        """
193
+        Called when we're updating an existing content; we create a new revision and update the file content
194
+        """
195
+
196
+        with new_revision(self._content):
197
+            self._api.update_file_data(
198
+                self._content,
199
+                self._file_name,
200
+                util.guessMimeType(self._content.file_name),
201
+                self._file_stream.read()
202
+            )
203
+
204
+            self._api.save(self._content, ActionDescription.REVISION)