Przeglądaj źródła

merge with master

Guénaël Muller 6 lat temu
rodzic
commit
5f52ff917b

+ 29 - 3
README.md Wyświetl plik

@@ -102,9 +102,35 @@ If you want your own dedicated instance but do not want to manage it by yourself
102 102
 
103 103
 In case you prefer using Docker:
104 104
 
105
-    sudo docker run -e DATABASE_TYPE=sqlite \
106
-               -p 80:80 -p 3030:3030 -p 5232:5232 \
107
-               -v /var/tracim/etc:/etc/tracim -v /var/tracim/var:/var/tracim algoo/tracim
105
+You need to install docker on your server first (see [here for debian](https://docs.docker.com/install/linux/docker-ce/debian/) or [here for windows]([docker toolbox](https://docs.docker.com/toolbox/toolbox_install_windows/)))
106
+
107
+Make sure `docker.service` is started correctly.
108
+
109
+    systemctl status docker.service
110
+
111
+Then use image like following (adapt volume paths or ports as you want):
112
+
113
+    sudo docker run \
114
+        -e DATABASE_TYPE=sqlite \
115
+        -p 80:80 \
116
+        -p 3030:3030 \
117
+        -p 5232:5232 \
118
+        -v /var/tracim/etc:/etc/tracim \
119
+        -v /var/tracim/var:/var/tracim \
120
+        algoo/tracim  # docker image: tracim:unstable for development version, tracim_test:latest for nightly builds
121
+
122
+
123
+All docker images are available here : https://hub.docker.com/u/algoo/
124
+More information about build docker image: https://github.com/tracim/docker_tracim
125
+
126
+Description of versioning:
127
+
128
+* algoo/`tracim:latest` is latest stable image from branch master
129
+* algoo/`tracim:<$tag>` is a stable tag image from branch master
130
+* algoo/`tracim_testing:latest` is a latest image from branch develop
131
+* algoo/`tracim:unstable` is a latest unstable image from branch develop
132
+
133
+----
108 134
 
109 135
 ## Install Tracim on your server ##
110 136
 

+ 125 - 30
doc/apache.md Wyświetl plik

@@ -2,45 +2,73 @@
2 2
 
3 3
 ### Installation ###
4 4
 
5
-Install `Apache` server and its [`WSGI` module](https://github.com/GrahamDumpleton/mod_wsgi):
5
+Install `tracim` first.
6
+Install `Apache` server and uwsgi  its [`WSGI` module](https://github.com/GrahamDumpleton/mod_wsgi):
6 7
 
7
-    sudo apt install apache2 libapache2-mod-wsgi-py3
8
+    sudo apt install apache2 libapache2-mod-wsgi-py3 uwsgi uwsgi-plugin-python3
8 9
 
9 10
 ### Configuration ###
10 11
 
11 12
 Create a file named `/etc/apache2/sites-available/tracim.conf` containing:
12 13
 
13
-    Listen 8080
14
+    Listen 80
14 15
 
15
-    <VirtualHost *:8080>
16
+    <VirtualHost *:80>
16 17
         ServerName tracim
17 18
 
18
-        # Serve Tracim through WSGI
19
-        WSGIDaemonProcess tracim user=[your_user] group=[your_user] threads=4 python-home=[tracim_path]/tg2env python-path=[tracim_path]/tracim lang='C.UTF-8' locale='C.UTF-8'
20
-        WSGIProcessGroup tracim
21
-        WSGIScriptAlias / [tracim_path]/tracim/app.wsgi process-group=tracim
22
-        <Directory "[tracim_path]/tracim">
23
-            <Files "app.wsgi">
24
-                Require all granted
25
-            </Files>
26
-        </Directory>
27
-
28
-        # Serve static files directly
29
-        Alias /assets          [tracim_path]/tracim/tracim/public/assets
30
-        Alias /_caldavzap      [tracim_path]/tracim/tracim/public/_caldavzap
31
-        Alias /favicon.ico     [tracim_path]/tracim/tracim/public/favicon.ico
32
-        <Directory "[tracim_path]/tracim/tracim/public">
33
-            Require all granted
34
-        </Directory>
19
+    <Directory "/">
20
+        Require all granted
21
+        Dav On
22
+    </Directory>
23
+    ProxyPreserveHost On 
24
+    CustomLog /var/log/apache2/algoo-access.log combined
25
+    ErrorLog /var/log/apache2/algoo-error.log
26
+    <Location "/webdav">
27
+        # Dav On
28
+    </Location>
29
+
30
+    # RemoteIPHeader X-Forwarded-For
31
+    ProxyPass /webdav http://127.0.0.1:3030/webdav
32
+    ProxyPassReverse /webdav http://127.0.0.1:3030/webdav
33
+    ProxyPass / http://127.0.0.1:8080/
34
+    ProxyPassReverse / http://127.0.0.1:8080/
35
+
35 36
     </VirtualHost>
36 37
 
37
-Replace `[tracim_path]` and `[your_user]` above by your tracim installation path and your user.
38
+Enable this configuration file:
39
+
40
+    sudo ln -s /etc/apache2/sites-available/tracim.conf /etc/apache2/sites-enabled/
41
+
42
+Check if you just have `tracim.conf` in `/etc/apache2/sites-enabled`, if not, remove other file.
43
+
44
+
45
+Create the file named `/etc/uwsgi/apps-available/tracim.ini` containing:
46
+
47
+    [uwsgi]
48
+    plugins = python3
49
+    chdir = [tracim_path]/tracim
50
+    home = [tracim_path]/tg2env
51
+    wsgi-file = app.wsgi
52
+    callable = application
53
+    http-socket = 0.0.0.0:8080
54
+    enable-threads = true
55
+    env = PYTHON_EGG_CACHE=/tmp
56
+
57
+Replace [tracim_path] by your path of tracim
38 58
 
39
-Set the `APP_CONFIG` variable of the `tracim/app.wsgi` file to match your tracim installation path:
59
+Enable this configuration file:
60
+
61
+    sudo ln -s /etc/uwsgi/apps-available/tracim.ini /etc/uwsgi/apps-enabled/
62
+
63
+
64
+Create file `tracim.log` in `/var/log/uwsgi/app/`
65
+
66
+Set the `APP_CONFIG` variable of the `[tracim_path]/tracim/app.wsgi` file to match your tracim installation path:
40 67
 
41 68
     # -*- coding: utf-8 -*-
42 69
 
43
-    APP_CONFIG = "[tracim_path]/tracim/development.ini"
70
+    APP_CONFIG = "[tracim_path]/tracim/development.ini" 
71
+    #(in file: replace /var/www/tracim by your [tracim_path] )
44 72
 
45 73
     #Setup logging
46 74
     # import logging
@@ -51,17 +79,84 @@ Set the `APP_CONFIG` variable of the `tracim/app.wsgi` file to match your tracim
51 79
     application = loadapp('config:%s' % APP_CONFIG)
52 80
     application.debug = False
53 81
 
82
+
83
+Add `webdav` at `root_path` in the `[tracim_path]/tracim/wsgidav.conf`:
84
+
85
+    ################################################################################
86
+    # Sample WsgiDAV configuration file
87
+    #
88
+    # 1. Rename this file to `wsgidav.conf`
89
+    # 2. Adjust settings as appropriate
90
+    # 3. Run tracim as you always do :)
91
+    #
92
+    ################################################################################
93
+    
94
+    ################################################################################
95
+    # SERVER OPTIONS
96
+    #===============================================================================
97
+    
98
+    # host  = "localhost"
99
+    # host  = "192.168.0.1"
100
+    host  = "0.0.0.0"
101
+    
102
+    port = 3030
103
+    
104
+    show_history = True
105
+    show_deleted = True
106
+    show_archived = True
107
+    
108
+    manager_locks = True
109
+    
110
+    root_path = ''
111
+    
112
+    # Tracim doesn't support digest auth for webdav
113
+    acceptbasic = True
114
+    acceptdigest = False
115
+    defaultdigest = False
116
+    #===============================================================================
117
+    # Lock Manager
118
+    #
119
+    # Example: Use PERSISTENT shelve based lock manager
120
+    #from wsgidav.lock_storage import LockStorageShelve
121
+    #locksmanager = LockStorageShelve("wsgidav-locks.shelve")
122
+
123
+
124
+
125
+Open `[tracim_path]/tracim/development.ini` and make some change:
126
+
127
+
128
+    In [server:main] modify IP:
129
+    `host = 127.0.0.1` by `host = 0.0.0.0`
130
+
131
+    For Radical (CalDav server):
132
+    Uncomment `# radicale.server.host = 0.0.0.0`
133
+    Uncomment `# radicale.server.allow_origin = *`
134
+    Uncomment `# radicale.client.base_url.host = http://127.0.0.1:5232`
135
+    and modifiy IP `# radicale.client.base_url.host = http://127.0.0.1:5232`to `radicale.client.base_url.host = http://[Your_server_IP]:5232`
136
+    
137
+    For WSGIDAV
138
+    Uncomment `# wsgidav.client.base_url = 127.0.0.1:<WSGIDAV_PORT>`
139
+    and modify IP and PORT `# wsgidav.client.base_url = 127.0.0.1:<WSGIDAV_PORT>` to `wsgidav.client.base_url = [Your_server_IP]/webdav`
140
+
141
+
142
+Restart `uwsgi` configuration:
143
+
144
+    sudo systemctl restart uwsgi.service
145
+
54 146
 Load needed proxy modules and enable this site configuration file:
55 147
 
56
-    sudo a2enmod proxy proxy_http
148
+    sudo a2enmod dav_fs dav proxy proxy_http
57 149
     sudo a2ensite tracim.conf
58 150
 
59
-Reload `Apache` configuration:
151
+Restart `Apache` configuration:
60 152
 
61
-    sudo systemctl reload apache2.service
153
+    sudo systemctl restart apache2.service
154
+    
155
+**Important**
156
+In case you have some permission problem, check if `www-data` can access to folder of tracim.
62 157
 
63 158
 ## Documentation Links ##
64 159
 
65
-[TurboGears](http://turbogears.readthedocs.io/en/tg2.3.7/cookbook/deploy/mod_wsgi.html)
66
-
67
-[mod_wsgi](http://modwsgi.readthedocs.io/en/develop/index.html)
160
+* [Apache](https://httpd.apache.org/docs/2.4/fr/)
161
+* [TurboGears](http://turbogears.readthedocs.io/en/tg2.3.7/cookbook/deploy/mod_wsgi.html)
162
+* [mod_wsgi](http://modwsgi.readthedocs.io/en/develop/index.html)

+ 31 - 0
doc/devtools.md Wyświetl plik

@@ -0,0 +1,31 @@
1
+# Devtools
2
+
3
+# Check third party licences
4
+
5
+Install `yolk3k` pip package:
6
+
7
+    pip install yolk3k
8
+
9
+Then execute command:
10
+
11
+    yolk -l -f license
12
+
13
+Output will look like:
14
+
15
+```
16
+Babel (2.2.0)
17
+    License: BSD
18
+
19
+Beaker (1.6.4)
20
+    License: BSD
21
+
22
+CherryPy (3.6.0)
23
+    License: BSD
24
+
25
+FormEncode (1.3.0a1)
26
+    License: PSF
27
+
28
+Genshi (0.7)
29
+    License: BSD
30
+...
31
+```

+ 70 - 0
doc/webdav.md Wyświetl plik

@@ -0,0 +1,70 @@
1
+# How to use webdav from different OS ?
2
+
3
+## Windows
4
+
5
+### Windows 7
6
+
7
+- Open Start Menu.
8
+- Click on Computer.
9
+- click on "Map network drive".
10
+
11
+### Windows 8 and 10
12
+
13
+- Open File explorer.
14
+- Right click on "This PC" (left panel)
15
+- From the dropdown menu, select "Map network drive".
16
+
17
+### Map Network drive Windows:
18
+
19
+Webdav Windows addresses are similar to:
20
+
21
+```
22
+https://<yourinstance>/webdav/ (secure)
23
+http://<yourinstance>/webdav/ (unsecure)
24
+```
25
+
26
+- Enter the address of webdav (you can find it in each workspace, under workspace details)
27
+- Check "Reconnect at sign-in" and "Connect using different credentials".
28
+- Click "Finish".
29
+- Your login/password will be ask. Use your Tracim credentials.
30
+- After that, your webdav access should be mounted.
31
+
32
+### Unsecure HTTP using Windows
33
+
34
+If you want to use webdav with tracim without https, you need to set Windows to accept basic auth in http.
35
+
36
+To enable it:
37
+- Launch regedit.
38
+- Go to "HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WebClient\Parameters\BasicAuthLevel".
39
+- set "BasicAuthLevel" to "2".
40
+
41
+## OSX
42
+
43
+Webdav OSX addresses are similar to:
44
+
45
+```
46
+https://<yourinstance>/webdav/ (secure)
47
+http://<yourinstance>/webdav/ (unsecure)
48
+```
49
+
50
+- In the Finder, choose "Go > Connect to Server".
51
+- Enter the address of webdav (you can find it in each workspace, under workspace details). Click Connect.
52
+- Your login/password will be ask. Use your Tracim credentials.
53
+- After that, your webdav access should be mounted.
54
+
55
+## Linux
56
+
57
+Webdav Linux addresses are similar to:
58
+
59
+```
60
+davs://<yourinstance>/webdav/ (secure)
61
+dav://<yourinstance>/webdav/ (unsecure)
62
+```
63
+
64
+### Gnome3 (nautilus)
65
+
66
+- Launch nautilus.
67
+- Show url bar : Ctrl+l.
68
+- Enter the address of webdav (you can find it in each workspace, under workspace details). Press Enter.
69
+- Your login/password will be ask. Use your Tracim credentials.
70
+- After that, your webdav access should be mounted.

+ 26 - 0
tracim/migration/versions/2b4043fa2502_remove_webdav_right_digest_response_.py Wyświetl plik

@@ -0,0 +1,26 @@
1
+"""remove webdav_right_digest_response_hash from database
2
+
3
+Revision ID: 2b4043fa2502
4
+Revises: f3852e1349c4
5
+Create Date: 2018-03-13 14:41:38.590375
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = '2b4043fa2502'
11
+down_revision = 'f3852e1349c4'
12
+
13
+from alembic import op
14
+from sqlalchemy import Column, Unicode
15
+
16
+
17
+def upgrade():
18
+    with op.batch_alter_table('users') as batch_op:
19
+        batch_op.drop_column('webdav_left_digest_response_hash')
20
+
21
+
22
+def downgrade():
23
+    with op.batch_alter_table('users') as batch_op:
24
+        batch_op.add_column(
25
+            Column('webdav_left_digest_response_hash', Unicode(128))
26
+        )

+ 8 - 0
tracim/tracim/controllers/workspace.py Wyświetl plik

@@ -23,6 +23,7 @@ from tracim.model.data import Workspace
23 23
 
24 24
 from tracim.model.serializers import Context, CTX, DictLikeClass
25 25
 
26
+from urllib.parse import urlparse
26 27
 
27 28
 class UserWorkspaceRestController(TIMRestController):
28 29
 
@@ -103,13 +104,20 @@ class UserWorkspaceRestController(TIMRestController):
103 104
         videoconf_enabled = CFG.get_instance().JITSI_MEET_ACTIVATED
104 105
 
105 106
         dictified_workspace = Context(CTX.WORKSPACE).toDict(workspace, 'workspace')
107
+
106 108
         webdav_url = CFG.get_instance().WSGIDAV_CLIENT_BASE_URL
109
+        website_protocol = urlparse(CFG.get_instance().WEBSITE_BASE_URL).scheme
110
+        dav_protocol = 'dav'
111
+        if website_protocol == "https":
112
+            dav_protocol = 'davs'
107 113
 
108 114
         return DictLikeClass(
109 115
             result=dictified_workspace,
110 116
             fake_api=fake_api,
111 117
             webdav_url=webdav_url,
112 118
             videoconf_enabled=videoconf_enabled,
119
+            website_protocol = website_protocol,
120
+            dav_protocol = dav_protocol,
113 121
             show_deleted=show_deleted,
114 122
             show_archived=show_archived,
115 123
         )

+ 2 - 2
tracim/tracim/i18n/fr/LC_MESSAGES/tracim.po Wyświetl plik

@@ -1403,7 +1403,7 @@ msgstr "Activer le calendrier associé"
1403 1403
 
1404 1404
 #: tracim/templates/admin/workspace_getall.mak:48
1405 1405
 msgid "<u>Note</u>: members will be added during next step."
1406
-msgstr "<u>Note</u> : les membres seront ajoutées à la prochaine étape."
1406
+msgstr "<u>Note</u> : les membres seront ajouté(e)s à la prochaine étape."
1407 1407
 
1408 1408
 #: tracim/templates/admin/workspace_getall.mak:75
1409 1409
 msgid "User Nb"
@@ -1433,7 +1433,7 @@ msgid ""
1433 1433
 "This workspace offers a calendar that you can configure in your software:"
1434 1434
 " Outlook, Thunderbird, etc."
1435 1435
 msgstr ""
1436
-"Cet espace de travaille propose un calendrier que vous pouvez configurer "
1436
+"Cet espace de travail propose un calendrier que vous pouvez configurer "
1437 1437
 "dans votre logiciel : Outlook, Thunderbird, etc."
1438 1438
 
1439 1439
 #: tracim/templates/admin/workspace_getone.mak:46

+ 64 - 0
tracim/tracim/lib/content.py Wyświetl plik

@@ -29,6 +29,7 @@ from sqlalchemy.sql.elements import and_
29 29
 from tracim.lib import cmp_to_key
30 30
 from tracim.lib.notifications import NotifierFactory
31 31
 from tracim.lib.utils import SameValueError
32
+from tracim.lib.utils import current_date_for_filename
32 33
 from tracim.model import DBSession
33 34
 from tracim.model import new_revision
34 35
 from tracim.model.auth import User
@@ -860,6 +861,53 @@ class ContentApi(object):
860 861
 
861 862
         item.revision_type = ActionDescription.MOVE
862 863
 
864
+    def copy(
865
+        self,
866
+        item: Content,
867
+        new_parent: Content=None,
868
+        new_label: str=None,
869
+        do_save: bool=True,
870
+        do_notify: bool=True,
871
+    ) -> Content:
872
+        """
873
+        Copy nearly all content, revision included. Children not included, see
874
+        "copy_children" for this.
875
+        :param item: Item to copy
876
+        :param new_parent: new parent of the new copied item
877
+        :param new_label: new label of the new copied item
878
+        :param do_notify: notify copy or not
879
+        :return: Newly copied item
880
+        """
881
+        if (not new_parent and not new_label) or (new_parent == item.parent and new_label == item.label):  # nopep8
882
+            # TODO - G.M - 08-03-2018 - Use something else than value error
883
+            raise ValueError("You can't copy file into itself")
884
+        if new_parent:
885
+            workspace = new_parent.workspace
886
+            parent = new_parent
887
+        else:
888
+            workspace = item.workspace
889
+            parent = item.parent
890
+        label = new_label or item.label
891
+
892
+        content = item.copy(parent)
893
+        # INFO - GM - 15-03-2018 - add "copy" revision
894
+        with new_revision(content, force_create_new_revision=True) as rev:
895
+            rev.parent = parent
896
+            rev.workspace = workspace
897
+            rev.label = label
898
+            rev.revision_type = ActionDescription.COPY
899
+            rev.properties['origin'] = {
900
+                'content': item.id,
901
+                'revision': item.last_revision.revision_id,
902
+            }
903
+        if do_save:
904
+            self.save(content, ActionDescription.COPY, do_notify=do_notify)
905
+        return content
906
+
907
+    def copy_children(self, origin_content: Content, new_content: Content):
908
+        for child in origin_content.children:
909
+            self.copy(child, new_content)
910
+
863 911
     def move_recursively(self, item: Content,
864 912
                          new_parent: Content, new_workspace: Workspace):
865 913
         self.move(item, new_parent, False, new_workspace)
@@ -894,6 +942,14 @@ class ContentApi(object):
894 942
     def archive(self, content: Content):
895 943
         content.owner = self._user
896 944
         content.is_archived = True
945
+        # TODO - G.M - 12-03-2018 - Inspect possible label conflict problem
946
+        # INFO - G.M - 12-03-2018 - Set label name to avoid trouble when
947
+        # un-archiving file.
948
+        content.label = '{label}-{action}-{date}'.format(
949
+            label=content.label,
950
+            action='archived',
951
+            date=current_date_for_filename()
952
+        )
897 953
         content.revision_type = ActionDescription.ARCHIVING
898 954
 
899 955
     def unarchive(self, content: Content):
@@ -904,6 +960,14 @@ class ContentApi(object):
904 960
     def delete(self, content: Content):
905 961
         content.owner = self._user
906 962
         content.is_deleted = True
963
+        # TODO - G.M - 12-03-2018 - Inspect possible label conflict problem
964
+        # INFO - G.M - 12-03-2018 - Set label name to avoid trouble when
965
+        # un-deleting file.
966
+        content.label = '{label}-{action}-{date}'.format(
967
+            label=content.label,
968
+            action='deleted',
969
+            date=current_date_for_filename()
970
+        )
907 971
         content.revision_type = ActionDescription.DELETION
908 972
 
909 973
     def undelete(self, content: Content):

+ 4 - 2
tracim/tracim/lib/daemons.py Wyświetl plik

@@ -19,6 +19,7 @@ from tracim.lib.base import logger
19 19
 from tracim.lib.exceptions import AlreadyRunningDaemon
20 20
 
21 21
 from tracim.lib.utils import get_rq_queue
22
+from tracim.lib.utils import TracimEnforceHTTPS
22 23
 from tracim.lib.email_fetcher import MailFetcher
23 24
 
24 25
 
@@ -389,13 +390,14 @@ class WsgiDavDaemon(Daemon):
389 390
             print(
390 391
                 "WARNING: Could not import lxml: using xml instead (slower). Consider installing lxml from http://codespeak.net/lxml/.")
391 392
         from wsgidav.dir_browser import WsgiDavDirBrowser
392
-        from tracim.lib.webdav.tracim_http_authenticator import TracimHTTPAuthenticator
393
+        from wsgidav.http_authenticator import HTTPAuthenticator
393 394
         from wsgidav.error_printer import ErrorPrinter
394 395
         from tracim.lib.webdav.utils import TracimWsgiDavDebugFilter
395 396
 
396 397
         config['middleware_stack'] = [
398
+            TracimEnforceHTTPS,
397 399
             WsgiDavDirBrowser,
398
-            TracimHTTPAuthenticator,
400
+            HTTPAuthenticator,
399 401
             ErrorPrinter,
400 402
             TracimWsgiDavDebugFilter,
401 403
         ]

+ 31 - 0
tracim/tracim/lib/utils.py Wyświetl plik

@@ -1,4 +1,5 @@
1 1
 # -*- coding: utf-8 -*-
2
+import datetime
2 3
 import os
3 4
 import time
4 5
 import signal
@@ -18,6 +19,7 @@ from redis import Redis
18 19
 from rq import Queue
19 20
 from unidecode import unidecode
20 21
 
22
+from wsgidav.middleware import BaseMiddleware
21 23
 from tracim.lib.base import logger
22 24
 from webob import Response
23 25
 from webob.exc import WSGIHTTPException
@@ -193,3 +195,32 @@ def get_rq_queue(queue_name: str= 'default') -> Queue:
193 195
         port=cfg.EMAIL_SENDER_REDIS_PORT,
194 196
         db=cfg.EMAIL_SENDER_REDIS_DB,
195 197
     ))
198
+
199
+def current_date_for_filename() -> str:
200
+    """
201
+    ISO8601 current date, adapted to be used in filename (for
202
+    webdav feature for example), with trouble-free characters.
203
+    :return: current date as string like "2018-03-19T15.49.27.246592"
204
+    """
205
+    # INFO - G.M - 19-03-2018 - As ':' is in transform_to_bdd method in
206
+    # webdav utils, it may cause trouble. So, it should be replaced to
207
+    # a character which will not change in bdd.
208
+    return datetime.datetime.now().isoformat().replace(':', '.')
209
+
210
+class TracimEnforceHTTPS(BaseMiddleware):
211
+
212
+    def __init__(self, application, config):
213
+        super().__init__(application, config)
214
+        self._application = application
215
+        self._config = config
216
+
217
+    def __call__(self, environ, start_response):
218
+        # TODO - G.M - 06-03-2018 - Check protocol from http header first
219
+        # see http://www.bortzmeyer.org/7239.html
220
+        # if this params doesn't exist, rely on tracim config
221
+        from tracim.config.app_cfg import CFG
222
+        cfg = CFG.get_instance()
223
+
224
+        if cfg.WEBSITE_BASE_URL.startswith('https'):
225
+            environ['wsgi.url_scheme'] = 'https'
226
+        return self._application(environ, start_response)

+ 4 - 3
tracim/tracim/lib/webdav/design.py Wyświetl plik

@@ -120,7 +120,8 @@ _LABELS = {
120 120
     'unarchiving': 'Item unarchived',
121 121
     'undeletion': 'Item undeleted',
122 122
     'move': 'Item moved',
123
-    'comment': 'Comment'
123
+    'comment': 'Comment',
124
+    'copy' : 'Item copied',
124 125
 }
125 126
 
126 127
 
@@ -148,7 +149,7 @@ def create_readable_date(created, delta_from_datetime: datetime = None):
148 149
     return aff
149 150
 
150 151
 def designPage(content: data.Content, content_revision: data.ContentRevisionRO) -> str:
151
-    hist = content.get_history()
152
+    hist = content.get_history(drop_empty_revision=False)
152 153
     histHTML = '<table class="table table-striped table-hover">'
153 154
     for event in hist:
154 155
         if isinstance(event, VirtualEvent):
@@ -237,7 +238,7 @@ def designPage(content: data.Content, content_revision: data.ContentRevisionRO)
237 238
     return page
238 239
 
239 240
 def designThread(content: data.Content, content_revision: data.ContentRevisionRO, comments) -> str:
240
-        hist = content.get_history()
241
+        hist = content.get_history(drop_empty_revision=False)
241 242
 
242 243
         allT = []
243 244
         allT += comments

+ 11 - 11
tracim/tracim/lib/webdav/sql_domain_controller.py Wyświetl plik

@@ -2,6 +2,9 @@
2 2
 
3 3
 from tracim.lib.user import UserApi
4 4
 
5
+class DigestAuthNotImplemented(Exception):
6
+    pass
7
+
5 8
 class TracimDomainController(object):
6 9
     """
7 10
     The domain controller is used by http_authenticator to authenticate the user every time a request is
@@ -13,6 +16,14 @@ class TracimDomainController(object):
13 16
     def getDomainRealm(self, inputURL, environ):
14 17
         return '/'
15 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
+
16 27
     def requireAuthentication(self, realmname, environ):
17 28
         return True
18 29
 
@@ -27,17 +38,6 @@ class TracimDomainController(object):
27 38
         except:
28 39
             return False
29 40
 
30
-    def get_left_digest_response_hash(self, realmname, username, environ):
31
-        """
32
-        Called by our http_authenticator to get the hashed md5 digest for the current user that is also sent by
33
-        the webdav client
34
-        """
35
-        try:
36
-            user = self._api.get_one_by_email(username)
37
-            return user.webdav_left_digest_response_hash
38
-        except:
39
-            return None
40
-
41 41
     def authDomainUser(self, realmname, username, password, environ):
42 42
         """
43 43
         If you ever feel the need to send a request al-mano with a curl, this is the function that'll be called by

+ 77 - 62
tracim/tracim/lib/webdav/sql_resources.py Wyświetl plik

@@ -36,7 +36,8 @@ logger = logging.getLogger()
36 36
 
37 37
 class ManageActions(object):
38 38
     """
39
-    This object is used to encapsulate all Deletion/Archiving related method as to not duplicate too much code
39
+    This object is used to encapsulate all Deletion/Archiving related
40
+    method as to not duplicate too much code
40 41
     """
41 42
     def __init__(self, action_type: str, api: ContentApi, content: Content):
42 43
         self.content_api = api
@@ -49,57 +50,14 @@ class ManageActions(object):
49 50
             ActionDescription.UNDELETION: self.content_api.undelete
50 51
         }
51 52
 
52
-        self._to_name = {
53
-            ActionDescription.ARCHIVING: 'archived',
54
-            ActionDescription.DELETION: 'deleted'
55
-        }
56
-
57 53
         self._type = action_type
58
-        self._new_name = self.make_name()
59 54
 
60 55
     def action(self):
61
-        try:
62
-            # When undeleting/unarchiving we except a content with the new name to not exist, thus if we
63
-            # don't get an error and the database request send back a result, we stop the action
64
-            self.content_api.get_one_by_label_and_parent(self._new_name, self.content.parent)
65
-            raise DAVError(HTTP_FORBIDDEN)
66
-        except NoResultFound:
67
-            with new_revision(self.content):
68
-                self.content_api.update_content(self.content, self._new_name)
69
-                self._actions[self._type](self.content)
70
-                self.content_api.save(self.content, self._type)
71
-
72
-            transaction.commit()
73
-
74
-    def make_name(self) -> str:
75
-        """
76
-        Will create the new name, either by adding '- deleted the [date]' after the name when archiving/deleting or
77
-        removing this string when undeleting/unarchiving
78
-        """
79
-        new_name = self.content.get_label_as_file()
80
-        extension = ''
81
-
82
-        # if the content has no label, the last .ext is important
83
-        # thus we want to rename a file from 'file.txt' to 'file - deleted... .txt' and not 'file.txt - deleted...'
84
-        is_file_name = self.content.label == ''
85
-        if is_file_name:
86
-            search = re.search(r'(\.[^.]+)$', new_name)
87
-            if search:
88
-                extension = search.group(0)
89
-            new_name = re.sub(r'(\.[^.]+)$', '', new_name)
90
-
91
-        if self._type in [ActionDescription.ARCHIVING, ActionDescription.DELETION]:
92
-            new_name += ' - %s the %s' % (self._to_name[self._type], datetime.now().strftime('%d-%m-%Y at %H:%M'))
93
-        else:
94
-            new_name = re.sub(
95
-                r'( - (%s|%s) the .*)$' % (self._to_name[ActionDescription.DELETION], self._to_name[ActionDescription.ARCHIVING]),
96
-                '',
97
-                new_name
98
-            )
99
-
100
-        new_name += extension
56
+        with new_revision(self.content):
57
+            self._actions[self._type](self.content)
58
+            self.content_api.save(self.content, self._type)
101 59
 
102
-        return new_name
60
+        transaction.commit()
103 61
 
104 62
 
105 63
 class Root(DAVCollection):
@@ -964,12 +922,25 @@ class File(DAVNonCollection):
964 922
         if invalid_path:
965 923
             raise DAVError(HTTP_FORBIDDEN)
966 924
 
967
-    def move_file(self, destpath):
925
+    def move_file(self, destpath: str) -> None:
926
+        """
927
+        Move file mean changing the path to access to a file. This can mean
928
+        simple renaming(1), moving file from a directory to one another(2)
929
+        but also renaming + moving file from a directory to one another at
930
+        the same time (3).
931
+
932
+        (1): move /dir1/file1 -> /dir1/file2
933
+        (2): move /dir1/file1 -> /dir2/file1
934
+        (3): move /dir1/file1 -> /dir2/file2
935
+        :param destpath: destination path of webdav move
936
+        :return: nothing
937
+        """
968 938
 
969 939
         workspace = self.content.workspace
970 940
         parent = self.content.parent
971 941
 
972 942
         with new_revision(self.content):
943
+            # INFO - G.M - 2018-03-09 - First, renaming file if needed
973 944
             if basename(destpath) != self.getDisplayName():
974 945
                 new_given_file_name = transform_to_bdd(basename(destpath))
975 946
                 new_file_name, new_file_extension = \
@@ -981,21 +952,23 @@ class File(DAVNonCollection):
981 952
                 )
982 953
                 self.content.file_extension = new_file_extension
983 954
                 self.content_api.save(self.content)
984
-            else:
985
-                workspace_api = WorkspaceApi(self.user)
986
-                content_api = ContentApi(self.user)
987 955
 
988
-                destination_workspace = self.provider.get_workspace_from_path(
989
-                    destpath,
990
-                    workspace_api,
991
-                )
992
-
993
-                destination_parent = self.provider.get_parent_from_path(
994
-                    destpath,
995
-                    content_api,
996
-                    destination_workspace,
997
-                )
956
+            # INFO - G.M - 2018-03-09 - Moving file if needed
957
+            workspace_api = WorkspaceApi(self.user)
958
+            content_api = ContentApi(self.user)
998 959
 
960
+            destination_workspace = self.provider.get_workspace_from_path(
961
+                destpath,
962
+                workspace_api,
963
+            )
964
+            destination_parent = self.provider.get_parent_from_path(
965
+                destpath,
966
+                content_api,
967
+                destination_workspace,
968
+            )
969
+            if destination_parent != parent or destination_workspace != workspace:  # nopep8
970
+                #  INFO - G.M - 12-03-2018 - Avoid moving the file "at the same place"  # nopep8
971
+                #  if the request does not result in a real move.
999 972
                 self.content_api.move(
1000 973
                     item=self.content,
1001 974
                     new_parent=destination_parent,
@@ -1005,6 +978,48 @@ class File(DAVNonCollection):
1005 978
 
1006 979
         transaction.commit()
1007 980
 
981
+    def copyMoveSingle(self, destpath, isMove):
982
+        if isMove:
983
+            # INFO - G.M - 12-03-2018 - This case should not happen
984
+            # As far as moveRecursive method exist, all move should not go
985
+            # through this method. If such case appear, try replace this to :
986
+            ####
987
+            # self.move_file(destpath)
988
+            # return
989
+            ####
990
+
991
+            raise NotImplemented
992
+
993
+        new_file_name = None
994
+        new_file_extension = None
995
+
996
+        # Inspect destpath
997
+        if basename(destpath) != self.getDisplayName():
998
+            new_given_file_name = transform_to_bdd(basename(destpath))
999
+            new_file_name, new_file_extension = \
1000
+                os.path.splitext(new_given_file_name)
1001
+
1002
+        workspace_api = WorkspaceApi(self.user)
1003
+        content_api = ContentApi(self.user)
1004
+        destination_workspace = self.provider.get_workspace_from_path(
1005
+            destpath,
1006
+            workspace_api,
1007
+        )
1008
+        destination_parent = self.provider.get_parent_from_path(
1009
+            destpath,
1010
+            content_api,
1011
+            destination_workspace,
1012
+        )
1013
+        workspace = self.content.workspace
1014
+        parent = self.content.parent
1015
+        new_content = self.content_api.copy(
1016
+            item=self.content,
1017
+            new_label=new_file_name,
1018
+            new_parent=destination_parent,
1019
+        )
1020
+        self.content_api.copy_children(self.content, new_content)
1021
+        transaction.commit()
1022
+
1008 1023
     def supportRecursiveMove(self, destPath):
1009 1024
         return True
1010 1025
 

+ 0 - 156
tracim/tracim/lib/webdav/tracim_http_authenticator.py Wyświetl plik

@@ -1,156 +0,0 @@
1
-import os
2
-import re
3
-
4
-from wsgidav.http_authenticator import HTTPAuthenticator
5
-from wsgidav import util
6
-import cherrypy
7
-
8
-from tracim.lib.user import CurrentUserGetterApi
9
-from tracim.lib.user import CURRENT_USER_WSGIDAV
10
-
11
-_logger = util.getModuleLogger(__name__, True)
12
-HOTFIX_WINXP_AcceptRootShareLogin = True
13
-
14
-
15
-class TracimHTTPAuthenticator(HTTPAuthenticator):
16
-    def __init__(self, application, config):
17
-        super(TracimHTTPAuthenticator, self).__init__(application, config)
18
-        self._headerfixparser = re.compile(r'([\w]+)=("[^"]*,[^"]*"),')
19
-
20
-    def authDigestAuthRequest(self, environ, start_response):
21
-        realmname = self._domaincontroller.getDomainRealm(environ["PATH_INFO"], environ)
22
-
23
-        isinvalidreq = False
24
-
25
-        authheaderdict = dict([])
26
-        authheaders = environ["HTTP_AUTHORIZATION"] + ","
27
-        if not authheaders.lower().strip().startswith("digest"):
28
-            isinvalidreq = True
29
-            # Hotfix for Windows file manager and OSX Finder:
30
-        # Some clients don't urlencode paths in auth header, so uri value may
31
-        # contain commas, which break the usual regex headerparser. Example:
32
-        # Digest username="user",realm="/",uri="a,b.txt",nc=00000001, ...
33
-        # -> [..., ('uri', '"a'), ('nc', '00000001'), ...]
34
-        # Override any such values with carefully extracted ones.
35
-        authheaderlist = self._headerparser.findall(authheaders)
36
-        authheaderfixlist = self._headerfixparser.findall(authheaders)
37
-        if authheaderfixlist:
38
-            _logger.info("Fixing authheader comma-parsing: extend %s with %s" \
39
-                         % (authheaderlist, authheaderfixlist))
40
-            authheaderlist += authheaderfixlist
41
-        for authheader in authheaderlist:
42
-            authheaderkey = authheader[0]
43
-            authheadervalue = authheader[1].strip().strip("\"")
44
-            authheaderdict[authheaderkey] = authheadervalue
45
-
46
-        _logger.debug("authDigestAuthRequest: %s" % environ["HTTP_AUTHORIZATION"])
47
-        _logger.debug("  -> %s" % authheaderdict)
48
-
49
-        if "username" in authheaderdict:
50
-            req_username = authheaderdict["username"]
51
-            req_username_org = req_username
52
-            # Hotfix for Windows XP:
53
-            #   net use W: http://127.0.0.1/dav /USER:DOMAIN\tester tester
54
-            # will send the name with double backslashes ('DOMAIN\\tester')
55
-            # but send the digest for the simple name ('DOMAIN\tester').
56
-            if r"\\" in req_username:
57
-                req_username = req_username.replace("\\\\", "\\")
58
-                _logger.info("Fixing Windows name with double backslash: '%s' --> '%s'" % (req_username_org, req_username))
59
-
60
-            if not self._domaincontroller.isRealmUser(realmname, req_username, environ):
61
-                isinvalidreq = True
62
-        else:
63
-            isinvalidreq = True
64
-
65
-            # TODO: Chun added this comments, but code was commented out
66
-            # Do not do realm checking - a hotfix for WinXP using some other realm's
67
-            # auth details for this realm - if user/password match
68
-
69
-        if 'realm' in authheaderdict:
70
-            if authheaderdict["realm"].upper() != realmname.upper():
71
-                if HOTFIX_WINXP_AcceptRootShareLogin:
72
-                    # Hotfix: also accept '/'
73
-                    if authheaderdict["realm"].upper() != "/":
74
-                        isinvalidreq = True
75
-                else:
76
-                    isinvalidreq = True
77
-
78
-        if "algorithm" in authheaderdict:
79
-            if authheaderdict["algorithm"].upper() != "MD5":
80
-                isinvalidreq = True  # only MD5 supported
81
-
82
-        if "uri" in authheaderdict:
83
-            req_uri = authheaderdict["uri"]
84
-
85
-        if "nonce" in authheaderdict:
86
-            req_nonce = authheaderdict["nonce"]
87
-        else:
88
-            isinvalidreq = True
89
-
90
-        req_hasqop = False
91
-        if "qop" in authheaderdict:
92
-            req_hasqop = True
93
-            req_qop = authheaderdict["qop"]
94
-            if req_qop.lower() != "auth":
95
-                isinvalidreq = True  # only auth supported, auth-int not supported
96
-        else:
97
-            req_qop = None
98
-
99
-        if "cnonce" in authheaderdict:
100
-            req_cnonce = authheaderdict["cnonce"]
101
-        else:
102
-            req_cnonce = None
103
-            if req_hasqop:
104
-                isinvalidreq = True
105
-
106
-        if "nc" in authheaderdict:  # is read but nonce-count checking not implemented
107
-            req_nc = authheaderdict["nc"]
108
-        else:
109
-            req_nc = None
110
-            if req_hasqop:
111
-                isinvalidreq = True
112
-
113
-        if "response" in authheaderdict:
114
-            req_response = authheaderdict["response"]
115
-        else:
116
-            isinvalidreq = True
117
-
118
-        if not isinvalidreq:
119
-            left_digest_response_hash = self._domaincontroller.get_left_digest_response_hash(realmname, req_username, environ)
120
-
121
-            req_method = environ["REQUEST_METHOD"]
122
-
123
-            required_digest = self.tracim_compute_digest_response(left_digest_response_hash, req_method, req_uri, req_nonce,
124
-                                                         req_cnonce, req_qop, req_nc)
125
-
126
-            if required_digest != req_response:
127
-                _logger.warning("computeDigestResponse('%s', '%s', ...): %s != %s" % (
128
-                realmname, req_username, required_digest, req_response))
129
-                isinvalidreq = True
130
-            else:
131
-                _logger.debug("digest succeeded for realm '%s', user '%s'" % (realmname, req_username))
132
-            pass
133
-
134
-        if isinvalidreq:
135
-            _logger.warning("Authentication failed for user '%s', realm '%s'" % (req_username, realmname))
136
-            return self.sendDigestAuthResponse(environ, start_response)
137
-
138
-        environ["http_authenticator.realm"] = realmname
139
-        environ["http_authenticator.username"] = req_username
140
-
141
-        # Set request current user email to be able to recognise him later
142
-        cherrypy.request.current_user_email = req_username
143
-        CurrentUserGetterApi.set_thread_local_getter(CURRENT_USER_WSGIDAV)
144
-
145
-        return self._application(environ, start_response)
146
-
147
-    def tracim_compute_digest_response(self, left_digest_response_hash, method, uri, nonce, cnonce, qop, nc):
148
-        # A1 : username:realm:password
149
-        A2 = method + ":" + uri
150
-        if qop:
151
-            digestresp = self.md5kd(left_digest_response_hash, nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + self.md5h(A2))
152
-        else:
153
-            digestresp = self.md5kd(left_digest_response_hash, nonce + ":" + self.md5h(A2))
154
-            # print(A1, A2)
155
-        # print(digestresp)
156
-        return digestresp

+ 6 - 2
tracim/tracim/model/__init__.py Wyświetl plik

@@ -120,7 +120,10 @@ def prevent_content_revision_delete(session: Session, flush_context: UOWTransact
120 120
 
121 121
 
122 122
 @contextmanager
123
-def new_revision(content: Content) -> Content:
123
+def new_revision(
124
+        content: Content,
125
+        force_create_new_revision: bool=False,
126
+) -> Content:
124 127
     """
125 128
     Prepare context to update a Content. It will add a new updatable revision to the content.
126 129
     :param content: Content instance to update
@@ -128,7 +131,8 @@ def new_revision(content: Content) -> Content:
128 131
     """
129 132
     with DBSession.no_autoflush:
130 133
         try:
131
-            if inspect(content.revision).has_identity:
134
+            if force_create_new_revision \
135
+                    or inspect(content.revision).has_identity:
132 136
                 content.new_revision()
133 137
             RevisionsIntegrity.add_to_updatable(content.revision)
134 138
             yield content

+ 0 - 25
tracim/tracim/model/auth.py Wyświetl plik

@@ -128,7 +128,6 @@ class User(DeclarativeBase):
128 128
     is_active = Column(Boolean, default=True, nullable=False)
129 129
     imported_from = Column(Unicode(32), nullable=True)
130 130
     timezone = Column(Unicode(255), nullable=False, server_default='')
131
-    _webdav_left_digest_response_hash = Column('webdav_left_digest_response_hash', Unicode(128))
132 131
     auth_token = Column(Unicode(255))
133 132
     auth_token_created = Column(DateTime)
134 133
 
@@ -202,10 +201,8 @@ class User(DeclarativeBase):
202 201
 
203 202
         Hash cleartext password on the fly,
204 203
         Store its ciphertext version,
205
-        Update the WebDAV hash as well.
206 204
         """
207 205
         self._password = self._hash_password(cleartext_password)
208
-        self.update_webdav_digest_auth(cleartext_password)
209 206
 
210 207
     def _get_password(self) -> str:
211 208
         """Return the hashed version of the password."""
@@ -214,26 +211,6 @@ class User(DeclarativeBase):
214 211
     password = synonym('_password', descriptor=property(_get_password,
215 212
                                                         _set_password))
216 213
 
217
-    @classmethod
218
-    def _hash_digest(cls, digest):
219
-        return md5(bytes(digest, 'utf-8')).hexdigest()
220
-
221
-    def _set_hash_digest(self, digest):
222
-        self._webdav_left_digest_response_hash = self._hash_digest(digest)
223
-
224
-    def _get_hash_digest(self):
225
-        return self._webdav_left_digest_response_hash
226
-
227
-    webdav_left_digest_response_hash = synonym('_webdav_left_digest_response_hash',
228
-                                               descriptor=property(_get_hash_digest,
229
-                                                                   _set_hash_digest))
230
-
231
-    def update_webdav_digest_auth(self, cleartext_password: str) -> None:
232
-        self.webdav_left_digest_response_hash \
233
-            = '{username}:/:{cleartext_password}'.format(
234
-                username=self.email,
235
-                cleartext_password=cleartext_password,
236
-            )
237 214
 
238 215
     def validate_password(self, cleartext_password: str) -> bool:
239 216
         """
@@ -252,8 +229,6 @@ class User(DeclarativeBase):
252 229
             hash = sha256()
253 230
             hash.update((cleartext_password + self.password[:64]).encode('utf-8'))
254 231
             result = self.password[64:] == hash.hexdigest()
255
-            if result and not self.webdav_left_digest_response_hash:
256
-                self.update_webdav_digest_auth(cleartext_password)
257 232
         return result
258 233
 
259 234
     def get_display_name(self, remove_email_part: bool=False) -> str:

+ 66 - 7
tracim/tracim/model/data.py Wyświetl plik

@@ -204,6 +204,7 @@ class ActionDescription(object):
204 204
     - closed-deprecated
205 205
     """
206 206
 
207
+    COPY = 'copy'
207 208
     ARCHIVING = 'archiving'
208 209
     COMMENT = 'content-comment'
209 210
     CREATION = 'creation'
@@ -225,7 +226,8 @@ class ActionDescription(object):
225 226
         'status-update': 'fa-random',
226 227
         'unarchiving': 'fa-file-archive-o',
227 228
         'undeletion': 'fa-trash-o',
228
-        'move': 'fa-arrows'
229
+        'move': 'fa-arrows',
230
+        'copy': 'fa-files-o',
229 231
     }
230 232
 
231 233
     _LABELS = {
@@ -238,7 +240,8 @@ class ActionDescription(object):
238 240
         'status-update': l_('New status'),
239 241
         'unarchiving': l_('Item unarchived'),
240 242
         'undeletion': l_('Item undeleted'),
241
-        'move': l_('Item moved')
243
+        'move': l_('Item moved'),
244
+        'copy': l_('Item copied'),
242 245
     }
243 246
 
244 247
     def __init__(self, id):
@@ -259,7 +262,9 @@ class ActionDescription(object):
259 262
                 cls.STATUS_UPDATE,
260 263
                 cls.UNARCHIVING,
261 264
                 cls.UNDELETION,
262
-                cls.MOVE]
265
+                cls.MOVE,
266
+                cls.COPY,
267
+                ]
263 268
 
264 269
 
265 270
 class ContentStatus(object):
@@ -514,6 +519,11 @@ class ContentChecker(object):
514 519
                 return False
515 520
             return True
516 521
 
522
+        # TODO - G.M - 15-03-2018 - Choose only correct Content-type for origin
523
+        # Only content who can be copied need this
524
+        if item.type == ContentType.Any:
525
+            if 'origin' in properties.keys():
526
+                return True
517 527
         raise NotImplementedError
518 528
 
519 529
     @classmethod
@@ -580,7 +590,6 @@ class ContentRevisionRO(DeclarativeBase):
580 590
         'is_archived',
581 591
         'is_deleted',
582 592
         'label',
583
-        'node',
584 593
         'owner',
585 594
         'owner_id',
586 595
         'parent',
@@ -640,6 +649,36 @@ class ContentRevisionRO(DeclarativeBase):
640 649
 
641 650
         return new_rev
642 651
 
652
+    @classmethod
653
+    def copy(
654
+            cls,
655
+            revision: 'ContentRevisionRO',
656
+            parent: 'Content'
657
+    ) -> 'ContentRevisionRO':
658
+
659
+        copy_rev = cls()
660
+        import copy
661
+        copy_columns = cls._cloned_columns
662
+        for column_name in copy_columns:
663
+            # INFO - G-M - 15-03-2018 - set correct parent
664
+            if column_name == 'parent_id':
665
+                column_value = copy.copy(parent.id)
666
+            elif column_name == 'parent':
667
+                column_value = copy.copy(parent)
668
+            else:
669
+                column_value = copy.copy(getattr(revision, column_name))
670
+            setattr(copy_rev, column_name, column_value)
671
+
672
+        # copy attached_file
673
+        if revision.depot_file:
674
+            copy_rev.depot_file = FileIntent(
675
+                revision.depot_file.file.read(),
676
+                revision.file_name,
677
+                revision.file_mimetype,
678
+            )
679
+        return copy_rev
680
+
681
+
643 682
     def __setattr__(self, key: str, value: 'mixed'):
644 683
         """
645 684
         ContentRevisionUpdateError is raised if tried to update column and revision own identity
@@ -1077,7 +1116,7 @@ class Content(DeclarativeBase):
1077 1116
         revisions = sorted(self.revisions, key=lambda revision: revision.revision_id)
1078 1117
         return revisions[-1]
1079 1118
 
1080
-    def new_revision(self) -> None:
1119
+    def new_revision(self) -> ContentRevisionRO:
1081 1120
         """
1082 1121
         Return and assign to this content a new revision.
1083 1122
         If it's a new content, revision is totally new.
@@ -1234,11 +1273,24 @@ class Content(DeclarativeBase):
1234 1273
 
1235 1274
         return ContentType.sorted(types)
1236 1275
 
1237
-    def get_history(self) -> '[VirtualEvent]':
1276
+    def get_history(self, drop_empty_revision=False) -> '[VirtualEvent]':
1238 1277
         events = []
1239 1278
         for comment in self.get_comments():
1240 1279
             events.append(VirtualEvent.create_from_content(comment))
1241
-        for revision in self.revisions:
1280
+
1281
+        revisions = sorted(self.revisions, key=lambda rev: rev.revision_id)
1282
+        for revision in revisions:
1283
+            # INFO - G.M - 09-03-2018 - Do not show file revision with empty
1284
+            # file to have a more clear view of revision.
1285
+            # Some webdav client create empty file before uploading, we must
1286
+            # have possibility to not show the related revision
1287
+            if drop_empty_revision:
1288
+                if revision.depot_file and revision.depot_file.file.content_length == 0:  # nopep8
1289
+                    # INFO - G.M - 12-03-2018 -Always show the last and
1290
+                    # first revision.
1291
+                    if revision != revisions[-1] and revision != revisions[0]:
1292
+                        continue
1293
+
1242 1294
             events.append(VirtualEvent.create_from_content_revision(revision))
1243 1295
 
1244 1296
         sorted_events = sorted(events,
@@ -1253,6 +1305,13 @@ class Content(DeclarativeBase):
1253 1305
         cid = content.content_id
1254 1306
         return url_template.format(wid=wid, fid=fid, ctype=ctype, cid=cid)
1255 1307
 
1308
+    def copy(self, parent):
1309
+        cpy_content = Content()
1310
+        for rev in self.revisions:
1311
+            cpy_rev = ContentRevisionRO.copy(rev, parent)
1312
+            cpy_content.revisions.append(cpy_rev)
1313
+        return cpy_content
1314
+
1256 1315
 
1257 1316
 class RevisionReadStatus(DeclarativeBase):
1258 1317
 

+ 2 - 2
tracim/tracim/model/serializers.py Wyświetl plik

@@ -398,7 +398,7 @@ def serialize_node_for_page(content: Content, context: Context):
398 398
             links=[],
399 399
             revision_nb = len(content.revisions),
400 400
             selected_revision='latest' if content.revision_to_serialize<=0 else content.revision_to_serialize,
401
-            history=Context(CTX.CONTENT_HISTORY).toDict(content.get_history()),
401
+            history=Context(CTX.CONTENT_HISTORY).toDict(content.get_history(drop_empty_revision=True)),  # nopep8
402 402
             is_editable=content.is_editable,
403 403
             is_deleted=content.is_deleted,
404 404
             is_archived=content.is_archived,
@@ -475,7 +475,7 @@ def serialize_node_for_thread(item: Content, context: Context):
475 475
             workspace = context.toDict(item.workspace),
476 476
             comments = reversed(context.toDict(item.get_comments())),
477 477
             is_new=item.has_new_information_for(context.get_user()),
478
-            history = Context(CTX.CONTENT_HISTORY).toDict(item.get_history()),
478
+            history = Context(CTX.CONTENT_HISTORY).toDict(item.get_history(drop_empty_revision=True)),  # nopep8
479 479
             is_editable=item.is_editable,
480 480
             is_deleted=item.is_deleted,
481 481
             is_archived=item.is_archived,

+ 3 - 1
tracim/tracim/public/_caldavzap/resource.js Wyświetl plik

@@ -489,11 +489,13 @@ function ResourceCalDAVList()
489 489
 			newElement.addClass('r_operate');
490 490
 
491 491
     var uniqueIdForLabel = inputResource.displayvalue.replace('.', '') // remove the point so jquery doesn't consider it as 2 differents classes when used in a selector
492
+    // Côme - 2018/03/19 - added custom style to avoid the '...' for long workspace name that hide the entire name
493
+		var customStyle = 'display: inline-block;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;vertical-align: top;width: 80%;'
492 494
 		newElement.html("\
493 495
       <div class='resourceCalDAVColor' style='background:"+inputResource.ecolor+"'></div>\
494 496
       <input type='text' class='colorPicker'/>\
495 497
       <input type='checkbox' name="+inputResource.uid+" />\
496
-      <div class='"+uniqueIdForLabel+"' style='display:inline-block'>"+inputResource.displayvalue+"</div>");
498
+      <div class='"+uniqueIdForLabel+"' style='"+customStyle+"'>"+inputResource.displayvalue+"</div>");
497 499
 
498 500
     // +$('<div/>').text(inputResource.displayvalue).html()); // legacy code
499 501
     $.ajax({

+ 2 - 2
tracim/tracim/templates/workspace/getone.mak Wyświetl plik

@@ -129,12 +129,12 @@
129 129
             <div class="col-md-6">
130 130
                 <div class="input-group">
131 131
                     <span class="input-group-addon" style="width: 8em;"><i class="fa fa-fw fa-windows"></i> Windows</span>
132
-                    <div class="form-control webdavconfig__input">http://${webdav_url}</div>
132
+                    <div class="form-control webdavconfig__input">${website_protocol}://${webdav_url}</div>
133 133
                 </div>
134 134
                 <p></p>
135 135
                 <div class="input-group">
136 136
                     <span class="input-group-addon" style="width: 8em;"><i class="fa fa-fw fa-linux"></i> Linux</span>
137
-                    <div class="form-control webdavconfig__input">dav://${webdav_url}</div>
137
+                    <div class="form-control webdavconfig__input">${dav_protocol}://${webdav_url}</div>
138 138
                 </div>
139 139
             </div>
140 140
             <div class="col-md-6">

+ 0 - 6
tracim/tracim/tests/command/user.py Wyświetl plik

@@ -14,7 +14,6 @@ class TestUserCommand(TestCommand):
14 14
         # Check webdav digest exist for this user
15 15
         user = DBSession.query(User)\
16 16
             .filter(User.email == 'new-user@algoo.fr').one()
17
-        ok_(user.webdav_left_digest_response_hash)
18 17
 
19 18
     def test_update_password(self):
20 19
         self._create_user('new-user@algoo.fr', 'toor')
@@ -22,7 +21,6 @@ class TestUserCommand(TestCommand):
22 21
         # Grab webdav digest
23 22
         user = DBSession.query(User) \
24 23
             .filter(User.email == 'new-user@algoo.fr').one()
25
-        webdav_digest = user.webdav_left_digest_response_hash
26 24
 
27 25
         self._execute_command(
28 26
             UpdateUserCommand,
@@ -35,10 +33,6 @@ class TestUserCommand(TestCommand):
35 33
         # Grab new webdav digest to compare it
36 34
         user = DBSession.query(User) \
37 35
             .filter(User.email == 'new-user@algoo.fr').one()
38
-        ok_(
39
-            webdav_digest != user.webdav_left_digest_response_hash,
40
-            msg='Webdav digest should be different',
41
-        )
42 36
 
43 37
     def test_create_with_group(self):
44 38
         more_args = ['--add-to-group', 'managers', '--add-to-group', 'administrators']

+ 0 - 8
tracim/tracim/tests/functional/test_admin.py Wyświetl plik

@@ -43,9 +43,6 @@ class TestAuthentication(TracimTestController):
43 43
         ok_(user, msg="User should exist now")
44 44
         ok_(user.validate_password('password'))
45 45
 
46
-        # User must have webdav digest
47
-        ok_(user.webdav_left_digest_response_hash)
48
-
49 46
     def test_update_user_password(self):
50 47
         self._connect_user(
51 48
             'admin@admin.admin',
@@ -67,7 +64,6 @@ class TestAuthentication(TracimTestController):
67 64
 
68 65
         user = DBSession.query(User) \
69 66
             .filter(User.email == 'an-other-email@test.local').one()
70
-        webdav_digest = user.webdav_left_digest_response_hash
71 67
 
72 68
         self.app.post(
73 69
             '/admin/users/{user_id}/password?_method=PUT'.format(
@@ -82,7 +78,3 @@ class TestAuthentication(TracimTestController):
82 78
         user = DBSession.query(User) \
83 79
             .filter(User.email == 'an-other-email@test.local').one()
84 80
         ok_(user.validate_password('new-password'))
85
-        ok_(
86
-            webdav_digest != user.webdav_left_digest_response_hash,
87
-            msg='Webdav digest should be updated',
88
-        )

+ 0 - 5
tracim/tracim/tests/functional/test_user.py Wyświetl plik

@@ -22,7 +22,6 @@ class TestAuthentication(TracimTestController):
22 22
 
23 23
         user = DBSession.query(User) \
24 24
             .filter(User.email == 'lawrence-not-real-email@fsf.local').one()
25
-        webdav_digest = user.webdav_left_digest_response_hash
26 25
 
27 26
         try_post_user = self.app.post(
28 27
             '/user/{user_id}/password?_method=PUT'.format(
@@ -40,7 +39,3 @@ class TestAuthentication(TracimTestController):
40 39
         user = DBSession.query(User) \
41 40
             .filter(User.email == 'lawrence-not-real-email@fsf.local').one()
42 41
         ok_(user.validate_password('new-password'))
43
-        ok_(
44
-            webdav_digest != user.webdav_left_digest_response_hash,
45
-            msg='Webdav digest should be updated',
46
-        )

+ 262 - 0
tracim/tracim/tests/library/test_content_api.py Wyświetl plik

@@ -4,6 +4,7 @@ import datetime
4 4
 from nose.tools import eq_, ok_
5 5
 from nose.tools import raises
6 6
 
7
+from depot.io.utils import FileIntent
7 8
 import transaction
8 9
 
9 10
 from tracim.lib.content import compare_content_for_sorting_by_type_and_name
@@ -347,6 +348,267 @@ class TestContentApi(BaseTest, TestStandard):
347 348
         eq_('', c.label)
348 349
         eq_(ActionDescription.COMMENT, c.revision_type)
349 350
 
351
+    def test_unit_copy_file_different_label_different_parent_ok(self):
352
+        uapi = UserApi(None)
353
+        groups = [
354
+            GroupApi(None).get_one(Group.TIM_USER),
355
+            GroupApi(None).get_one(Group.TIM_MANAGER),
356
+            GroupApi(None).get_one(Group.TIM_ADMIN)
357
+        ]
358
+
359
+        user = uapi.create_user(
360
+            email='user1@user',
361
+            groups=groups,
362
+            save_now=True
363
+        )
364
+        user2 = uapi.create_user(
365
+            email='user2@user',
366
+            groups=groups,
367
+            save_now=True
368
+        )
369
+        workspace = WorkspaceApi(user).create_workspace(
370
+            'test workspace',
371
+            save_now=True
372
+        )
373
+        RoleApi(user).create_one(
374
+            user2,
375
+            workspace,
376
+            UserRoleInWorkspace.WORKSPACE_MANAGER,
377
+            with_notif=False
378
+        )
379
+        api = ContentApi(user)
380
+        foldera = api.create(
381
+            ContentType.Folder,
382
+            workspace,
383
+            None,
384
+            'folder a',
385
+            True
386
+        )
387
+        with DBSession.no_autoflush:
388
+            text_file = api.create(
389
+                content_type=ContentType.File,
390
+                workspace=workspace,
391
+                parent=foldera,
392
+                label='test_file',
393
+                do_save=False,
394
+            )
395
+            api.update_file_data(
396
+                text_file,
397
+                'test_file',
398
+                'text/plain',
399
+                b'test_content'
400
+            )
401
+
402
+        api.save(text_file, ActionDescription.CREATION)
403
+        api2 = ContentApi(user2)
404
+        workspace2 = WorkspaceApi(user2).create_workspace(
405
+            'test workspace2',
406
+            save_now=True
407
+        )
408
+        folderb = api2.create(
409
+            ContentType.Folder,
410
+            workspace2,
411
+            None,
412
+            'folder b',
413
+            True
414
+        )
415
+
416
+        api2.copy(
417
+            item=text_file,
418
+            new_parent=folderb,
419
+            new_label='test_file_copy'
420
+        )
421
+
422
+        transaction.commit()
423
+        text_file_copy = api2.get_one_by_label_and_parent(
424
+            'test_file_copy',
425
+            folderb,
426
+        )
427
+
428
+        assert text_file != text_file_copy
429
+        assert text_file_copy.content_id != text_file.content_id
430
+        assert text_file_copy.workspace_id == workspace2.workspace_id
431
+        assert text_file_copy.depot_file.file.read() == text_file.depot_file.file.read()   # nopep8
432
+        assert text_file_copy.depot_file.path != text_file.depot_file.path
433
+        assert text_file_copy.label == 'test_file_copy'
434
+        assert text_file_copy.type == text_file.type
435
+        assert text_file_copy.parent.content_id == folderb.content_id
436
+        assert text_file_copy.owner.user_id == user.user_id
437
+        assert text_file_copy.description == text_file.description
438
+        assert text_file_copy.file_extension == text_file.file_extension
439
+        assert text_file_copy.file_mimetype == text_file.file_mimetype
440
+        assert text_file_copy.revision_type == ActionDescription.COPY
441
+        assert len(text_file_copy.revisions) == len(text_file.revisions) + 1
442
+
443
+    def test_unit_copy_file__same_label_different_parent_ok(self):
444
+        uapi = UserApi(None)
445
+        groups = [GroupApi(None).get_one(Group.TIM_USER),
446
+                  GroupApi(None).get_one(Group.TIM_MANAGER),
447
+                  GroupApi(None).get_one(Group.TIM_ADMIN)]
448
+
449
+        user = uapi.create_user(
450
+            email='user1@user',
451
+            groups=groups,
452
+            save_now=True
453
+        )
454
+        user2 = uapi.create_user(
455
+            email='user2@user',
456
+            groups=groups,
457
+            save_now=True
458
+        )
459
+        workspace = WorkspaceApi(user).create_workspace(
460
+            'test workspace',
461
+            save_now=True
462
+        )
463
+        RoleApi(user).create_one(
464
+            user2,
465
+            workspace,
466
+            UserRoleInWorkspace.WORKSPACE_MANAGER,
467
+            with_notif=False
468
+        )
469
+        api = ContentApi(user)
470
+        foldera = api.create(
471
+            ContentType.Folder,
472
+            workspace,
473
+            None,
474
+            'folder a',
475
+            True
476
+        )
477
+        with DBSession.no_autoflush:
478
+            text_file = api.create(
479
+                content_type=ContentType.File,
480
+                workspace=workspace,
481
+                parent=foldera,
482
+                label='test_file',
483
+                do_save=False,
484
+            )
485
+            api.update_file_data(
486
+                text_file,
487
+                'test_file',
488
+                'text/plain',
489
+                b'test_content'
490
+            )
491
+
492
+        api.save(text_file, ActionDescription.CREATION)
493
+        api2 = ContentApi(user2)
494
+        workspace2 = WorkspaceApi(user2).create_workspace(
495
+            'test workspace2',
496
+            save_now=True
497
+        )
498
+        folderb = api2.create(
499
+            ContentType.Folder,
500
+            workspace2,
501
+            None,
502
+            'folder b',
503
+            True
504
+        )
505
+        api2.copy(
506
+            item=text_file,
507
+            new_parent=folderb,
508
+        )
509
+
510
+        transaction.commit()
511
+        text_file_copy = api2.get_one_by_label_and_parent(
512
+            'test_file',
513
+            folderb,
514
+        )
515
+
516
+        assert text_file != text_file_copy
517
+        assert text_file_copy.content_id != text_file.content_id
518
+        assert text_file_copy.workspace_id == workspace2.workspace_id
519
+        assert text_file_copy.depot_file.file.read() == text_file.depot_file.file.read()  # nopep8
520
+        assert text_file_copy.depot_file.path != text_file.depot_file.path
521
+        assert text_file_copy.label == text_file.label
522
+        assert text_file_copy.type == text_file.type
523
+        assert text_file_copy.parent.content_id == folderb.content_id
524
+        assert text_file_copy.owner.user_id == user.user_id
525
+        assert text_file_copy.description == text_file.description
526
+        assert text_file_copy.file_extension == text_file.file_extension
527
+        assert text_file_copy.file_mimetype == text_file.file_mimetype
528
+        assert text_file_copy.revision_type == ActionDescription.COPY
529
+        assert len(text_file_copy.revisions) == len(text_file.revisions) + 1
530
+
531
+    def test_unit_copy_file_different_label_same_parent_ok(self):
532
+        uapi = UserApi(None)
533
+        groups = [
534
+            GroupApi(None).get_one(Group.TIM_USER),
535
+            GroupApi(None).get_one(Group.TIM_MANAGER),
536
+            GroupApi(None).get_one(Group.TIM_ADMIN),
537
+        ]
538
+
539
+        user = uapi.create_user(
540
+            email='user1@user',
541
+            groups=groups,
542
+            save_now=True,
543
+        )
544
+        user2 = uapi.create_user(
545
+            email='user2@user',
546
+            groups=groups,
547
+            save_now=True
548
+        )
549
+        workspace = WorkspaceApi(user).create_workspace(
550
+            'test workspace',
551
+            save_now=True
552
+        )
553
+        RoleApi(user).create_one(
554
+            user2, workspace,
555
+            UserRoleInWorkspace.WORKSPACE_MANAGER,
556
+            with_notif=False
557
+        )
558
+        api = ContentApi(user)
559
+        foldera = api.create(
560
+            ContentType.Folder,
561
+            workspace,
562
+            None,
563
+            'folder a',
564
+            True
565
+        )
566
+        with DBSession.no_autoflush:
567
+            text_file = api.create(
568
+                content_type=ContentType.File,
569
+                workspace=workspace,
570
+                parent=foldera,
571
+                label='test_file',
572
+                do_save=False,
573
+            )
574
+            api.update_file_data(
575
+                text_file,
576
+                'test_file',
577
+                'text/plain',
578
+                b'test_content'
579
+            )
580
+
581
+        api.save(
582
+            text_file,
583
+            ActionDescription.CREATION
584
+        )
585
+        api2 = ContentApi(user2)
586
+
587
+        api2.copy(
588
+            item=text_file,
589
+            new_label='test_file_copy'
590
+        )
591
+
592
+        transaction.commit()
593
+        text_file_copy = api2.get_one_by_label_and_parent(
594
+            'test_file_copy',
595
+            foldera,
596
+        )
597
+
598
+        assert text_file != text_file_copy
599
+        assert text_file_copy.content_id != text_file.content_id
600
+        assert text_file_copy.workspace_id == workspace.workspace_id
601
+        assert text_file_copy.depot_file.file.read() == text_file.depot_file.file.read()  # nopep8
602
+        assert text_file_copy.depot_file.path != text_file.depot_file.path
603
+        assert text_file_copy.label == 'test_file_copy'
604
+        assert text_file_copy.type == text_file.type
605
+        assert text_file_copy.parent.content_id == foldera.content_id
606
+        assert text_file_copy.owner.user_id == user.user_id
607
+        assert text_file_copy.description == text_file.description
608
+        assert text_file_copy.file_extension == text_file.file_extension
609
+        assert text_file_copy.file_mimetype == text_file.file_mimetype
610
+        assert text_file_copy.revision_type == ActionDescription.COPY
611
+        assert len(text_file_copy.revisions) == len(text_file.revisions) + 1
350 612
 
351 613
     def test_mark_read__workspace(self):
352 614
         uapi = UserApi(None)

+ 44 - 0
tracim/tracim/tests/library/test_webdav.py Wyświetl plik

@@ -481,6 +481,50 @@ class TestWebDav(TestStandard):
481 481
             )
482 482
         )
483 483
 
484
+    def test_unit__move_and_rename_content__ok(self):
485
+        provider = self._get_provider()
486
+        environ = self._get_environ(
487
+            provider,
488
+            'bob@fsf.local',
489
+        )
490
+        w1f1d1 = provider.getResourceInst(
491
+            '/w1/w1f1/w1f1d1.txt',
492
+            environ,
493
+        )
494
+
495
+        content_w1f1d1 = DBSession.query(ContentRevisionRO) \
496
+            .filter(Content.label == 'w1f1d1') \
497
+            .one()  # It must exist only one revision, cf fixtures
498
+        ok_(content_w1f1d1, msg='w1f1d1 should be exist')
499
+        content_w1f1d1_id = content_w1f1d1.content_id
500
+        content_w1f1d1_parent = content_w1f1d1.parent
501
+        eq_(
502
+            content_w1f1d1_parent.label,
503
+            'w1f1',
504
+            msg='field parent should be w1f1',
505
+        )
506
+
507
+        w1f1d1.moveRecursive('/w1/w1f2/w1f1d1_RENAMED.txt')
508
+
509
+        # Database content is moved
510
+        content_w1f1d1 = DBSession.query(ContentRevisionRO) \
511
+            .filter(ContentRevisionRO.content_id == content_w1f1d1_id) \
512
+            .order_by(ContentRevisionRO.revision_id.desc()) \
513
+            .first()
514
+        ok_(
515
+            content_w1f1d1.parent.label != content_w1f1d1_parent.label,
516
+            msg='file should be moved in w1f2 but is in {0}'.format(
517
+                content_w1f1d1.parent.label
518
+            )
519
+        )
520
+        eq_(
521
+            'w1f1d1_RENAMED',
522
+            content_w1f1d1.label,
523
+            msg='File should be labeled w1f1d1_RENAMED, not {0}'.format(
524
+                content_w1f1d1.label
525
+            )
526
+        )
527
+
484 528
     def test_unit__move_content__ok__another_workspace(self):
485 529
         provider = self._get_provider()
486 530
         environ = self._get_environ(

+ 7 - 1
tracim/tracim/tests/models/test_content_revision.py Wyświetl plik

@@ -13,7 +13,13 @@ from tracim.tests import TestStandard, BaseTest
13 13
 class TestContentRevision(BaseTest, TestStandard):
14 14
 
15 15
     def _new_from(self, revision):
16
-        excluded_columns = ('revision_id', '_sa_instance_state', 'depot_file')
16
+        excluded_columns = (
17
+            'revision_id',
18
+            '_sa_instance_state',
19
+            'depot_file',
20
+            'node',
21
+            'revision_read_statuses',
22
+        )
17 23
         revision_columns = [attr.key for attr in inspect(revision).attrs if not attr.key in excluded_columns]
18 24
         new_revision = ContentRevisionRO()
19 25
 

+ 4 - 0
tracim/wsgidav.conf.sample Wyświetl plik

@@ -25,6 +25,10 @@ manager_locks = True
25 25
 
26 26
 root_path = ''
27 27
 
28
+# Tracim doesn't support digest auth for webdav
29
+acceptbasic = True
30
+acceptdigest = False
31
+defaultdigest = False
28 32
 #===============================================================================
29 33
 # Lock Manager
30 34
 #