Browse Source

get rid of settings page

implement transfer on cli and api

Nostr-Signature: a312986953d2b408aae10a51ec29b51aca8a2e6396e5b5ec7fd969bb12c5b882 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 09b7bff4ce945ac120a413246a0a6111bf9afc14e570524f1e2e4f8ee8e22a2a2c71fd00fedb836e030245d3cbc1e42fcb8c5bde7c643fde2551582f63942851
main
Silberengel 3 weeks ago
parent
commit
a64307efdd
  1. 1
      nostr/commit-signatures.jsonl
  2. 1
      src/hooks.server.ts
  3. 182
      src/routes/api/openapi.json/openapi.json
  4. 222
      src/routes/api/repos/[npub]/[repo]/settings/+server.ts
  5. 2
      src/routes/repos/[npub]/[repo]/+page.svelte
  6. 237
      src/routes/repos/[npub]/[repo]/settings/+page.svelte

1
nostr/commit-signatures.jsonl

@ -7,3 +7,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771520422,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","correct icons"]],"content":"Signed commit: correct icons","id":"3d630436d21542aa097b574829ba03f9700db4d707f3d7065bc24000321d0ba2","sig":"6e345bb8ca6fef352400dd10a801d1f41b8798b7a0307eba9af84ea3b4045235b50510905ab2cc9cbdd2894b56a0d1524560a9347c137f39cf756c43ca72c326"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771520422,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","correct icons"]],"content":"Signed commit: correct icons","id":"3d630436d21542aa097b574829ba03f9700db4d707f3d7065bc24000321d0ba2","sig":"6e345bb8ca6fef352400dd10a801d1f41b8798b7a0307eba9af84ea3b4045235b50510905ab2cc9cbdd2894b56a0d1524560a9347c137f39cf756c43ca72c326"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771520523,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix contrast"]],"content":"Signed commit: fix contrast","id":"210177972a67b45a8c56494f2423987ffd30fc5594c539ed6a9f23c0f0992d21","sig":"c3122ebc0055f5a7145d394b9461b811b6e37a7423493d62a6debf7078c006435352e2e2a4259fce6a8a13486bdd137e2e7a49bfdf512a37a73d0c36d405ff2f"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771520523,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix contrast"]],"content":"Signed commit: fix contrast","id":"210177972a67b45a8c56494f2423987ffd30fc5594c539ed6a9f23c0f0992d21","sig":"c3122ebc0055f5a7145d394b9461b811b6e37a7423493d62a6debf7078c006435352e2e2a4259fce6a8a13486bdd137e2e7a49bfdf512a37a73d0c36d405ff2f"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771522633,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","adjusting api for themes"]],"content":"Signed commit: adjusting api for themes","id":"c6125da849827ef6481eed3588231630470289db0176066fc9c1e044f839976b","sig":"7a943b493af9d7108a26fb3bad8166e58ba2ed08eb6c24c178775387620601e6a130ce8a0f344a79e637fc4e75ed2e6d308a242101b14bdb38ccb901c09ff13f"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771522633,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","adjusting api for themes"]],"content":"Signed commit: adjusting api for themes","id":"c6125da849827ef6481eed3588231630470289db0176066fc9c1e044f839976b","sig":"7a943b493af9d7108a26fb3bad8166e58ba2ed08eb6c24c178775387620601e6a130ce8a0f344a79e637fc4e75ed2e6d308a242101b14bdb38ccb901c09ff13f"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771529356,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","update transfer workflow"]],"content":"Signed commit: update transfer workflow","id":"5d6d6909666a881f88f240389d30f5bedd36dba5d69a9d24dca86557b0098867","sig":"d13caca8b3e1009469e28c352bdfacf5eb78e2e9f5ac80c8511a9e2c6c5ac7031b83374d2d91b93b8018b5a3402e3e9c7114332da89ee2cb039f64aa3207f3f4"}

1
src/hooks.server.ts

