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.
This commit is contained in:
jmoore-skild
2026-08-05 16:09:20 -04:00
parent f85e3e7fa1
commit 4ec0f3f9f2
16 changed files with 297 additions and 14 deletions
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 備份中',