• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

fiduswriter / fiduswriter / 25264717828

02 May 2026 11:36PM UTC coverage: 86.618% (+0.1%) from 86.52%
25264717828

push

github

web-flow
Merge pull request #1383 from fiduswriter/feature/e2ee

Add E2E Encryption mode

8764 of 10118 relevant lines covered (86.62%)

5.27 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.85
fiduswriter/document/models.py
1
import uuid
17✔
2
from django.db import models
17✔
3
from django.db.utils import OperationalError, ProgrammingError
17✔
4
from django.core import checks
17✔
5
from django.conf import settings
17✔
6
from django.contrib.contenttypes.fields import GenericForeignKey
17✔
7
from django.contrib.contenttypes.models import ContentType
17✔
8

9
# FW_DOCUMENT_VERSION:
10
# Also defined in frontend
11
# document/static/js/modules/schema/index.js
12

13
FW_DOCUMENT_VERSION = 3.5
17✔
14

15

16
class DocumentTemplate(models.Model):
17✔
17
    title = models.TextField(default="", blank=True)
17✔
18
    import_id = models.CharField(max_length=255, default="", blank=True)
17✔
19
    content = models.JSONField(default=dict)
17✔
20
    doc_version = models.DecimalField(
17✔
21
        max_digits=3, decimal_places=1, default=FW_DOCUMENT_VERSION
22
    )
23
    user = models.ForeignKey(
17✔
24
        settings.AUTH_USER_MODEL,
25
        null=True,
26
        blank=True,
27
        on_delete=models.deletion.CASCADE,
28
    )
29
    added = models.DateTimeField(auto_now_add=True)
17✔
30
    updated = models.DateTimeField(auto_now=True)
17✔
31
    auto_delete = True
17✔
32

33
    def __str__(self):
17✔
34
        return self.title
1✔
35

36
    def is_deletable(self):
17✔
37
        reverse_relations = [
1✔
38
            f
39
            for f in self._meta.model._meta.get_fields()
40
            if (f.one_to_many or f.one_to_one)
41
            and f.auto_created
42
            and not f.concrete
43
            and f.name not in ["documentstyle", "exporttemplate"]
44
        ]
45

46
        for r in reverse_relations:
1✔
47
            if r.remote_field.model.objects.filter(
1✔
48
                **{r.field.name: self}
49
            ).exists():
50
                return False
×
51
        return True
1✔
52

53
    @classmethod
17✔
54
    def check(cls, **kwargs):
17✔
55
        errors = super().check(**kwargs)
17✔
56
        errors.extend(cls._check_doc_versions(**kwargs))
17✔
57
        return errors
17✔
58

59
    @classmethod
17✔
60
    def _check_doc_versions(cls, **kwargs):
17✔
61
        try:
17✔
62
            if cls.objects.filter(
17✔
63
                doc_version__lt=str(FW_DOCUMENT_VERSION)
64
            ).exists():
65
                return [
×
66
                    checks.Warning(
67
                        "Document templates need to be upgraded. Please "
68
                        "navigate to /admin/document/document/maintenance/ "
69
                        "with a browser as a superuser and upgrade all "
70
                        "document templates on this server.",
71
                        obj=cls,
72
                    )
73
                ]
74
            else:
75
                return []
17✔
76
        except (ProgrammingError, OperationalError):
17✔
77
            # Database has not yet been initialized, so don't throw any error.
78
            return []
17✔
79

80

81
class Document(models.Model):
17✔
82
    title = models.TextField(default="", blank=True)
17✔
83
    path = models.TextField(default="", blank=True)
17✔
84
    content = models.JSONField(default=dict)
17✔
85
    doc_version = models.DecimalField(
17✔
86
        max_digits=3, decimal_places=1, default=FW_DOCUMENT_VERSION
87
    )
88
    # The doc_version is the version of the data format in the content field.
89
    # We upgrade the content field in JavaScript and not migrations so that
90
    # the same code can be used for migrations and for importing old fidus
91
    # files that are being uploaded. This field is only used for upgrading data