@ -73,7 +73,6 @@ export const handle: Handle = async ({ event, resolve }) => {
!url.pathname.includes('/file') && // File operations are rate limited separately !url.pathname.includes('/file') && // File operations are rate limited separately
!url.pathname.includes('/delete') && !url.pathname.includes('/delete') &&
!url.pathname.includes('/transfer') && !url.pathname.includes('/transfer') &&
!url.pathname.includes('/settings') && // Settings might be write operations
(url.pathname.endsWith('/fork') || // GET /fork is read-only (url.pathname.endsWith('/fork') || // GET /fork is read-only
url.pathname.endsWith('/verify') || // GET /verify is read-only url.pathname.endsWith('/verify') || // GET /verify is read-only
url.pathname.endsWith('/readme') || // GET /readme is read-only url.pathname.endsWith('/readme') || // GET /readme is read-only

182
src/routes/api/openapi.json/openapi.json

@ -130,45 +130,6 @@
} }
} }
}, },
"RepositorySettings": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"cloneUrls": {
"type": "array",
"items": {
"type": "string"
}
},
"maintainers": {
"type": "array",
"items": {
"type": "string"
}
},
"chatRelays": {
"type": "array",
"items": {
"type": "string"
}
},
"isPrivate": {
"type": "boolean"
},
"owner": {
"type": "string",
"format": "hex"
},
"npub": {
"type": "string"
}
}
},
"FileContent": { "FileContent": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -331,149 +292,6 @@
} }
} }
}, },
"/api/repos/{npub}/{repo}/settings": {
"get": {
"summary": "Get repository settings",
"description": "Get repository settings. Requires owner authentication.",
"tags": ["Repositories"],
"security": [{"NIP98": []}],
"parameters": [
{
"name": "npub",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Repository owner's npub"
},
{
"name": "repo",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Repository name"
}
],
"responses": {
"200": {
"description": "Repository settings",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositorySettings"
}
}
}
},
"403": {
"description": "Not authorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"post": {
"summary": "Update repository settings",
"description": "Update repository settings. Requires owner authentication.",
"tags": ["Repositories"],
"security": [{"NIP98": []}],
"parameters": [
{
"name": "npub",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "repo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"cloneUrls": {
"type": "array",
"items": {
"type": "string"
}
},
"maintainers": {
"type": "array",
"items": {
"type": "string"
}
},
"chatRelays": {
"type": "array",
"items": {
"type": "string"
}
},
"isPrivate": {
"type": "boolean"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Settings updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"event": {
"$ref": "#/components/schemas/NostrEvent"
}
}
}
}
}
},
"403": {
"description": "Not authorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/repos/{npub}/{repo}/file": { "/api/repos/{npub}/{repo}/file": {
"get": { "get": {
"summary": "Get file content", "summary": "Get file content",

222
src/routes/api/repos/[npub]/[repo]/settings/+server.ts

@ -1,222 +0,0 @@
/**
* API endpoint for repository settings
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { nostrClient, maintainerService, ownershipTransferService, fileManager } from '$lib/services/service-registry.js';
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { KIND } from '$lib/types/nostr.js';
import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js';
import { generateVerificationFile, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js';
import { nip19 } from 'nostr-tools';
import logger from '$lib/services/logger.js';
/**
* GET - Get repository settings
*/
export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => {
// Check if user is owner
if (!context.userPubkeyHex) {
throw handleApiError(new Error('Authentication required'), { operation: 'getSettings', npub: context.npub, repo: context.repo }, 'Authentication required');
}
const currentOwner = await ownershipTransferService.getCurrentOwner(context.repoOwnerPubkey, context.repo);
if (context.userPubkeyHex !== currentOwner) {
throw handleAuthorizationError('Only the repository owner can access settings', { operation: 'getSettings', npub: context.npub, repo: context.repo });
}
// Get repository announcement
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [currentOwner],
'#d': [context.repo],
limit: 1
}
]);
if (events.length === 0) {
throw handleNotFoundError('Repository announcement not found', { operation: 'getSettings', npub: context.npub, repo: context.repo });
}
const announcement = events[0];
const name = announcement.tags.find(t => t[0] === 'name')?.[1] || context.repo;
const description = announcement.tags.find(t => t[0] === 'description')?.[1] || '';
const cloneUrls = announcement.tags
.filter(t => t[0] === 'clone')
.flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string') as string[];
const maintainers = announcement.tags
.filter(t => t[0] === 'maintainers')
.flatMap(t => t.slice(1))
.filter(m => m && typeof m === 'string') as string[];
const chatRelays = announcement.tags
.filter(t => t[0] === 'chat-relay')
.flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string') as string[];
const privacyInfo = await maintainerService.getPrivacyInfo(currentOwner, context.repo);
const isPrivate = privacyInfo.isPrivate;
return json({
name,
description,
cloneUrls,
maintainers,
chatRelays,
isPrivate,
owner: currentOwner,
npub: context.npub
});
},
{ operation: 'getSettings', requireRepoAccess: false } // Override to check owner instead
);
/**
* POST - Update repository settings
*/
export const POST: RequestHandler = withRepoValidation(
async ({ repoContext, requestContext, event }) => {
if (!requestContext.userPubkeyHex) {
throw handleApiError(new Error('Authentication required'), { operation: 'updateSettings', npub: repoContext.npub, repo: repoContext.repo }, 'Authentication required');
}
const body = await event.request.json();
const { name, description, cloneUrls, maintainers, chatRelays, isPrivate } = body;
// Check if user is owner
const currentOwner = await ownershipTransferService.getCurrentOwner(repoContext.repoOwnerPubkey, repoContext.repo);
if (requestContext.userPubkeyHex !== currentOwner) {
throw handleAuthorizationError('Only the repository owner can update settings', { operation: 'updateSettings', npub: repoContext.npub, repo: repoContext.repo });
}
// Get existing announcement
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [currentOwner],
'#d': [repoContext.repo],
limit: 1
}
]);
if (events.length === 0) {
throw handleNotFoundError('Repository announcement not found', { operation: 'updateSettings', npub: repoContext.npub, repo: repoContext.repo });
}
const existingAnnouncement = events[0];
// Build updated tags
const gitDomain = process.env.GIT_DOMAIN || 'localhost:6543';
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1');
const protocol = isLocalhost ? 'http' : 'https';
const gitUrl = `${protocol}://${gitDomain}/${repoContext.npub}/${repoContext.repo}.git`;
// Get Tor .onion URL if available
const { getTorGitUrl } = await import('$lib/services/tor/hidden-service.js');
const torOnionUrl = await getTorGitUrl(repoContext.npub, repoContext.repo);
// Filter user-provided clone URLs (exclude localhost and .onion duplicates)
const userCloneUrls = (cloneUrls || []).filter((url: string) => {
if (!url || !url.trim()) return false;
// Exclude if it's our domain or already a .onion
if (url.includes(gitDomain)) return false;
if (url.includes('.onion')) return false;
return true;
});
// Build clone URLs - NEVER include localhost, only include public domain or Tor .onion
const cloneUrlList: string[] = [];
// Add our domain URL only if it's NOT localhost (explicitly check the URL)
if (!isLocalhost && !gitUrl.includes('localhost') && !gitUrl.includes('127.0.0.1')) {
cloneUrlList.push(gitUrl);
}
// Add Tor .onion URL if available (always useful, even with localhost)
if (torOnionUrl) {
cloneUrlList.push(torOnionUrl);
}
// Add user-provided clone URLs
cloneUrlList.push(...userCloneUrls);
// Validate: If using localhost, require either Tor .onion URL or at least one other clone URL
if (isLocalhost && !torOnionUrl && userCloneUrls.length === 0) {
throw error(400, 'Cannot update with only localhost. You need either a Tor .onion address or at least one other clone URL.');
}
const tags: string[][] = [
['d', repoContext.repo],
['name', name || repoContext.repo],
...(description ? [['description', description]] : []),
['clone', ...cloneUrlList],
['relays', ...DEFAULT_NOSTR_RELAYS],
...(isPrivate ? [['private', 'true']] : []),
...(maintainers || []).map((m: string) => ['maintainers', m]),
...(chatRelays && chatRelays.length > 0 ? [['chat-relay', ...chatRelays]] : [])
];
// Preserve other tags from original announcement
const preserveTags = ['r', 'web', 't'];
for (const tag of existingAnnouncement.tags) {
if (preserveTags.includes(tag[0]) && !tags.some(t => t[0] === tag[0])) {
tags.push(tag);
}
}
// Create updated announcement
const updatedAnnouncement = {
kind: KIND.REPO_ANNOUNCEMENT,
pubkey: currentOwner,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags
};
// Sign and publish
const signedEvent = await signEventWithNIP07(updatedAnnouncement);
const { outbox } = await getUserRelays(currentOwner, nostrClient);
const combinedRelays = combineRelays(outbox);
const result = await nostrClient.publishEvent(signedEvent, combinedRelays);
if (result.success.length === 0) {
throw error(500, 'Failed to publish updated announcement to relays');
}
// Save updated announcement to repo (offline papertrail)
try {
const announcementFileContent = generateVerificationFile(signedEvent, currentOwner);
// Save to repo if it exists locally
if (fileManager.repoExists(repoContext.npub, repoContext.repo)) {
await fileManager.writeFile(
repoContext.npub,
repoContext.repo,
VERIFICATION_FILE_PATH,
announcementFileContent,
`Update repository announcement: ${signedEvent.id.slice(0, 16)}...`,
'Nostr',
`${currentOwner}@nostr`,
'main'
).catch(err => {
// Log but don't fail - publishing to relays is more important
logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save updated announcement to repo');
});
}
} catch (err) {
// Log but don't fail - publishing to relays is more important
logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save updated announcement to repo');
}
return json({ success: true, event: signedEvent });
},
{ operation: 'updateSettings', requireRepoAccess: false } // Override to check owner instead
);

