Bläddra i källkod

WIP Webdav integration

Guénaël Muller 7 år sedan
förälder
incheckning
df85d0a977

+ 4 - 0
setup.py Visa fil

@@ -28,6 +28,9 @@ requires = [
28 28
     'marshmallow <3.0.0a1,>2.0.0',
29 29
     # CLI
30 30
     'cliff',
31
+    # Webdav
32
+    'wsgidav',
33
+    'PyYAML',
31 34
     # others
32 35
     'filedepot',
33 36
     'babel',
@@ -96,6 +99,7 @@ setup(
96 99
             'user_create = tracim.command.user:CreateUserCommand',
97 100
             'user_update = tracim.command.user:UpdateUserCommand',
98 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 Visa fil

@@ -10,6 +10,7 @@ from cliff.commandmanager import CommandManager
10 10
 from pyramid.paster import bootstrap
11 11
 from pyramid.scripting import AppEnvironment
12 12
 from tracim.exceptions import CommandAbortedError
13
+from tracim.lib.utils.utils import DEFAULT_TRACIM_CONFIG_FILE
13 14
 
14 15
 
15 16
 class TracimCLI(App):
@@ -61,9 +62,11 @@ class AppContextCommand(Command):
61 62
         parser.add_argument(
62 63
             "-c",
63 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 68
             dest='config_file',
66
-            default="development.ini"
69
+            default=DEFAULT_TRACIM_CONFIG_FILE,
67 70
         )
68 71
         return parser
69 72
 

+ 29 - 0
tracim/command/webdav.py Visa fil

@@ -0,0 +1,29 @@
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 Visa fil

@@ -79,3 +79,7 @@ class InsufficientUserProfile(TracimException):
79 79
 
80 80
 class ImmutableAttribute(TracimException):
81 81
     pass
82
+
83
+
84
+class DigestAuthNotImplemented(Exception):
85
+    pass

+ 3 - 0
tracim/lib/utils/utils.py Visa fil

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

+ 145 - 0
tracim/lib/webdav/__init__.py Visa fil

@@ -0,0 +1,145 @@
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 Visa fil

@@ -0,0 +1,49 @@
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 Visa fil

@@ -0,0 +1,320 @@
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 Visa fil

@@ -0,0 +1,385 @@
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 Visa fil

@@ -0,0 +1,275 @@
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 Visa fil

@@ -0,0 +1,288 @@
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 Visa fil

@@ -0,0 +1,28 @@
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)

Filskillnaden har hållits tillbaka eftersom den är för stor
+ 1340 - 0
tracim/lib/webdav/resources.py


+ 204 - 0
tracim/lib/webdav/utils.py Visa fil

@@ -0,0 +1,204 @@
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)