92
    # and is therefore not handed to the editor or document overview page.
93
    version = models.PositiveIntegerField(default=0)
17✔
94
    diffs = models.JSONField(default=list, blank=True)
17✔
95
    # The last few diffs that were received and approved. The number of stored
96
    # diffs should always be equivalent to or more than all the diffs since the
97
    # last full save of the document.
98
    owner = models.ForeignKey(
17✔
99
        settings.AUTH_USER_MODEL,
100
        related_name="owner",
101
        on_delete=models.deletion.CASCADE,
102
    )
103
    added = models.DateTimeField(auto_now_add=True)
17✔
104
    updated = models.DateTimeField(auto_now=True)
17✔
105
    comments = models.JSONField(default=dict, blank=True)
17✔
106
    bibliography = models.JSONField(default=dict, blank=True)
17✔
107
    # Whether or not document is listed on document overview list page.
108
    # True by default and for all normal documents. Can be set to False when
109
    # documents are added in plugins that list these documents somewhere else.
110
    listed = models.BooleanField(default=True)
17✔
111
    template = models.ForeignKey(
17✔
112
        DocumentTemplate, on_delete=models.deletion.CASCADE
113
    )
114
    e2ee = models.BooleanField(default=False)
17✔
115
    e2ee_salt = models.BinaryField(max_length=16, null=True, blank=True)
17✔
116
    e2ee_iterations = models.PositiveIntegerField(default=600000)
17✔
117
    # For E2EE documents: the document version at which the last encrypted
118
    # snapshot was saved. Diffs stored in `diffs` cover snapshot_version →
119
    # version. Null for non-E2EE documents.
120
    e2ee_snapshot_version = models.PositiveIntegerField(null=True, blank=True)
17✔
121

122
    def __str__(self):
17✔
123
        if len(self.title) > 0:
×
124
            return f"{self.title} ({self.id})"
×
125
        else:
126
            return str(self.id)
×
127

128
    class Meta:
17✔
129
        ordering = ["-id"]
17✔
130

131
    def clean(self, *args, **kwargs):
17✔
132
        if self.comments is None:
13✔
133
            self.comments = "{}"
×
134
        if self.bibliography is None:
13✔
135
            self.bibliography = "{}"
×
136

137
    def save(self, *args, **kwargs):
17✔
138
        self.clean()
13✔
139
        return super().save(*args, **kwargs)
13✔
140

141
    def get_absolute_url(self):
17✔
142
        return "/document/%i/" % self.id
8✔
143

144
    def is_deletable(self):
17✔
145
        reverse_relations = [
2✔
146
            f
147
            for f in self._meta.model._meta.get_fields()
148
            if (f.one_to_many or f.one_to_one)
149
            and f.auto_created
150
            and not f.concrete
151
            and f.name
152
            not in [
153
                "accessright",
154
                "accessrightinvite",
155
                "documentrevision",
156
                "documentimage",
157
            ]
158
        ]
159

160
        for r in reverse_relations:
2✔
161
            if r.remote_field.model.objects.filter(
2✔
162
                **{r.field.name: self}
163
            ).exists():
164
                return False
×
165
        return True
2✔
166

167
    @classmethod
17✔
168
    def check(cls, **kwargs):
17✔
169
        errors = super().check(**kwargs)
17✔
170
        errors.extend(cls._check_doc_versions(**kwargs))
17✔
171
        return errors
17✔
172

173
    @classmethod
17✔
174
    def _check_doc_versions(cls, **kwargs):
17✔
175
        try:
17✔
176
            if len(
17✔
177
                cls.objects.filter(doc_version__lt=str(FW_DOCUMENT_VERSION))
178
            ):
179
                return [
×
180
                    checks.Warning(
181
                        "Documents need to be upgraded. Please navigate to "
182
                        "/admin/document/document/maintenance/ with a browser "
183
                        "as a superuser and upgrade all documents on this "
184
                        "server.",
185
                        obj=cls,
186
                    )
187
                ]
188
            else:
189
                return []
