Преглед изворни кода

fix(backup): resolve a restored archive's owner by username, not by id (#2656)

created_by_id is only meaningful on the instance that wrote it. Restoring
onto a rebuilt instance - this feature's main use case - renumbers the
users table, so a live id can land on a different person and hand one
user's print history to another under archives:read_own. The id path
cannot even detect that: archivesOwnerCleared fires only for an id that
is absent, so a valid-but-wrong id produced no note at all.

The collector now records created_by_username alongside the id, and the
restore prefers it. username is unique on users, so a match is the same
person; the one case it cannot resolve - a user renamed since the backup
- falls through to ownerless with a note rather than guessing from the
id. The id stays as the fallback for backups taken before this change.
jmoore-skild пре 1 месец
родитељ
комит
4ec0f3f9f2

+ 14 - 0
backend/app/services/github_backup.py

@@ -828,6 +828,14 @@ class GitHubBackupService:
         if not archives:
             return
 
+        # The natural key for an owner. created_by_id alone is only meaningful on
+        # the instance that wrote it: restoring onto a rebuilt instance — this
+        # feature's main use case — renumbers the users table, so a live id can
+        # land on a different person. username is unique on users, so the restore
+        # can resolve on it and treat a rename as unknown rather than guess.
+        # One query for the map; archives outnumber users by orders of magnitude.
+        user_names = dict((await db.execute(select(User.id, User.username))).all())
+
         archive_list = []
         for a in archives:
             archive_data = {
@@ -876,6 +884,12 @@ class GitHubBackupService:
                 # restored row without it is invisible to everyone but an admin —
                 # while the restore reports it restored.
                 "created_by_id": a.created_by_id,
+                # Preferred over the id on restore; the id stays as the fallback
+                # for an owner whose row has since gone. Null when the archive
+                # has no owner, or when it points at a user row that no longer
+                # exists locally — the same "absent is not null" rule the restore
+                # applies, so a backup can't claim an owner it cannot name.
+                "created_by_username": user_names.get(a.created_by_id),
             }
             archive_list.append(archive_data)
 

+ 37 - 14
backend/app/services/github_restore.py

@@ -942,14 +942,17 @@ class GitHubRestoreService:
         # (_ensure_archive_visible fails closed on it) and never appears in the
         # ownership-scoped list queries. Hoisted like the two above.
         #
-        # Note this is the one place a raw backup id is reused, against the
-        # module's own rule at the top of the file. Users have no natural key the
-        # backup carries today, and the id is validated rather than trusted, so a
-        # *stale* id clears instead of pointing somewhere wrong. What it cannot
-        # catch is a live id belonging to a different person on a different
-        # instance. Collecting username and resolving on that would close it;
-        # raised with the maintainer rather than decided here.
-        valid_users = set((await db.execute(select(User.id))).scalars().all())
+        # username is the natural key and wins, per the module's rule at the top
+        # of the file; created_by_id is the fallback for a pre-#2656 commit that
+        # carries no username. That ordering is what makes restoring onto a
+        # rebuilt instance safe: the users table renumbers there, so a live id
+        # can land on a different person, and the id path alone cannot tell that
+        # from a correct match. Resolving on the name instead means the one case
+        # it cannot resolve — a user renamed since the backup — falls through to
+        # ownerless-with-a-note below rather than misattributing in silence.
+        users = (await db.execute(select(User.id, User.username))).all()
+        valid_users = {user_id for user_id, _ in users}
+        users_by_name = {username: user_id for user_id, username in users}
 
         # Only metadata is backed up, never the 3MF/thumbnail bytes, and
         # print_archives.file_path is NOT NULL — so inserted rows get an empty
@@ -1008,7 +1011,7 @@ class GitHubRestoreService:
             fields["printer_id"] = printer_id
             fields["project_id"] = project_id
 
-            # created_by_id and deleted_at are the two late arrivals — a backup
+            # The ownership pair and deleted_at are the late arrivals — a backup
             # commit taken before the collector wrote them carries neither key.
             # Absent is NOT the same as null here, because the overwrite branch
             # below is a blanket setattr: treating a missing key as None would
@@ -1018,13 +1021,33 @@ class GitHubRestoreService:
             # deleted. So only carry a column the backup actually knows about;
             # on insert, an absent key just takes the model default.
             owner_cleared = False
