portal-mojo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- package/src/ui/ui.tsx +45 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { useQuery, type QueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { apiOrigin, defineModel, mojoCall, mojoDelete, mojoGet, mojoList, mojoSave, type Params } from '../../client';
|
|
3
|
+
|
|
4
|
+
export const SHORTLINK_MANAGE_PERMISSIONS = ['sys.manage_shortlinks'];
|
|
5
|
+
export const SHORTLINK_ENDPOINT = '/api/shortlink/link';
|
|
6
|
+
export const SHORTLINK_HISTORY_ENDPOINT = '/api/shortlink/history';
|
|
7
|
+
|
|
8
|
+
export interface ShortlinkRelation { id: number; name?: string; display_name?: string; email?: string }
|
|
9
|
+
export interface ShortlinkRow {
|
|
10
|
+
id: number;
|
|
11
|
+
code: string;
|
|
12
|
+
source: string;
|
|
13
|
+
hit_count: number;
|
|
14
|
+
expires_at: number | string | null;
|
|
15
|
+
is_active: boolean;
|
|
16
|
+
is_protected: boolean;
|
|
17
|
+
track_clicks: boolean;
|
|
18
|
+
bot_passthrough: boolean;
|
|
19
|
+
created: number | string;
|
|
20
|
+
modified: number | string | null;
|
|
21
|
+
user: ShortlinkRelation | number | null;
|
|
22
|
+
group: ShortlinkRelation | number | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ShortlinkHistoryRow {
|
|
26
|
+
id: number;
|
|
27
|
+
shortlink_id: number | null;
|
|
28
|
+
code: string;
|
|
29
|
+
is_bot: boolean;
|
|
30
|
+
created: number | string;
|
|
31
|
+
agent_summary: string;
|
|
32
|
+
referer_origin: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const FORBIDDEN_KEYS = /^(url|destination|ip|ip_address|user_agent|referer|metadata)$/i;
|
|
36
|
+
|
|
37
|
+
/** Defense in depth: fail a verifier if a future projection accidentally retains a raw sensitive key. */
|
|
38
|
+
export function assertSafeShortlinkProjection(value: unknown): void {
|
|
39
|
+
const visit = (candidate: unknown): void => {
|
|
40
|
+
if (Array.isArray(candidate)) { candidate.forEach(visit); return; }
|
|
41
|
+
if (!candidate || typeof candidate !== 'object') return;
|
|
42
|
+
for (const [key, child] of Object.entries(candidate as Record<string, unknown>)) {
|
|
43
|
+
if (FORBIDDEN_KEYS.test(key)) throw new Error(`Unsafe shortlink projection retained ${key}`);
|
|
44
|
+
visit(child);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
visit(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function relation(value: unknown): ShortlinkRelation | number | null {
|
|
51
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
52
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
53
|
+
const raw = value as Record<string, unknown>;
|
|
54
|
+
if (!Number.isFinite(Number(raw.id))) return null;
|
|
55
|
+
return {
|
|
56
|
+
id: Number(raw.id),
|
|
57
|
+
...(typeof raw.name === 'string' ? { name: raw.name.slice(0, 120) } : {}),
|
|
58
|
+
...(typeof raw.display_name === 'string' ? { display_name: raw.display_name.slice(0, 120) } : {}),
|
|
59
|
+
...(typeof raw.email === 'string' ? { email: raw.email.slice(0, 254) } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function temporal(value: unknown): number | string | null {
|
|
64
|
+
return typeof value === 'number' && Number.isFinite(value) ? value
|
|
65
|
+
: typeof value === 'string' && value.length <= 40 ? value : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Positive projection. The backend's graph=list includes url; it never crosses this boundary. */
|
|
69
|
+
export function sanitizeShortlinkRow(input: unknown): ShortlinkRow {
|
|
70
|
+
const raw = input && typeof input === 'object' && !Array.isArray(input) ? input as Record<string, unknown> : {};
|
|
71
|
+
const row: ShortlinkRow = {
|
|
72
|
+
id: Number(raw.id), code: String(raw.code ?? '').slice(0, 10), source: String(raw.source ?? '').slice(0, 50),
|
|
73
|
+
hit_count: Math.max(0, Number(raw.hit_count ?? 0) || 0), expires_at: temporal(raw.expires_at),
|
|
74
|
+
is_active: Boolean(raw.is_active), is_protected: Boolean(raw.is_protected),
|
|
75
|
+
track_clicks: Boolean(raw.track_clicks), bot_passthrough: Boolean(raw.bot_passthrough),
|
|
76
|
+
created: temporal(raw.created) ?? 0, modified: temporal(raw.modified), user: relation(raw.user), group: relation(raw.group),
|
|
77
|
+
};
|
|
78
|
+
assertSafeShortlinkProjection(row);
|
|
79
|
+
return row;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function boundedAgentSummary(userAgent: unknown, isBot: boolean): string {
|
|
83
|
+
const ua = typeof userAgent === 'string' ? userAgent : '';
|
|
84
|
+
const bot = ua.match(/(Googlebot|Bingbot|Slackbot|Discordbot|Twitterbot|facebookexternalhit|bot|crawler|spider)/i)?.[1];
|
|
85
|
+
if (isBot) return `${bot ? bot.replace(/externalhit/i, '') : 'Bot'} · automated`.slice(0, 60);
|
|
86
|
+
const browser = ua.match(/Edg\/[\d.]+/i) ? 'Edge'
|
|
87
|
+
: ua.match(/Firefox\/[\d.]+/i) ? 'Firefox'
|
|
88
|
+
: ua.match(/(?:Chrome|CriOS)\/[\d.]+/i) ? 'Chrome'
|
|
89
|
+
: ua.match(/Version\/[\d.]+.*Safari/i) ? 'Safari' : 'Browser';
|
|
90
|
+
const platform = /Android/i.test(ua) ? 'Android' : /iPhone|iPad/i.test(ua) ? 'iOS'
|
|
91
|
+
: /Windows/i.test(ua) ? 'Windows' : /Macintosh|Mac OS/i.test(ua) ? 'macOS'
|
|
92
|
+
: /Linux/i.test(ua) ? 'Linux' : 'unknown platform';
|
|
93
|
+
return `${browser} · ${platform}`.slice(0, 60);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function refererOrigin(value: unknown): string | null {
|
|
97
|
+
if (typeof value !== 'string' || value.length > 4096) return null;
|
|
98
|
+
try {
|
|
99
|
+
const parsed = new URL(value);
|
|
100
|
+
return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password ? parsed.origin.slice(0, 255) : null;
|
|
101
|
+
} catch { return null; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Positive projection for click evidence: no IP, raw UA, path/query/fragment, metadata, or nested destination. */
|
|
105
|
+
export function sanitizeShortlinkHistoryRow(input: unknown): ShortlinkHistoryRow {
|
|
106
|
+
const raw = input && typeof input === 'object' && !Array.isArray(input) ? input as Record<string, unknown> : {};
|
|
107
|
+
const nested = raw.shortlink && typeof raw.shortlink === 'object' && !Array.isArray(raw.shortlink)
|
|
108
|
+
? raw.shortlink as Record<string, unknown> : {};
|
|
109
|
+
const isBot = Boolean(raw.is_bot);
|
|
110
|
+
const row: ShortlinkHistoryRow = {
|
|
111
|
+
id: Number(raw.id), shortlink_id: Number.isFinite(Number(nested.id)) ? Number(nested.id) : null,
|
|
112
|
+
code: String(nested.code ?? '').slice(0, 10), is_bot: isBot, created: temporal(raw.created) ?? 0,
|
|
113
|
+
agent_summary: boundedAgentSummary(raw.user_agent, isBot), referer_origin: refererOrigin(raw.referer),
|
|
114
|
+
};
|
|
115
|
+
assertSafeShortlinkProjection(row);
|
|
116
|
+
return row;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const LINK_FILTERS = new Set(['id', 'id__in', 'code', 'source', 'source__in', 'is_active', 'created__gte', 'created__lte']);
|
|
120
|
+
const LINK_SORTS = new Set(['id', 'code', 'source', 'hit_count', 'expires_at', 'is_active', 'created']);
|
|
121
|
+
const HISTORY_FILTERS = new Set(['id', 'shortlink', 'is_bot', 'created__gte', 'created__lte']);
|
|
122
|
+
const HISTORY_SORTS = new Set(['id', 'is_bot', 'created']);
|
|
123
|
+
function allow(params: Params, graph: 'list' | 'default', filters: Set<string>, sorts: Set<string>): Params {
|
|
124
|
+
const out: Params = { graph, start: params.start ?? 0, size: params.size ?? 25 };
|
|
125
|
+
for (const [key, value] of Object.entries(params)) {
|
|
126
|
+
if ((filters.has(key) || ['start', 'size', 'sort', 'dr_start', 'dr_end'].includes(key)) && value != null && value !== '') out[key] = value;
|
|
127
|
+
}
|
|
128
|
+
if (out.dr_start != null || out.dr_end != null) out.dr_field = 'created';
|
|
129
|
+
const sort = typeof out.sort === 'string' ? out.sort : '-created';
|
|
130
|
+
out.sort = sorts.has(sort.replace(/^-/, '')) ? sort : '-created';
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const ShortlinkModel = defineModel<ShortlinkRow>({
|
|
135
|
+
name: 'shortlink', endpoint: SHORTLINK_ENDPOINT,
|
|
136
|
+
permissions: { view: SHORTLINK_MANAGE_PERMISSIONS, manage: SHORTLINK_MANAGE_PERMISSIONS, create: SHORTLINK_MANAGE_PERMISSIONS, delete: SHORTLINK_MANAGE_PERMISSIONS },
|
|
137
|
+
normalizeListParams: (params) => allow(params, 'list', LINK_FILTERS, LINK_SORTS), sanitizeRow: sanitizeShortlinkRow,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export const ShortlinkHistoryModel = defineModel<ShortlinkHistoryRow>({
|
|
141
|
+
name: 'shortlink-history', endpoint: SHORTLINK_HISTORY_ENDPOINT,
|
|
142
|
+
permissions: { view: SHORTLINK_MANAGE_PERMISSIONS },
|
|
143
|
+
normalizeListParams: (params) => allow(params, 'default', HISTORY_FILTERS, HISTORY_SORTS), sanitizeRow: sanitizeShortlinkHistoryRow,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
export function functionalShortlinkUrl(code: string): string {
|
|
147
|
+
const origin = apiOrigin().replace(/\/$/, '');
|
|
148
|
+
return `${origin}/s/${encodeURIComponent(code)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export class ShortlinkReconciliationError extends Error {
|
|
152
|
+
readonly retryBlocked = true;
|
|
153
|
+
constructor(cause: unknown) {
|
|
154
|
+
super('The change may have persisted, but authoritative reconciliation failed. Refresh successfully before retrying.', { cause });
|
|
155
|
+
this.name = 'ShortlinkReconciliationError';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function authoritativeList(params: Params): Promise<ShortlinkRow[]> {
|
|
160
|
+
const result = await mojoList<unknown>(SHORTLINK_ENDPOINT, ShortlinkModel.normalizeListParams?.(params) ?? params);
|
|
161
|
+
return result.rows.map(sanitizeShortlinkRow);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function reconcile(queryClient: QueryClient, id?: number, code?: string): Promise<ShortlinkRow | null> {
|
|
165
|
+
let row: ShortlinkRow | null = null;
|
|
166
|
+
if (id != null) {
|
|
167
|
+
try { row = sanitizeShortlinkRow(await mojoGet<unknown>(SHORTLINK_ENDPOINT, id)); }
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (!(error && typeof error === 'object' && 'status' in error && Number((error as { status: unknown }).status) === 404)) throw error;
|
|
170
|
+
}
|
|
171
|
+
} else if (code) row = (await authoritativeList({ code, size: 2 }))[0] ?? null;
|
|
172
|
+
await queryClient.invalidateQueries({ queryKey: ShortlinkModel.keys.root, refetchType: 'none' });
|
|
173
|
+
if (row) queryClient.setQueryData(ShortlinkModel.keys.one(row.id), row);
|
|
174
|
+
else if (id != null) queryClient.removeQueries({ queryKey: ShortlinkModel.keys.one(id) });
|
|
175
|
+
await queryClient.refetchQueries({ queryKey: ShortlinkModel.keys.root, type: 'active' });
|
|
176
|
+
return row;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function runReconciled<T>(mutation: () => Promise<T>, reconcileNow: (result: T | null) => Promise<ShortlinkRow | null>): Promise<ShortlinkRow | null> {
|
|
180
|
+
let result: T | null = null;
|
|
181
|
+
let mutationError: unknown;
|
|
182
|
+
let authoritative: ShortlinkRow | null = null;
|
|
183
|
+
let reconciliationError: unknown;
|
|
184
|
+
try { result = await mutation(); }
|
|
185
|
+
catch (error) { mutationError = error; }
|
|
186
|
+
finally {
|
|
187
|
+
try { authoritative = await reconcileNow(result); }
|
|
188
|
+
catch (error) { reconciliationError = error; }
|
|
189
|
+
}
|
|
190
|
+
if (reconciliationError) throw new ShortlinkReconciliationError(reconciliationError);
|
|
191
|
+
if (mutationError) throw mutationError;
|
|
192
|
+
return authoritative;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function createShortlink(queryClient: QueryClient, changes: { url: string; source?: string; expire_days?: number; expire_hours?: number; track_clicks?: boolean; bot_passthrough?: boolean; is_protected?: boolean }): Promise<ShortlinkRow> {
|
|
196
|
+
const result = await runReconciled(
|
|
197
|
+
() => mojoCall(`${SHORTLINK_ENDPOINT}/create`, { method: 'POST', body: changes }),
|
|
198
|
+
async (body) => {
|
|
199
|
+
const raw = body?.data && typeof body.data === 'object' ? body.data as Record<string, unknown> : {};
|
|
200
|
+
const short = typeof raw.short_link === 'string' ? raw.short_link : '';
|
|
201
|
+
const code = decodeURIComponent(short.split('/').filter(Boolean).at(-1) ?? '');
|
|
202
|
+
if (!code) {
|
|
203
|
+
await authoritativeList({ size: 1 });
|
|
204
|
+
await queryClient.invalidateQueries({ queryKey: ShortlinkModel.keys.root });
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const listed = (await authoritativeList({ code, size: 2 }))[0] ?? null;
|
|
208
|
+
return listed ? reconcile(queryClient, listed.id) : null;
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
if (!result) throw new ShortlinkReconciliationError(new Error('Created row was not visible after create'));
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function setShortlinkActive(queryClient: QueryClient, row: ShortlinkRow, isActive: boolean): Promise<ShortlinkRow | null> {
|
|
216
|
+
return runReconciled(() => mojoSave(SHORTLINK_ENDPOINT, row.id, { is_active: isActive }), () => reconcile(queryClient, row.id));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function deleteShortlink(queryClient: QueryClient, row: ShortlinkRow): Promise<ShortlinkRow | null> {
|
|
220
|
+
return runReconciled(() => mojoDelete(SHORTLINK_ENDPOINT, row.id), () => reconcile(queryClient, row.id));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function refreshShortlinks(queryClient: QueryClient): Promise<void> {
|
|
224
|
+
await authoritativeList({ size: 1 });
|
|
225
|
+
await queryClient.invalidateQueries({ queryKey: ShortlinkModel.keys.root });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface TrackedCounts { human: number; bot: number; tracked: number; remainder: number }
|
|
229
|
+
export async function fetchTrackedCounts(shortlink: ShortlinkRow): Promise<TrackedCounts> {
|
|
230
|
+
const fetchCount = async (isBot: boolean) => {
|
|
231
|
+
const result = await mojoList<unknown>(SHORTLINK_HISTORY_ENDPOINT,
|
|
232
|
+
ShortlinkHistoryModel.normalizeListParams?.({ shortlink: shortlink.id, is_bot: isBot, size: 1 }) ?? {});
|
|
233
|
+
result.rows.forEach(sanitizeShortlinkHistoryRow);
|
|
234
|
+
return result.count;
|
|
235
|
+
};
|
|
236
|
+
const [human, bot] = await Promise.all([fetchCount(false), fetchCount(true)]);
|
|
237
|
+
const tracked = human + bot;
|
|
238
|
+
return { human, bot, tracked, remainder: Math.max(0, shortlink.hit_count - tracked) };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function useTrackedCounts(shortlink: ShortlinkRow | null) {
|
|
242
|
+
return useQuery({
|
|
243
|
+
queryKey: ['shortlink-tracked-counts', shortlink?.id, shortlink?.hit_count],
|
|
244
|
+
queryFn: () => fetchTrackedCounts(shortlink!), enabled: shortlink != null,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { Badge, ModelTable, fmt, modal, toast, type BatchAction, type Column, type FilterDef } from '../../ui';
|
|
3
|
+
import { useCan } from '../../client';
|
|
4
|
+
import { FileManagerDetail } from './FileManagerDetail';
|
|
5
|
+
import { openFileManagerEditor } from './storage-dialogs';
|
|
6
|
+
import { FileManagerModel, GROUP_DIRECTORY_PERMS, STORAGE_MANAGE_PERMS, USER_DIRECTORY_PERMS, exportFileManagers, storageRelationId, saveFileManagerAtomic, type FileManagerRow } from './models';
|
|
7
|
+
|
|
8
|
+
function scope(row: FileManagerRow): string { return storageRelationId(row.group) != null ? `Group · ${(typeof row.group === 'object' && row.group?.name) || `#${storageRelationId(row.group)}`}` : storageRelationId(row.user) != null ? `User · ${(typeof row.user === 'object' && row.user && (row.user.display_name || row.user.name)) || `#${storageRelationId(row.user)}`}` : 'System'; }
|
|
9
|
+
const COLUMNS: Column<FileManagerRow>[] = [
|
|
10
|
+
{ key: 'name', label: 'Name', sortable: true, hideable: false },
|
|
11
|
+
{ key: 'backend_type', label: 'Backend', sortable: true, render: (row) => <Badge tone={row.backend_type === 's3' ? 'info' : 'muted'}>{row.backend_type}</Badge> },
|
|
12
|
+
{ key: 'backend_url', label: 'Location', render: (row) => <code>{fmt.truncate(row.backend_url, 36)}</code> },
|
|
13
|
+
{ key: 'scope', label: 'Scope', render: scope },
|
|
14
|
+
{ key: 'is_active', label: 'Active', sortable: true, render: (row) => <Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge> },
|
|
15
|
+
{ key: 'is_default', label: 'Default', sortable: true, render: (row) => row.is_default ? <i className="bi bi-star-fill text-warn" /> : '—' },
|
|
16
|
+
{ key: 'is_public', label: 'Access', sortable: true, render: (row) => <Badge tone={row.is_public ? 'warning' : 'success'}>{row.is_public ? 'Public' : 'Private'}</Badge> },
|
|
17
|
+
{ key: 'aws_region', label: 'Region', render: (row) => row.aws_region || '—' },
|
|
18
|
+
{ key: 'credentials', label: 'Credentials', render: (row) => row.aws_key_masked ? <code>{row.aws_key_masked}</code> : '—' },
|
|
19
|
+
{ key: 'created', label: 'Created', sortable: true, render: (row) => fmt.date(row.created) },
|
|
20
|
+
];
|
|
21
|
+
const FILTERS: FilterDef[] = [
|
|
22
|
+
{ key: 'backend_type', label: 'Backend', type: 'select', options: [{ value: 'file', label: 'File system' }, { value: 's3', label: 'AWS S3' }] },
|
|
23
|
+
{ key: 'is_active', label: 'Active', type: 'boolean' }, { key: 'is_default', label: 'Default', type: 'boolean' }, { key: 'is_public', label: 'Public', type: 'boolean' },
|
|
24
|
+
{ key: 'group__isnull', label: 'Group scope', type: 'boolean', trueLabel: 'No group', falseLabel: 'Has group' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export function showFileManagerDetail(id: number): void { void modal.detail((close) => <FileManagerDetail id={id} onClose={() => close(null)} />); }
|
|
28
|
+
|
|
29
|
+
export function BackendsPage() {
|
|
30
|
+
const queryClient = useQueryClient();
|
|
31
|
+
const manage = useCan(STORAGE_MANAGE_PERMS);
|
|
32
|
+
const canManage = manage.can;
|
|
33
|
+
const canViewGroups = useCan(GROUP_DIRECTORY_PERMS).can;
|
|
34
|
+
const canViewUsers = useCan(USER_DIRECTORY_PERMS).can;
|
|
35
|
+
const canCreate = canManage && (Boolean(manage.me?.is_superuser) || canViewGroups || canViewUsers);
|
|
36
|
+
const saveState = (row: FileManagerRow, active: boolean) => saveFileManagerAtomic({ queryClient, id: row.id, changes: { is_active: active } });
|
|
37
|
+
const batchActions: BatchAction<FileManagerRow>[] = [
|
|
38
|
+
{ key: 'activate', label: 'Activate', eligible: (row) => !row.is_active, run: (row) => saveState(row, true) },
|
|
39
|
+
{ key: 'deactivate', label: 'Deactivate', eligible: (row) => row.is_active, run: (row) => saveState(row, false) },
|
|
40
|
+
];
|
|
41
|
+
const add = async () => { const row = await openFileManagerEditor('create'); if (row) toast.success(`${row.name} created private`); };
|
|
42
|
+
return <ModelTable model={FileManagerModel} eyebrow="Infrastructure · Storage" title="Storage Backends" columns={COLUMNS} filters={FILTERS} defaultSort="-created" searchable searchPlaceholder="Search name, backend, description" selectable={canManage} batchActions={canManage ? batchActions : []} columnChooser persistState persistKey="admin:storage:backends" exportFormats={['csv', 'json']} exporter={exportFileManagers} onRowClick={(row) => showFileManagerDetail(row.id)} {...(canCreate ? { addLabel: 'Create backend', onAdd: () => void add() } : {})} />;
|
|
43
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { ArmedButton, DetailView, FlatRow, SchemaForm, fmt, modal, toast } from '../../ui';
|
|
4
|
+
import {
|
|
5
|
+
BUCKET_MANAGE_PERMS, emptyBucket, setBucketPublic,
|
|
6
|
+
type BucketEmptyResult, type BucketMutationOutcome, type S3BucketRow, type S3FailureEvidence,
|
|
7
|
+
} from './models';
|
|
8
|
+
|
|
9
|
+
function message(error: unknown): string { return error instanceof Error ? error.message : 'Storage operation failed'; }
|
|
10
|
+
|
|
11
|
+
function AggregateEvidence({ label, values }: { label: string; values: Record<string, number | null> | null | undefined }) {
|
|
12
|
+
if (values === undefined) return null;
|
|
13
|
+
if (values === null) return <p><b>{label}:</b> Unknown (no safe final probe)</p>;
|
|
14
|
+
const entries = Object.entries(values);
|
|
15
|
+
return entries.length > 0 ? <><h4>{label}</h4><dl>{entries.map(([key, value]) => <div key={key}><dt>{key.replaceAll('_', ' ')}</dt><dd>{value == null ? 'Unknown' : value.toLocaleString()}</dd></div>)}</dl></> : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function posture(value: boolean | null | undefined): string { return value == null ? 'Unknown' : value ? 'Public' : 'Private'; }
|
|
19
|
+
|
|
20
|
+
export function IncompleteEvidence({ evidence }: { evidence: S3FailureEvidence }) {
|
|
21
|
+
const failure = evidence.failure;
|
|
22
|
+
return <div className="storage-operation-evidence"><b>External operation incomplete: {evidence.mutation_state}</b><p>AWS state may have changed. Stop writers where relevant, correct IAM/provider posture, refresh, and retry.</p><AggregateEvidence label="Acknowledged counts" values={evidence.counts} /><AggregateEvidence label="Failed" values={evidence.failed} /><AggregateEvidence label="Remaining" values={evidence.remaining} />{failure && <dl>{failure.operation !== undefined && <div><dt>Failure operation</dt><dd>{failure.operation}</dd></div>}{failure.provider_code !== undefined && <div><dt>Provider code</dt><dd>{failure.provider_code}</dd></div>}{failure.retryable !== undefined && <div><dt>Retryable</dt><dd>{failure.retryable ? 'Yes' : 'No'}</dd></div>}</dl>}{evidence.requested_public !== undefined && <p>Requested posture: <b>{posture(evidence.requested_public)}</b></p>}{evidence.configured_public !== undefined && <p>Configured posture: <b>{posture(evidence.configured_public)}</b></p>}{evidence.created_new !== undefined && <p>Bucket newly created: <b>{evidence.created_new == null ? 'Unknown' : evidence.created_new ? 'Yes' : 'No'}</b></p>}{evidence.safety_lock && <p>Safety lock: <b>{evidence.safety_lock}</b></p>}</div>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function Outcome({ value }: { value: BucketMutationOutcome<unknown> | null }) {
|
|
26
|
+
if (!value) return null;
|
|
27
|
+
return <div className={`storage-operation-result${value.error ? ' is-error' : ''}`}>{Boolean(value.error) && <p>{message(value.error)}</p>}{value.evidence && <IncompleteEvidence evidence={value.evidence} />}{Boolean(value.refreshError) && <p className="text-warn"><i className="bi bi-exclamation-triangle" /> Authoritative refresh also failed. Refresh manually before another action.</p>}</div>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function BucketDetail({ bucket, onClose }: { bucket: S3BucketRow; onClose: () => void }) {
|
|
31
|
+
const queryClient = useQueryClient();
|
|
32
|
+
const [outcome, setOutcome] = useState<BucketMutationOutcome<unknown> | null>(null);
|
|
33
|
+
const [emptyResult, setEmptyResult] = useState<BucketEmptyResult | null>(null);
|
|
34
|
+
|
|
35
|
+
const access = async (isPublic: boolean) => {
|
|
36
|
+
const confirmed = await modal.confirm({
|
|
37
|
+
title: isPublic ? 'Make whole bucket public?' : 'Make whole bucket private?',
|
|
38
|
+
message: isPublic
|
|
39
|
+
? <>This changes account-level S3 policy and Public Access Block posture for <code>{bucket.name}</code>. Verified configuration does not guarantee every anonymous fetch.</>
|
|
40
|
+
: <>This applies the all-private Public Access Block posture to <code>{bucket.name}</code>. The operation is externally observable and not atomic.</>,
|
|
41
|
+
confirmText: isPublic ? 'Make public' : 'Make private', danger: isPublic,
|
|
42
|
+
});
|
|
43
|
+
if (!confirmed) return;
|
|
44
|
+
const next = await setBucketPublic(queryClient, bucket.name, isPublic);
|
|
45
|
+
setOutcome(next);
|
|
46
|
+
if (next.data) toast.success(`${bucket.name} verified ${next.data.is_public ? 'public' : 'private'}`);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const confirmEmpty = async () => {
|
|
50
|
+
await modal.open((close) => (
|
|
51
|
+
<div className="modal-pad">
|
|
52
|
+
<h2 className="modal-title">Empty {bucket.name}</h2>
|
|
53
|
+
<p className="modal-message">Irreversibly removes current objects, every version/delete marker, and outstanding multipart uploads. Enter the exact case-sensitive name.</p>
|
|
54
|
+
<SchemaForm
|
|
55
|
+
fields={[{ name: 'confirm_name', type: 'text', label: 'Exact bucket name', required: true }]}
|
|
56
|
+
submitText="Empty bucket"
|
|
57
|
+
onCancel={() => close(null)}
|
|
58
|
+
onSubmit={async (data) => {
|
|
59
|
+
const exact = String(data.confirm_name ?? '');
|
|
60
|
+
if (exact !== bucket.name) throw new Error('Bucket name does not match exactly. No request was sent.');
|
|
61
|
+
const next = await emptyBucket(queryClient, bucket.name, exact);
|
|
62
|
+
setOutcome(next);
|
|
63
|
+
if (next.error) throw next.error;
|
|
64
|
+
if (next.data) { setEmptyResult(next.data); toast.success(`${bucket.name} empty acknowledged`); }
|
|
65
|
+
close(true);
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
), { size: 'sm' });
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return <DetailView title={bucket.name} subtitle="Account-level S3 bucket" icon="bi-bucket" chips={[{ text: 'Global', tone: 'info' }]} sections={[
|
|
73
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section"><FlatRow label="Name"><code>{bucket.name}</code></FlatRow><FlatRow label="Created">{fmt.datetime(bucket.created)}</FlatRow><FlatRow label="Inventory">Complete account inventory row</FlatRow><Outcome value={outcome} />{emptyResult && <div className="storage-counts"><b>Last acknowledged empty counts</b><span>Objects {emptyResult.deleted_objects}</span><span>Versions {emptyResult.deleted_versions}</span><span>Delete markers {emptyResult.deleted_markers}</span><span>Multipart uploads {emptyResult.aborted_uploads}</span></div>}</div> },
|
|
74
|
+
{ key: 'access', label: 'Access', icon: 'bi-shield-lock', permissions: BUCKET_MANAGE_PERMS, render: () => <div className="detail-section"><h3>Verified access posture</h3><p className="dim">Every attempt refreshes inventory in a finally path. A rejection can still represent partial external change.</p><div className="storage-action-row"><button className="btn btn-danger" onClick={() => void access(true)}>Make public…</button><button className="btn" onClick={() => void access(false)}>Make private…</button></div></div> },
|
|
75
|
+
{ key: 'danger', label: 'Danger zone', icon: 'bi-exclamation-octagon', permissions: BUCKET_MANAGE_PERMS, render: () => <div className="detail-section storage-danger"><h3>Empty bucket</h3><p>Irreversible and non-atomic against active writers. Bucket deletion is unavailable.</p><ArmedButton className="btn-danger" icon="bi-trash3" label="Empty bucket" armedLabel="Click again — remove all objects, versions, markers, and uploads" onConfirm={confirmEmpty} /></div> },
|
|
76
|
+
]} initialSection="overview" onClose={onClose} />;
|
|
77
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { fmt, modal, toast } from '../../ui';
|
|
4
|
+
import { BucketDetail, IncompleteEvidence } from './BucketDetail';
|
|
5
|
+
import { createBucket, useBuckets, type BucketMutationOutcome, type BucketCreateResult, type S3BucketRow } from './models';
|
|
6
|
+
|
|
7
|
+
export function showBucketDetail(bucket: S3BucketRow): void {
|
|
8
|
+
void modal.detail((close) => <BucketDetail bucket={bucket} onClose={() => close(null)} />);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function BucketsPage() {
|
|
12
|
+
const queryClient = useQueryClient();
|
|
13
|
+
const query = useBuckets();
|
|
14
|
+
const [search, setSearch] = useState('');
|
|
15
|
+
const [name, setName] = useState('');
|
|
16
|
+
const [creating, setCreating] = useState(false);
|
|
17
|
+
const [createOutcome, setCreateOutcome] = useState<BucketMutationOutcome<BucketCreateResult> | null>(null);
|
|
18
|
+
const rows = useMemo(() => [...(query.data ?? [])]
|
|
19
|
+
.filter((row) => row.name.toLowerCase().includes(search.trim().toLowerCase()))
|
|
20
|
+
.sort((a, b) => a.name.localeCompare(b.name)), [query.data, search]);
|
|
21
|
+
|
|
22
|
+
const submit = async (event: React.FormEvent) => {
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
const bucketName = name.trim();
|
|
25
|
+
if (!bucketName) return;
|
|
26
|
+
setCreating(true);
|
|
27
|
+
setCreateOutcome(null);
|
|
28
|
+
const outcome = await createBucket(queryClient, bucketName);
|
|
29
|
+
setCreateOutcome(outcome);
|
|
30
|
+
setCreating(false);
|
|
31
|
+
if (outcome.error) {
|
|
32
|
+
toast.error(outcome.error instanceof Error ? outcome.error.message : 'Bucket create failed');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
setName('');
|
|
36
|
+
toast.success(outcome.data?.created_new === false ? `${bucketName} already exists` : `${bucketName} created private`);
|
|
37
|
+
if (outcome.refreshError) toast.warning('Bucket created, but inventory refresh failed. Retry refresh before another action.');
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="panel storage-buckets">
|
|
42
|
+
<div className="toolbar">
|
|
43
|
+
<div className="toolbar-heading"><div className="eyebrow">Infrastructure · Storage</div><h1 className="panel-title">Buckets</h1></div>
|
|
44
|
+
<button className="btn-icon" title="Refresh bucket inventory" onClick={() => void query.refetch()}>
|
|
45
|
+
<i className={`bi bi-arrow-repeat${query.isFetching ? ' spin' : ''}`} />
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
<div className="storage-bucket-tools">
|
|
49
|
+
<label className="field"><span className="field-label">Local name search</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search complete inventory" /></label>
|
|
50
|
+
<form className="storage-bucket-create" onSubmit={(event) => void submit(event)}>
|
|
51
|
+
<label className="field"><span className="field-label">New private bucket</span><input value={name} onChange={(event) => setName(event.target.value)} required placeholder="exact-bucket-name" /></label>
|
|
52
|
+
<button className="btn btn-primary" disabled={creating || !name.trim()}>{creating ? 'Creating…' : 'Create private'}</button>
|
|
53
|
+
</form>
|
|
54
|
+
{createOutcome && Boolean(createOutcome.evidence || createOutcome.refreshError) && <div className={`storage-operation-result${createOutcome.error ? ' is-error' : ''}`}>{Boolean(createOutcome.error) && <p>{createOutcome.error instanceof Error ? createOutcome.error.message : 'Bucket create failed'}</p>}{createOutcome.evidence && <IncompleteEvidence evidence={createOutcome.evidence} />}{Boolean(createOutcome.refreshError) && <p className="text-warn"><i className="bi bi-exclamation-triangle" /> Authoritative refresh also failed. Refresh manually before another action.</p>}</div>}
|
|
55
|
+
</div>
|
|
56
|
+
{query.isError ? (
|
|
57
|
+
<div className="empty storage-error"><i className="bi bi-cloud-slash" /><h2>Bucket inventory unavailable</h2><p>{query.error instanceof Error ? query.error.message : 'AWS did not return a conclusive inventory.'}</p><button className="btn" onClick={() => void query.refetch()}>Retry</button></div>
|
|
58
|
+
) : query.isLoading ? <div className="empty"><i className="bi bi-hourglass-split" /><p>Loading the complete account inventory…</p></div>
|
|
59
|
+
: rows.length === 0 ? <div className="empty"><i className="bi bi-bucket" /><h2>{query.data?.length ? 'No matching buckets' : 'No buckets'}</h2><p>{query.data?.length ? 'Clear the local name search.' : 'AWS returned a conclusive empty inventory.'}</p></div>
|
|
60
|
+
: <div className="tbl-wrap"><table className="tbl"><thead><tr><th>Name</th><th>Created</th><th aria-label="Open" /></tr></thead><tbody>{rows.map((row) => <tr key={row.id} className="row-click" onClick={() => showBucketDetail(row)}><td><code>{row.name}</code></td><td>{fmt.datetime(row.created)}</td><td className="text-end"><i className="bi bi-chevron-right" /></td></tr>)}</tbody></table></div>}
|
|
61
|
+
<p className="storage-footnote">This is a bounded complete-list surface. Bucket deletion is not supported.</p>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { Badge, DetailView, FlatRow, fmt, modal, toast } from '../../ui';
|
|
3
|
+
import { useCan } from '../../client';
|
|
4
|
+
import { FileView } from './FileView';
|
|
5
|
+
import { openFileManagerEditor } from './storage-dialogs';
|
|
6
|
+
import { FileManagerModel, FileModel, GROUP_DIRECTORY_PERMS, STORAGE_MANAGE_PERMS, USER_DIRECTORY_PERMS, storageRelationId, runFileManagerAction, saveFileManagerAtomic } from './models';
|
|
7
|
+
|
|
8
|
+
function NestedFiles({ managerId }: { managerId: number }) {
|
|
9
|
+
const query = FileModel.useList({ file_manager: managerId, start: 0, size: 10, sort: '-created' });
|
|
10
|
+
if (query.isLoading) return <p className="dim">Loading recent files…</p>;
|
|
11
|
+
if (query.isError) return <div className="form-alert">{query.error instanceof Error ? query.error.message : 'Files unavailable'}</div>;
|
|
12
|
+
const rows = query.data?.rows ?? [];
|
|
13
|
+
return <div className="storage-child-files">{rows.length === 0 ? <p className="dim">No files. Upload is unavailable in this release.</p> : rows.map((file) => <button key={file.id} className="storage-child-file" onClick={() => void modal.detail((close) => <FileView id={file.id} onClose={() => close(null)} />)}><span><b>{file.filename}</b><small>{file.content_type} · {fmt.filesize(file.file_size ?? 0)}</small></span><i className="bi bi-chevron-right" /></button>)}</div>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function FileManagerDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
17
|
+
const queryClient = useQueryClient();
|
|
18
|
+
const query = FileManagerModel.useOne(id);
|
|
19
|
+
const managerPermission = useCan(STORAGE_MANAGE_PERMS);
|
|
20
|
+
const groupDirectoryPermission = useCan(GROUP_DIRECTORY_PERMS);
|
|
21
|
+
const userDirectoryPermission = useCan(USER_DIRECTORY_PERMS);
|
|
22
|
+
const canManage = managerPermission.can;
|
|
23
|
+
const canChangeOwner = groupDirectoryPermission.can || userDirectoryPermission.can || Boolean(managerPermission.me?.is_superuser);
|
|
24
|
+
if (query.isLoading) return <div className="empty"><p>Loading storage backend…</p></div>;
|
|
25
|
+
if (!query.data) return <div className="empty"><h2>Storage backend unavailable</h2><p>{query.error instanceof Error ? query.error.message : 'The record was not returned.'}</p><button className="btn" onClick={onClose}>Close</button></div>;
|
|
26
|
+
const row = query.data;
|
|
27
|
+
const edit = async (mode: 'general' | 'credentials' | 'owner') => { const saved = await openFileManagerEditor(mode, row); if (saved) { toast.success(`${saved.name} updated`); await query.refetch(); } };
|
|
28
|
+
const action = async (name: 'test_connection' | 'check_cors' | 'fix_cors') => { try { const result = await runFileManagerAction(id, name); toast.success(name === 'test_connection' ? 'Connection succeeded' : `${name.replace('_', ' ')} completed`); await modal.open((close) => <div className="modal-pad"><h2 className="modal-title">{name.replaceAll('_', ' ')}</h2><pre className="storage-result-json">{JSON.stringify(result, null, 2)}</pre><div className="modal-actions"><button className="btn" onClick={() => close(null)}>Close</button></div></div>); } catch (error) { toast.error(error instanceof Error ? error.message : `${name} failed`); } };
|
|
29
|
+
const clone = async () => { const ok = await modal.confirm({ title: 'Clone backend?', message: <>Create a private clone of <b>{row.name}</b> under the same explicit owner scope?</>, confirmText: 'Clone' }); if (!ok) return; try { await runFileManagerAction(id, 'clone'); await FileManagerModel.invalidate(queryClient); toast.success(`${row.name} cloned`); } catch (error) { toast.error(error instanceof Error ? error.message : 'Clone failed'); } };
|
|
30
|
+
const toggle = async () => { const ok = await modal.confirm({ title: row.is_public ? 'Make backend private?' : 'Make backend public?', message: 'This changes prefix policy on the storage provider. Continue?', confirmText: row.is_public ? 'Make private' : 'Make public', danger: !row.is_public }); if (!ok) return; try { await saveFileManagerAtomic({ queryClient, id, changes: { is_public: !row.is_public } }); await query.refetch(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Access change failed'); } };
|
|
31
|
+
return <DetailView title={row.name} subtitle={`${row.backend_type} · ${row.backend_url}`} icon="bi-hdd-stack" chips={[{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }, { text: row.is_public ? 'Public' : 'Private', tone: row.is_public ? 'warning' : 'success' }]} sections={[
|
|
32
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section"><div className="storage-action-row">{canManage && <><button className="btn" onClick={() => void edit('general')}>Edit</button><button className="btn" onClick={() => void edit('credentials')}>Credentials</button>{canChangeOwner && <button className="btn" onClick={() => void edit('owner')}>Owner scope</button>}<button className="btn" onClick={() => void toggle()}>{row.is_public ? 'Make private…' : 'Make public…'}</button></>}</div><FlatRow label="Backend"><Badge tone="info">{row.backend_type}</Badge></FlatRow><FlatRow label="Use">{row.use || '—'}</FlatRow><FlatRow label="URL"><code>{row.backend_url}</code></FlatRow><FlatRow label="Owner">{storageRelationId(row.group) != null ? `Group #${storageRelationId(row.group)}` : storageRelationId(row.user) != null ? `User #${storageRelationId(row.user)}` : 'System'}</FlatRow><FlatRow label="Default">{row.is_default ? 'Yes' : 'No'}</FlatRow><FlatRow label="Region">{row.aws_region || '—'}</FlatRow><FlatRow label="Access key">{row.aws_key_masked || 'Not configured'}</FlatRow><FlatRow label="Secret key">{row.aws_secret_masked || 'Not configured'}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> },
|
|
33
|
+
{ key: 'files', label: 'Files', icon: 'bi-files', render: () => <div className="detail-section"><h3>Recent files</h3><p className="dim">Newest 10 for this backend. This bounded child inventory does not own URL table state.</p><NestedFiles managerId={id} /></div> },
|
|
34
|
+
{ key: 'operations', label: 'Operations', icon: 'bi-tools', permissions: STORAGE_MANAGE_PERMS, render: () => <div className="detail-section"><h3>Provider operations</h3><div className="storage-action-row"><button className="btn" onClick={() => void action('test_connection')}>Test connection</button>{row.backend_type === 's3' && <><button className="btn" onClick={() => void action('check_cors')}>Check CORS</button><button className="btn" onClick={() => void action('fix_cors')}>Fix CORS</button></>}<button className="btn" onClick={() => void clone()}>Clone…</button></div><p className="dim">Backend deletion and public-access audit shortcuts are deliberately unavailable.</p></div> },
|
|
35
|
+
]} initialSection="overview" onClose={onClose} />;
|
|
36
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { openCapabilityUrl, isSafeCapabilityUrl, type FileRow } from './models';
|
|
3
|
+
import { normalizeRenditions } from './file-renditions';
|
|
4
|
+
|
|
5
|
+
const KNOWN = new Set(['image', 'video', 'audio', 'pdf', 'document', 'spreadsheet', 'presentation', 'archive', 'text', 'csv']);
|
|
6
|
+
|
|
7
|
+
export function filePreviewCategory(file: Pick<FileRow, 'category' | 'content_type'>): string {
|
|
8
|
+
if (file.category) return file.category.toLowerCase();
|
|
9
|
+
const mime = file.content_type.toLowerCase();
|
|
10
|
+
if (mime.startsWith('image/')) return 'image';
|
|
11
|
+
if (mime.startsWith('video/')) return 'video';
|
|
12
|
+
if (mime.startsWith('audio/')) return 'audio';
|
|
13
|
+
if (mime === 'application/pdf') return 'pdf';
|
|
14
|
+
if (mime.includes('spreadsheet') || mime.includes('excel') || mime.includes('csv')) return 'spreadsheet';
|
|
15
|
+
if (mime.includes('presentation') || mime.includes('powerpoint')) return 'presentation';
|
|
16
|
+
if (mime.includes('word') || mime.includes('document')) return 'document';
|
|
17
|
+
if (mime.includes('zip') || mime.includes('archive') || mime.includes('tar')) return 'archive';
|
|
18
|
+
if (mime.startsWith('text/')) return 'text';
|
|
19
|
+
return 'unknown';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function SafeActions({ url, download = true }: { url: string | null | undefined; download?: boolean }) {
|
|
23
|
+
if (!isSafeCapabilityUrl(url)) return <p className="storage-unsafe"><i className="bi bi-shield-exclamation" /> URL withheld: the capability value is missing or unsafe.</p>;
|
|
24
|
+
return <div className="storage-action-row"><button className="btn" onClick={() => openCapabilityUrl(url)}><i className="bi bi-box-arrow-up-right" /> Open</button>{download && <button className="btn" onClick={() => openCapabilityUrl(url, true)}><i className="bi bi-download" /> Download</button>}</div>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Captures one safe media source for this mounted File id. */
|
|
28
|
+
export function StableMediaPreview({ file }: { file: FileRow }) {
|
|
29
|
+
const source = useRef(isSafeCapabilityUrl(file.url) ? file.url : null);
|
|
30
|
+
const category = filePreviewCategory(file);
|
|
31
|
+
if (!source.current) return <SafeActions url={file.url} />;
|
|
32
|
+
if (category === 'video') return <video className="storage-media" controls src={source.current} preload="metadata" />;
|
|
33
|
+
return <audio className="storage-media storage-audio" controls src={source.current} preload="metadata" />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function FilePreview({ file }: { file: FileRow }) {
|
|
37
|
+
const category = filePreviewCategory(file);
|
|
38
|
+
const warned = useRef(false);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!KNOWN.has(category) && !warned.current) {
|
|
41
|
+
warned.current = true;
|
|
42
|
+
console.warn(`FilePreview: unknown category "${category}" — using safe generic actions`);
|
|
43
|
+
}
|
|
44
|
+
}, [category]);
|
|
45
|
+
if (category === 'image') return isSafeCapabilityUrl(file.url) ? <div className="storage-preview"><img src={file.url} alt={file.filename} referrerPolicy="no-referrer" /><SafeActions url={file.url} /></div> : <SafeActions url={file.url} />;
|
|
46
|
+
if (category === 'video' || category === 'audio') return <div className="storage-preview"><StableMediaPreview file={file} /><SafeActions url={file.url} /></div>;
|
|
47
|
+
if (['document', 'spreadsheet', 'presentation'].includes(category)) {
|
|
48
|
+
const image = normalizeRenditions(file.renditions).find((row) => row.content_type.startsWith('image/') && isSafeCapabilityUrl(row.url));
|
|
49
|
+
return <div className="storage-preview">{image?.url && <img src={image.url} alt={`${file.filename} preview`} referrerPolicy="no-referrer" />}<SafeActions url={file.url} /></div>;
|
|
50
|
+
}
|
|
51
|
+
if (category === 'archive') return <div className="storage-generic-preview"><i className="bi bi-file-earmark-zip" /><h3>{file.filename}</h3><SafeActions url={file.url} /></div>;
|
|
52
|
+
if (category === 'pdf') return <div className="storage-generic-preview"><i className="bi bi-file-earmark-pdf" /><h3>PDF document</h3><SafeActions url={file.url} download={false} /></div>;
|
|
53
|
+
return <div className="storage-generic-preview"><i className="bi bi-file-earmark" /><h3>{file.filename}</h3><p>{file.content_type}</p><SafeActions url={file.url} /></div>;
|
|
54
|
+
}
|