17✔
190
        except (ProgrammingError, OperationalError):
17✔
191
            # Database has not yet been initialized, so don't throw any error.
192
            return []
17✔
193

194

195
RIGHTS_CHOICES = (
17✔
196
    ("write", "Writer"),
197
    # Can write content and can read+write comments.
198
    # Can chat with collaborators.
199
    # Has read access to revisions.
200
    ("write-tracked", "Write with tracked changes"),
201
    # Can write tracked content and can read/write comments.
202
    # Cannot turn off tracked changes.
203
    # Can chat with collaborators.
204
    # Has read access to revisions.
205
    ("comment", "Commentator"),
206
    # Can read content and can read+write comments.
207
    # Can chat with collaborators.
208
    # Has read access to revisions.
209
    ("review-tracked", "Reviewer who can write with tracked changes"),
210
    # Can write tracked content and can read/write his own comments.
211
    # Cannot turn off tracked changes.
212
    # Cannot chat with collaborators.
213
    # Has no access to revisions.
214
    ("review", "Reviewer"),
215
    # Can read the content and can read/write his own comments.
216
    # Comments by users with this access right only show the user's
217
    # numeric ID, not their username.
218
    # Cannot chat with collaborators nor see that they are connected.
219
    # Has no access to revisions.
220
    ("read", "Reader"),
221
    # Can read content, including comments
222
    # Can chat with collaborators.
223
    # Has read access to revisions.
224
    ("read-without-comments", "Reader without comment access"),
225
    # Can read content, but not the comments.
226
    # Cannot chat with collaborators.
227
    # Has no access to revisions.
228
)
229

230
E2EE_ALLOWED_RIGHTS = ["write", "read-without-comments", "read"]
17✔
231

232
# Editor and Reviewer can only comment and not edit document
233
COMMENT_ONLY = ("review", "comment")
17✔
234

235
CAN_UPDATE_DOCUMENT = [
17✔
236
    "write",
237
    "write-tracked",
238
    "review",
239
    "review-tracked",
240
    "comment",
241
]
242

243
# Whether the collaborator is allowed to know about other collaborators
244
# and communicate with them.
245
CAN_COMMUNICATE = ["read", "write", "comment", "write-tracked"]
17✔
246

247

248
class AccessRight(models.Model):
17✔
249
    document = models.ForeignKey(Document, on_delete=models.deletion.CASCADE)
17✔
250
    path = models.TextField(default="", blank=True)
17✔
251
    holder_choices = models.Q(app_label="user", model="user") | models.Q(
17✔
252
        app_label="user", model="userinvite"
253
    )
254
    holder_type = models.ForeignKey(
17✔
255
        ContentType, on_delete=models.CASCADE, limit_choices_to=holder_choices
256
    )
257
    holder_id = models.PositiveIntegerField()
17✔
258
    holder_obj = GenericForeignKey("holder_type", "holder_id")
17✔
259
    rights = models.CharField(
17✔
260
        max_length=21, choices=RIGHTS_CHOICES, blank=False
261
    )
262

263
    class Meta:
17✔
264
        unique_together = (("document", "holder_type", "holder_id"),)
17✔
265

266
    def __str__(self):
17✔
267
        return "%(name)s %(rights)s on %(doc_id)d" % {
×
268
            "name": self.holder_obj.readable_name,
269
            "rights": self.rights,
270
            "doc_id": self.document.id,
271
        }
272

273

274
class ShareToken(models.Model):
17✔
275
    token = models.UUIDField(unique=True, default=uuid.uuid4)
17✔
276
    document = models.ForeignKey(Document, on_delete=models.CASCADE)
17✔
277
    rights = models.CharField(choices=RIGHTS_CHOICES, max_length=21)
17✔
278
    created_by = models.ForeignKey(
17✔
279
        settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL
280
    )
281
    expires_at = models.DateTimeField(null=True, blank=True)
17✔
282
    note = models.CharField(max_length=255, blank=True, default="")
17✔
283
    created_at = models.DateTimeField(auto_now_add=True)