-            if "created_by_id" in entry:
+            backup_username = entry.get("created_by_username")
+            if isinstance(backup_username, str) and backup_username:
+                # The natural-key path. A miss here is a user renamed or deleted
+                # since the backup, and there is nothing else to resolve on: the
+                # id alongside it is from the source instance's numbering, so
+                # trusting it is exactly the misattribution the name is here to
+                # prevent. Cleared rather than failing the row — the archive is
+                # still worth having, and an admin can reassign it — but said out
+                # loud, because a cleared owner is not silent-safe.
+                created_by_id = users_by_name.get(backup_username)
+                if created_by_id is None:
+                    tally.note(
+                        "archivesOwnerUnmatched",
+                        "Some archives name an owner this instance does not have — owner cleared rather than "
+                        "guessed from the backup's user id, so they are visible only to users with the "
+                        "archives:read_all permission until an admin reassigns them",
+                    )
+                    owner_cleared = True
+                fields["created_by_id"] = created_by_id
+            elif "created_by_id" in entry:
+                # Fallback for a commit taken before the collector recorded the
+                # username. Validated rather than trusted, so a *stale* id clears
+                # instead of pointing somewhere wrong; a live id belonging to a
+                # different person on a rebuilt instance is the case this path
+                # cannot see, and is why the branch above exists.
                 created_by_id = entry.get("created_by_id")
                 if created_by_id is not None and created_by_id not in valid_users:
-                    # Coerced rather than failing the row: the archive is still
-                    # worth having, and an admin can reassign it. Said out loud
-                    # because a cleared owner is not silent-safe — the archive
-                    # becomes visible only to archives:read_all until someone does.
                     tally.note(
                         "archivesOwnerCleared",
                         "Some archives referenced users that no longer exist — owner cleared, so they are "

+ 233 - 0
backend/tests/unit/test_github_restore.py

@@ -2390,6 +2390,239 @@ class TestRestoredArchiveOwnership:
         assert row.created_by_id == user.id, "a restored archive its owner cannot see is not restored"
 
 
+class TestArchiveOwnerNaturalKey:
+    """``created_by_username`` decides the owner; the id is only the fallback.
+
+    Restoring onto a rebuilt instance is this feature's main use case, and the
+    users table renumbers there. A raw ``created_by_id`` cannot tell a correct
+    match from a live id that now belongs to somebody else, so the id path hands
+    one person's print history to another under ``ARCHIVES_READ_OWN`` — silently,
+    because ``archivesOwnerCleared`` only fires for an id that is *absent*.
+    ``username`` is unique on ``users``, so resolving on it turns that silent
+    misattribution into an ownerless row with a note.
+    """
+
+    def _entry(self, **overrides):
+        entry = {
+            "id": 77,
+            "filename": "benchy.3mf",
+            "file_size": 2048,
+            "content_hash": "abc123",
+            "started_at": "2026-03-01 10:00:00",
+            "created_at": "2026-03-01 10:00:00",
+        }
+        entry.update(overrides)
+        return entry
+
+    async def _user(self, db, username):
+        user = User(username=username, role="operator")
+        db.add(user)
+        await db.flush()
+        return user
+
+    @pytest.mark.asyncio
+    async def test_the_name_resolves_across_a_renumbered_users_table(self, db_session):
+        """The whole point: same person, different id, restore still finds them."""
+        alice = await self._user(db_session, "alice")
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session,
+            {"archives": [self._entry(created_by_id=alice.id + 500, created_by_username="alice")]},
+            False,
+            tally,
+            {},
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == alice.id
+        assert not any("owner cleared" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_the_name_beats_a_live_id_belonging_to_someone_else(self, db_session):
+        """The misattribution case, and the one the id path cannot even detect.
+
+        Both ids exist locally, so the id path would write bob's — a valid row,
+        no note, alice's print history readable by bob.
+        """
+        alice = await self._user(db_session, "alice")
+        bob = await self._user(db_session, "bob")
+
+        await _service()._restore_archives(
+            db_session,
+            {"archives": [self._entry(created_by_id=bob.id, created_by_username="alice")]},
+            False,
+            _CategoryTally(),
+            {},
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == alice.id, "the name is the natural key; the id is from another instance"
+
+    @pytest.mark.asyncio
+    async def test_a_renamed_owner_lands_ownerless_with_a_note(self, db_session):
+        """No local match, so nothing to resolve — and the id is not a fallback here.
+
+        Falling back to it is exactly the guess the name exists to prevent, so
+        the row is cleared and said out loud instead.
+        """
+        bob = await self._user(db_session, "bob")
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session,
+            {"archives": [self._entry(created_by_id=bob.id, created_by_username="alice")]},
+            False,
+            tally,
+            {},
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id is None
+        assert tally.restored == 1 and tally.failed == 0
+        assert any("does not have" in note and "archives:read_all" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_the_unmatched_note_is_emitted_once_for_many_rows(self, db_session):
+        tally = _CategoryTally()
+        archives = [
+            self._entry(id=1, content_hash="h1", filename="a.3mf", created_by_username="alice"),
+            self._entry(id=2, content_hash="h2", filename="b.3mf", created_by_username="carol"),
+        ]
+
+        await _service()._restore_archives(db_session, {"archives": archives}, False, tally, {})
+        await db_session.commit()
+
+        assert sum(1 for note in _messages(tally) if "does not have" in note) == 1
+
+    @pytest.mark.asyncio
+    async def test_an_unmatched_name_does_not_also_claim_no_owner_was_recorded(self, db_session):
+        """One row, one cause, one note — as with the stale-id branch."""
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_username="alice")]}, False, tally, {}
+        )
+        await db_session.commit()
+
+        assert any("does not have" in note for note in _messages(tally))
+        assert not any("without an owner" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_a_pre_username_backup_still_resolves_on_the_id(self, db_session):
+        """The fallback has to keep working — every backup taken before this change."""
+        alice = await self._user(db_session, "alice")
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_id=alice.id)]}, False, _CategoryTally(), {}
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == alice.id
+
+    @pytest.mark.asyncio
+    async def test_an_explicitly_ownerless_archive_reads_as_no_owner_not_as_unmatched(self, db_session):
+        """A current-format backup of an unowned archive writes both keys null."""
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session,
+            {"archives": [self._entry(created_by_id=None, created_by_username=None)]},
+            False,
+            tally,
+            {},
+        )
+        await db_session.commit()
+
+        assert any("without an owner" in note for note in _messages(tally))
+        assert not any("does not have" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_overwrite_leaves_the_owner_alone_when_neither_key_is_present(self, db_session):
+        """The absent-is-not-null rule still holds now that there are two keys."""
+        bob = await self._user(db_session, "bob")
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=bob.id,
+            )
+        )
+        await db_session.commit()
+
+        await _service()._restore_archives(db_session, {"archives": [self._entry()]}, True, _CategoryTally(), {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == bob.id
+
+    @pytest.mark.asyncio
+    async def test_the_name_survives_collect_then_restore(self, db_session):
+        """Both halves, because the collector writing nothing looks harmless alone."""
+        from backend.app.services.github_backup import github_backup_service
+
+        alice = await self._user(db_session, "alice")
+        db_session.add(
+            PrintArchive(
+                filename="owned.3mf",
+                file_path="",
+                file_size=1024,
+                content_hash="hash-owned",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=alice.id,
+            )
+        )
+        await db_session.commit()
+
+        files: dict = {}
+        await github_backup_service._collect_archives(db_session, files)
+        payload = files[ARCHIVES_PATH]
+        assert payload["archives"][0]["created_by_username"] == "alice"
+
+        # Rebuilt instance: same person, and nothing else holds their old id.
+        await db_session.execute(PrintArchive.__table__.delete())
+        await db_session.execute(User.__table__.delete())
+        await db_session.commit()
+        rebuilt = await self._user(db_session, "alice")
+        await db_session.commit()
+
+        await _service()._restore_archives(db_session, payload, False, _CategoryTally(), {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == rebuilt.id
+
+    @pytest.mark.asyncio
+    async def test_the_collector_names_no_owner_for_an_unowned_archive(self, db_session):
+        """Null rather than absent, so a restore can tell "none" from "not recorded"."""
+        from backend.app.services.github_backup import github_backup_service
+
+        db_session.add(
+            PrintArchive(
+                filename="unowned.3mf",
+                file_path="",
+                file_size=1024,
+                content_hash="hash-unowned",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+            )
+        )
+        await db_session.commit()
+
+        files: dict = {}
+        await github_backup_service._collect_archives(db_session, files)
+
+        entry = files[ARCHIVES_PATH]["archives"][0]
+        assert entry["created_by_username"] is None
+        assert "created_by_username" in entry
+
+
 class TestCategoryPathMapping:
     def setup_method(self):
         self.service = _service()

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -4912,6 +4912,7 @@ export default {
         archivesPrinterMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Drucker - Verknüpfung entfernt',
         archivesProjectMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Projekte - Verknüpfung entfernt',
         archivesOwnerCleared: 'Einige Archive verwiesen auf nicht mehr vorhandene Benutzer - Eigentümer entfernt. Sie sind daher nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
+        archivesOwnerUnmatched: 'Einige Archive nennen einen Eigentümer, den es auf dieser Instanz nicht gibt - der Eigentümer wurde entfernt statt aus der Benutzer-ID der Sicherung geraten. Sie sind daher nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
         archivesOwnerUnknown: 'Einige Archive wurden ohne Eigentümer wiederhergestellt - diese Sicherung enthält keinen, daher sind sie nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
         archivesUndeleted: 'Seit dem Backup gelöschte Archive sind wieder sichtbar - Überschreiben war aktiv',
         archivesMetadataOnly: 'Wiederhergestellte Archive enthalten nur Metadaten - die 3MF- und Vorschaudateien sind nicht im Git-Backup enthalten',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -4960,6 +4960,7 @@ export default {
         archivesPrinterMissing: 'Some archives referenced printers that no longer exist - link cleared',
         archivesProjectMissing: 'Some archives referenced projects that no longer exist - link cleared',
         archivesOwnerCleared: 'Some archives referenced users that no longer exist - owner cleared, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
+        archivesOwnerUnmatched: 'Some archives name an owner this instance does not have - owner cleared rather than guessed from the backup\'s user id, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
         archivesOwnerUnknown: 'Some archives were restored without an owner - this backup does not record one, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
         archivesUndeleted: 'Archive(s) deleted since the backup are visible again - overwrite was on',
         archivesMetadataOnly: 'Restored archives carry metadata only - the 3MF and thumbnail files are not in a Git backup',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -4920,6 +4920,7 @@ export default {
         archivesPrinterMissing: 'Algunos archivos hacían referencia a impresoras que ya no existen - enlace eliminado',
         archivesProjectMissing: 'Algunos archivos hacían referencia a proyectos que ya no existen - enlace eliminado',
         archivesOwnerCleared: 'Algunos archivos hacían referencia a usuarios que ya no existen - se ha borrado el propietario, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
+        archivesOwnerUnmatched: 'Algunos archivos indican un propietario que no existe en esta instancia - se ha borrado el propietario en lugar de deducirlo del id de usuario de la copia de seguridad, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
         archivesOwnerUnknown: 'Algunos archivos se restauraron sin propietario - esta copia de seguridad no registra ninguno, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
         archivesUndeleted: 'Los archivos eliminados desde la copia vuelven a estar visibles - la sobrescritura estaba activada',
         archivesMetadataOnly: 'Los archivos restaurados solo contienen metadatos - los ficheros 3MF y las miniaturas no están en una copia de Git',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -4901,6 +4901,7 @@ export default {
         archivesPrinterMissing: 'Certaines archives référençaient des imprimantes qui n\'existent plus - lien effacé',
         archivesProjectMissing: 'Certaines archives référençaient des projets qui n\'existent plus - lien effacé',
         archivesOwnerCleared: 'Certaines archives référençaient des utilisateurs qui n\'existent plus - propriétaire effacé, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
+        archivesOwnerUnmatched: 'Certaines archives désignent un propriétaire absent de cette instance - le propriétaire a été effacé plutôt que déduit de l\'identifiant utilisateur de la sauvegarde, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
         archivesOwnerUnknown: 'Certaines archives ont été restaurées sans propriétaire - cette sauvegarde n\'en enregistre aucun, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
         archivesUndeleted: 'Les archives supprimées depuis la sauvegarde sont de nouveau visibles - l\'écrasement était activé',
         archivesMetadataOnly: 'Les archives restaurées ne contiennent que des métadonnées - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -4900,6 +4900,7 @@ export default {
         archivesPrinterMissing: 'Alcuni archivi facevano riferimento a stampanti non più esistenti - collegamento rimosso',
         archivesProjectMissing: 'Alcuni archivi facevano riferimento a progetti non più esistenti - collegamento rimosso',
         archivesOwnerCleared: 'Alcuni archivi facevano riferimento a utenti non più esistenti - proprietario rimosso, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
+        archivesOwnerUnmatched: 'Alcuni archivi indicano un proprietario che questa istanza non ha - il proprietario è stato rimosso anziché dedotto dall\'id utente del backup, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
         archivesOwnerUnknown: 'Alcuni archivi sono stati ripristinati senza proprietario - questo backup non ne registra alcuno, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
         archivesUndeleted: 'Gli archivi eliminati dopo il backup sono di nuovo visibili - la sovrascrittura era attiva',
         archivesMetadataOnly: 'Gli archivi ripristinati contengono solo metadati - i file 3MF e le miniature non sono in un backup Git',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -4912,6 +4912,7 @@ export default {
         archivesPrinterMissing: '一部のアーカイブが存在しないプリンターを参照していました - リンクを解除しました',
         archivesProjectMissing: '一部のアーカイブが存在しないプロジェクトを参照していました - リンクを解除しました',
         archivesOwnerCleared: '一部のアーカイブが存在しないユーザーを参照していました - 所有者を解除したため、管理者が割り当て直すまで archives:read_all 権限を持つユーザーにしか表示されません',
+        archivesOwnerUnmatched: '一部のアーカイブはこのインスタンスに存在しない所有者を指しています - バックアップのユーザー ID から推測せずに所有者を解除したため、管理者が割り当て直すまで archives:read_all 権限を持つユーザーにしか表示されません',
         archivesOwnerUnknown: '一部のアーカイブは所有者なしで復元されました - このバックアップに所有者が記録されていないため、管理者が割り当てるまで archives:read_all 権限を持つユーザーにしか表示されません',
         archivesUndeleted: 'バックアップ後に削除されたアーカイブが再び表示されます - 上書きが有効でした',
         archivesMetadataOnly: '復元されたアーカイブはメタデータのみです - 3MF ファイルとサムネイルは Git バックアップに含まれません',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -4677,6 +4677,7 @@ export default {
         archivesPrinterMissing: '일부 아카이브가 더 이상 존재하지 않는 프린터를 참조했습니다 - 연결을 해제했습니다',
         archivesProjectMissing: '일부 아카이브가 더 이상 존재하지 않는 프로젝트를 참조했습니다 - 연결을 해제했습니다',
         archivesOwnerCleared: '일부 아카이브가 더 이상 존재하지 않는 사용자를 참조했습니다 - 소유자를 비웠으므로 관리자가 다시 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
+        archivesOwnerUnmatched: '일부 아카이브가 이 인스턴스에 없는 소유자를 가리킵니다 - 백업의 사용자 ID로 추측하지 않고 소유자를 비웠으므로 관리자가 다시 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
         archivesOwnerUnknown: '일부 아카이브가 소유자 없이 복원되었습니다 - 이 백업에 소유자가 기록되어 있지 않으므로 관리자가 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
         archivesUndeleted: '백업 이후 삭제된 아카이브가 다시 표시됩니다 - 덮어쓰기가 켜져 있었습니다',
         archivesMetadataOnly: '복원된 아카이브에는 메타데이터만 있습니다 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4900,6 +4900,7 @@ export default {
         archivesPrinterMissing: 'Alguns arquivos referenciavam impressoras que não existem mais - vínculo removido',
         archivesProjectMissing: 'Alguns arquivos referenciavam projetos que não existem mais - vínculo removido',
         archivesOwnerCleared: 'Alguns arquivos referenciavam usuários que não existem mais - o proprietário foi limpo, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os reatribua',
+        archivesOwnerUnmatched: 'Alguns arquivos apontam para um proprietário que esta instância não tem - o proprietário foi limpo em vez de deduzido do id de usuário da cópia, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os reatribua',
         archivesOwnerUnknown: 'Alguns arquivos foram restaurados sem proprietário - este backup não registra nenhum, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os atribua',
         archivesUndeleted: 'Arquivos excluídos desde o backup voltaram a ficar visíveis - a sobrescrita estava ligada',
         archivesMetadataOnly: 'Os arquivos restaurados contêm apenas metadados - os arquivos 3MF e as miniaturas não ficam em um backup Git',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -4669,6 +4669,7 @@ export default {
         archivesPrinterMissing: 'Некоторые архивы ссылались на несуществующие принтеры - связь очищена',
         archivesProjectMissing: 'Некоторые архивы ссылались на несуществующие проекты - связь очищена',
         archivesOwnerCleared: 'Некоторые архивы ссылались на несуществующих пользователей - владелец очищен, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца заново',
+        archivesOwnerUnmatched: 'Некоторые архивы указывают владельца, которого нет в этом экземпляре - владелец очищен, а не угадан по идентификатору пользователя из резервной копии, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца заново',
         archivesOwnerUnknown: 'Некоторые архивы восстановлены без владельца - в этой резервной копии он не записан, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца',
         archivesUndeleted: 'Архивы, удалённые после резервного копирования, снова видны - перезапись была включена',
         archivesMetadataOnly: 'Восстановленные архивы содержат только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -4890,6 +4890,7 @@ export default {
         archivesPrinterMissing: 'Bazı arşivler artık var olmayan yazıcılara işaret ediyordu - bağlantı temizlendi',
         archivesProjectMissing: 'Bazı arşivler artık var olmayan projelere işaret ediyordu - bağlantı temizlendi',
         archivesOwnerCleared: 'Bazı arşivler artık var olmayan kullanıcılara işaret ediyordu - sahip temizlendi, bu yüzden bir yönetici yeniden atayana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
+        archivesOwnerUnmatched: 'Bazı arşivler bu örnekte bulunmayan bir sahibi belirtiyor - sahip, yedekteki kullanıcı kimliğinden tahmin edilmek yerine temizlendi, bu yüzden bir yönetici yeniden atayana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
         archivesOwnerUnknown: 'Bazı arşivler sahipsiz geri yüklendi - bu yedek sahip bilgisi içermiyor, bu yüzden bir yönetici atama yapana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
         archivesUndeleted: 'Yedekten sonra silinen arşivler yeniden görünür oldu - üzerine yazma açıktı',
         archivesMetadataOnly: 'Geri yüklenen arşivler yalnızca üst veri içerir - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -4955,6 +4955,7 @@ export default {
         archivesPrinterMissing: "Деякі архіви посилалися на принтери, яких більше немає - зв'язок очищено",
         archivesProjectMissing: "Деякі архіви посилалися на проєкти, яких більше немає - зв'язок очищено",
         archivesOwnerCleared: "Деякі архіви посилалися на користувачів, яких більше немає - власника очищено, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника знову",
+        archivesOwnerUnmatched: "Деякі архіви вказують власника, якого немає в цьому екземплярі - власника очищено, а не вгадано за ідентифікатором користувача з резервної копії, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника знову",
         archivesOwnerUnknown: "Деякі архіви відновлено без власника - у цій резервній копії його не записано, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника",
         archivesUndeleted: "Архіви, видалені після резервного копіювання, знову видимі - перезапис був увімкнений",
         archivesMetadataOnly: "Відновлені архіви містять лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4900,6 +4900,7 @@ export default {
         archivesPrinterMissing: '部分归档引用了已不存在的打印机 - 已清除关联',
         archivesProjectMissing: '部分归档引用了已不存在的项目 - 已清除关联',
         archivesOwnerCleared: '部分归档引用了已不存在的用户 - 已清除归属,因此在管理员重新指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
+        archivesOwnerUnmatched: '部分归档指向本实例没有的用户 - 已清除归属,而不是根据备份中的用户 ID 猜测,因此在管理员重新指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
         archivesOwnerUnknown: '部分归档在恢复时没有归属 - 此备份未记录归属,因此在管理员指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
         archivesUndeleted: '备份之后被删除的归档重新可见 - 当时启用了覆盖',
         archivesMetadataOnly: '恢复的归档仅含元数据 - 3MF 文件和缩略图不在 Git 备份中',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4900,6 +4900,7 @@ export default {
         archivesPrinterMissing: '部分封存參照了已不存在的印表機 - 已清除連結',
         archivesProjectMissing: '部分封存參照了已不存在的專案 - 已清除連結',
         archivesOwnerCleared: '部分封存參照了已不存在的使用者 - 已清除擁有者,因此在管理員重新指派之前,只有具備 archives:read_all 權限的使用者才看得到',
+        archivesOwnerUnmatched: '部分封存指向本執行個體沒有的使用者 - 已清除擁有者,而非依備份中的使用者 ID 推測,因此在管理員重新指派之前,只有具備 archives:read_all 權限的使用者才看得到',
         archivesOwnerUnknown: '部分封存還原時沒有擁有者 - 此備份未記錄擁有者,因此在管理員指派之前,只有具備 archives:read_all 權限的使用者才看得到',
         archivesUndeleted: '備份之後刪除的封存重新可見 - 當時啟用了覆寫',
         archivesMetadataOnly: '還原的封存僅含中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',