2
src/routes/repos/[npub]/[repo]/+page.svelte

@ -2987,7 +2987,7 @@
</button> </button>
{/if} {/if}
{#if isMaintainer} {#if isMaintainer}
<a href={`/repos/${npub}/${repo}/settings`} class="settings-button">Settings</a> <a href={`/signup?npub=${npub}&repo=${repo}`} class="settings-button">Settings</a>
{/if} {/if}
{#if pageData.repoOwnerPubkey && userPubkeyHex === pageData.repoOwnerPubkey} {#if pageData.repoOwnerPubkey && userPubkeyHex === pageData.repoOwnerPubkey}
{#if verificationStatus?.verified !== true} {#if verificationStatus?.verified !== true}

237
src/routes/repos/[npub]/[repo]/settings/+page.svelte

@ -1,237 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { userStore } from '$lib/stores/user-store.js';
const npub = ($page.params as { npub?: string; repo?: string }).npub || '';
const repo = ($page.params as { npub?: string; repo?: string }).repo || '';
let loading = $state(true);
let saving = $state(false);
let error = $state<string | null>(null);
let userPubkey = $state<string | null>(null);
// Sync with userStore
$effect(() => {
const currentUser = $userStore;
if (currentUser.userPubkey) {
userPubkey = currentUser.userPubkey;
} else {
userPubkey = null;
}
});
let name = $state('');
let description = $state('');
let cloneUrls = $state<string[]>(['']);
let maintainers = $state<string[]>(['']);
let chatRelays = $state<string[]>(['']);
let isPrivate = $state(false);
onMount(async () => {
await checkAuth();
await loadSettings();
});
async function checkAuth() {
// Check userStore first
const currentUser = $userStore;
if (currentUser.userPubkey) {
userPubkey = currentUser.userPubkey;
return;
}
// Fallback: try NIP-07 if store doesn't have it
try {
if (typeof window !== 'undefined' && window.nostr) {
userPubkey = await getPublicKeyWithNIP07();
}
} catch (err) {
console.error('Auth check failed:', err);
}
}
async function loadSettings() {
loading = true;
error = null;
try {
const response = await fetch(`/api/repos/${npub}/${repo}/settings?userPubkey=${userPubkey}`);
if (response.ok) {
const data = await response.json();
name = data.name || '';
description = data.description || '';
cloneUrls = data.cloneUrls?.length > 0 ? data.cloneUrls : [''];
maintainers = data.maintainers?.length > 0 ? data.maintainers : [''];
chatRelays = data.chatRelays?.length > 0 ? data.chatRelays : [''];
isPrivate = data.isPrivate || false;
} else {
const data = await response.json();
error = data.error || 'Failed to load settings';
if (response.status === 403) {
setTimeout(() => goto(`/repos/${npub}/${repo}`), 2000);
}
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load settings';
} finally {
loading = false;
}
}
async function saveSettings() {
if (!userPubkey) {
error = 'Please connect your NIP-07 extension';
return;
}
saving = true;
error = null;
try {
const response = await fetch(`/api/repos/${npub}/${repo}/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userPubkey,
name,
description,
cloneUrls: cloneUrls.filter(url => url.trim()),
maintainers: maintainers.filter(m => m.trim()),
chatRelays: chatRelays.filter(url => url.trim()),
isPrivate
})
});
if (response.ok) {
alert('Settings saved successfully!');
goto(`/repos/${npub}/${repo}`);
} else {
const data = await response.json();
error = data.error || 'Failed to save settings';
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save settings';
} finally {
saving = false;
}
}
function addCloneUrl() {
cloneUrls = [...cloneUrls, ''];
}
function removeCloneUrl(index: number) {
cloneUrls = cloneUrls.filter((_, i) => i !== index);
}
function addMaintainer() {
maintainers = [...maintainers, ''];
}
function removeMaintainer(index: number) {
maintainers = maintainers.filter((_, i) => i !== index);
}
function addChatRelay() {
chatRelays = [...chatRelays, ''];
}
function removeChatRelay(index: number) {
chatRelays = chatRelays.filter((_, i) => i !== index);
}
</script>
<div class="container">
<header>
<h1>Repository Settings</h1>
</header>
<main>
{#if loading}
<div class="loading">Loading settings...</div>
{:else if error && !userPubkey}
<div class="error">
{error}
<p>Redirecting to repository...</p>
</div>
{:else}
<form onsubmit={(e) => { e.preventDefault(); saveSettings(); }} class="settings-form">
<div class="form-section">
<h2>Basic Information</h2>
<label>
Repository Name
<input type="text" bind:value={name} required />
</label>
<label>
Description
<textarea bind:value={description} rows="3"></textarea>
</label>
<label>
<input type="checkbox" bind:checked={isPrivate} />
Private Repository (only owners and maintainers can view)
</label>
</div>
<div class="form-section">
<h2>Clone URLs</h2>
<p class="help-text">Additional clone URLs (your server URL is automatically included)</p>
{#each cloneUrls as url, index}
<div class="array-input">
<input type="url" bind:value={cloneUrls[index]} placeholder="https://example.com/repo.git" />
{#if cloneUrls.length > 1}
<button type="button" onclick={() => removeCloneUrl(index)} class="remove-button">Remove</button>
{/if}
</div>
{/each}
<button type="button" onclick={addCloneUrl} class="add-button">+ Add Clone URL</button>
</div>
<div class="form-section">
<h2>Maintainers</h2>
<p class="help-text">Additional maintainers (npub or hex pubkey)</p>
{#each maintainers as maintainer, index}
<div class="array-input">
<input type="text" bind:value={maintainers[index]} placeholder="npub1..." />
{#if maintainers.length > 1}
<button type="button" onclick={() => removeMaintainer(index)} class="remove-button">Remove</button>
{/if}
</div>
{/each}
<button type="button" onclick={addMaintainer} class="add-button">+ Add Maintainer</button>
</div>
<div class="form-section">
<h2>Chat Relays</h2>
<p class="help-text">WebSocket relays for kind 11 discussion threads (e.g., wss://myprojechat.com, ws://localhost:2937)</p>
{#each chatRelays as relay, index}
<div class="array-input">
<input type="text" bind:value={chatRelays[index]} placeholder="wss://example.com" />
{#if chatRelays.length > 1}
<button type="button" onclick={() => removeChatRelay(index)} class="remove-button">Remove</button>
{/if}
</div>
{/each}
<button type="button" onclick={addChatRelay} class="add-button">+ Add Chat Relay</button>
</div>
{#if error}
<div class="error">{error}</div>
{/if}
<div class="form-actions">
<button type="button" onclick={() => goto(`/repos/${npub}/${repo}`)} class="cancel-button">Cancel</button>
<button type="submit" disabled={saving} class="save-button">
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</form>
{/if}
</main>
</div>
Loading…
Cancel
Save