17✔
284
    is_active = models.BooleanField(default=True)
17✔
285

286
    class Meta:
17✔
287
        unique_together = ("document", "rights", "created_by")
17✔
288

289

290
def revision_filename(instance, filename):
17✔
291
    return f"document-revisions/{instance.pk}.fidus"
1✔
292

293

294
class DocumentEncryptionKey(models.Model):
17✔
295
    document = models.ForeignKey(
17✔
296
        Document,
297
        on_delete=models.CASCADE,
298
        related_name="encryption_keys",
299
    )
300
    holder = models.ForeignKey(
17✔
301
        settings.AUTH_USER_MODEL,
302
        on_delete=models.CASCADE,
303
        related_name="document_encryption_keys",
304
    )
305
    # The document encryption key, encrypted. Which key encrypts it
306
    # depends on `encrypted_with_master_key`:
307
    # - True:  encrypted with the owner's master key (MK)
308
    # - False: encrypted with the recipient's public key (PK) via ECDH
309
    encrypted_key = models.TextField()  # Base64
17✔
310
    encrypted_with_master_key = models.BooleanField(default=True)
17✔
311
    created_at = models.DateTimeField(auto_now_add=True)
17✔
312
    updated_at = models.DateTimeField(auto_now=True)
17✔
313

314
    class Meta:
17✔
315
        unique_together = (("document", "holder"),)
17✔
316

317
    def __str__(self):
17✔
318
        return f"DEK for doc {self.document_id} / {self.holder.readable_name}"
×
319

320

321
class DocumentRevision(models.Model):
17✔
322
    document = models.ForeignKey(Document, on_delete=models.deletion.CASCADE)
17✔
323
    doc_version = models.DecimalField(
17✔
324
        max_digits=3, decimal_places=1, default=FW_DOCUMENT_VERSION
325
    )
326
    note = models.CharField(max_length=255, default="", blank=True)
17✔
327
    date = models.DateTimeField(auto_now=True)
17✔
328
    file_object = models.FileField(upload_to=revision_filename)
17✔
329
    file_name = models.CharField(max_length=255, default="", blank=True)
17✔
330

331
    def save(self, *args, **kwargs):
17✔
332
        if self.pk is None:
1✔
333
            # We remove the file_object the first time so that we can use the
334
            # pk as the name of the saved revision file.
335
            file_object = self.file_object
1✔
336
            self.file_object = None
1✔
337
            super().save(*args, **kwargs)
1✔
338
            self.file_object = file_object
1✔
339
            kwargs.pop("force_insert", None)
1✔
340
        super().save(*args, **kwargs)
1✔
341

342
    def __str__(self):
17✔
343
        if len(self.note) > 0:
×
344
            return "{note} ({id}) of {doc_id}".format(
×
345
                note=self.note,
346
                id=self.id,
347
                doc_id=self.document.id,
348
            )
349
        else:
350
            return "{id} of {doc_id}".format(
×
351
                id=self.id,
352
                doc_id=self.document.id,
353
            )
354

355
    @classmethod
17✔
356
    def check(cls, **kwargs):
17✔
357
        errors = super().check(**kwargs)
17✔
358
        errors.extend(cls._check_doc_versions(**kwargs))
17✔
359
        return errors
17✔
360

361
    @classmethod
17✔
362
    def _check_doc_versions(cls, **kwargs):
17✔
363
        try:
17✔
364
            if len(
17✔
365
                cls.objects.filter(doc_version__lt=str(FW_DOCUMENT_VERSION))
366
            ):
367
                return [
×
368
                    checks.Warning(
369
                        "Document revisions need to be upgraded. Please "
370
                        "navigate to /admin/document/document/maintenance/ "
371
                        "with a browser as a superuser and upgrade all "
372
                        "document revisions on this server.",
373
                        obj=cls,
374
                    )
375
                ]
376
            else:
377
                return []
17✔
378
        except (ProgrammingError, OperationalError):
17✔
379
            # Database has not yet been initialized, so don't throw any error.
380
            return []
17✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc