feat: add branch support, sync resolution, and dynamic cache TTL
- Add branch detection in parseRemoteIOR for GitHub and Gitea - Support fetching from branches in addition to tags - Implement synchronous remote resolution using sync-fetch - Add dynamic cache TTL based on version type (beta/rc/branch/stable) - Export sync functions for Metro bundler usage - Fix remote component resolution in Metro (no more sync errors)
This commit is contained in:
103
lib/ior-core.js
103
lib/ior-core.js
@@ -31,14 +31,23 @@ function parseRemoteIOR(ior) {
|
|||||||
// ior:github:owner/repo:componentname@version
|
// ior:github:owner/repo:componentname@version
|
||||||
const [, , repoPath, componentAndVersion] = parts;
|
const [, , repoPath, componentAndVersion] = parts;
|
||||||
const [owner, repo] = repoPath.split('/');
|
const [owner, repo] = repoPath.split('/');
|
||||||
const [componentName, version] = componentAndVersion.split('@');
|
const [componentName, versionOrBranch] = componentAndVersion.split('@');
|
||||||
|
|
||||||
|
// Detect if it's a branch
|
||||||
|
const isBranch = versionOrBranch && (
|
||||||
|
versionOrBranch.includes('/') ||
|
||||||
|
versionOrBranch.startsWith('main') ||
|
||||||
|
versionOrBranch.startsWith('master') ||
|
||||||
|
versionOrBranch.startsWith('dev')
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'github',
|
type: 'github',
|
||||||
owner,
|
owner,
|
||||||
repository: repo,
|
repository: repo,
|
||||||
componentName,
|
componentName,
|
||||||
version,
|
version: !isBranch ? versionOrBranch : undefined,
|
||||||
|
branch: isBranch ? versionOrBranch : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,24 +55,33 @@ function parseRemoteIOR(ior) {
|
|||||||
// Two possible formats:
|
// Two possible formats:
|
||||||
// ior:gitea:instance.com:owner/repo:componentname@version (5 parts)
|
// ior:gitea:instance.com:owner/repo:componentname@version (5 parts)
|
||||||
// ior:gitea:instance.com:owner/repo/componentname@version (4 parts - component name in path)
|
// ior:gitea:instance.com:owner/repo/componentname@version (4 parts - component name in path)
|
||||||
|
|
||||||
if (parts.length === 4) {
|
if (parts.length === 4) {
|
||||||
// Format: ior:gitea:instance.com:owner/repo/componentname@version
|
// Format: ior:gitea:instance.com:owner/repo/componentname@version
|
||||||
const [, , instance, pathAndVersion] = parts;
|
const [, , instance, pathAndVersion] = parts;
|
||||||
const [fullPath, version] = pathAndVersion.split('@');
|
const [fullPath, versionOrBranch] = pathAndVersion.split('@');
|
||||||
const pathParts = fullPath.split('/');
|
const pathParts = fullPath.split('/');
|
||||||
|
|
||||||
const owner = pathParts[0];
|
const owner = pathParts[0];
|
||||||
const componentName = pathParts[pathParts.length - 1];
|
const componentName = pathParts[pathParts.length - 1];
|
||||||
const repository = pathParts.slice(1).join('/') || componentName;
|
const repository = pathParts.slice(1).join('/') || componentName;
|
||||||
|
|
||||||
|
// Detect if it's a branch
|
||||||
|
const isBranch = versionOrBranch && (
|
||||||
|
versionOrBranch.includes('/') ||
|
||||||
|
versionOrBranch.startsWith('main') ||
|
||||||
|
versionOrBranch.startsWith('master') ||
|
||||||
|
versionOrBranch.startsWith('dev')
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'gitea',
|
type: 'gitea',
|
||||||
instance,
|
instance,
|
||||||
owner,
|
owner,
|
||||||
repository,
|
repository,
|
||||||
componentName,
|
componentName,
|
||||||
version,
|
version: !isBranch ? versionOrBranch : undefined,
|
||||||
|
branch: isBranch ? versionOrBranch : undefined,
|
||||||
};
|
};
|
||||||
} else if (parts.length === 5) {
|
} else if (parts.length === 5) {
|
||||||
// Format: ior:gitea:instance.com:owner/repo:componentname@version
|
// Format: ior:gitea:instance.com:owner/repo:componentname@version
|
||||||
@@ -71,18 +89,27 @@ function parseRemoteIOR(ior) {
|
|||||||
const repoPathParts = repoPath.split('/');
|
const repoPathParts = repoPath.split('/');
|
||||||
const owner = repoPathParts[0];
|
const owner = repoPathParts[0];
|
||||||
const repository = repoPathParts.slice(1).join('/');
|
const repository = repoPathParts.slice(1).join('/');
|
||||||
const [componentName, version] = componentAndVersion.split('@');
|
const [componentName, versionOrBranch] = componentAndVersion.split('@');
|
||||||
|
|
||||||
|
// Detect if it's a branch
|
||||||
|
const isBranch = versionOrBranch && (
|
||||||
|
versionOrBranch.includes('/') ||
|
||||||
|
versionOrBranch.startsWith('main') ||
|
||||||
|
versionOrBranch.startsWith('master') ||
|
||||||
|
versionOrBranch.startsWith('dev')
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'gitea',
|
type: 'gitea',
|
||||||
instance,
|
instance,
|
||||||
owner,
|
owner,
|
||||||
repository,
|
repository,
|
||||||
componentName,
|
componentName,
|
||||||
version,
|
version: !isBranch ? versionOrBranch : undefined,
|
||||||
|
branch: isBranch ? versionOrBranch : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,15 +226,29 @@ async function fetchUrlBuffer(url) {
|
|||||||
* Fetch component archive from GitHub
|
* Fetch component archive from GitHub
|
||||||
*/
|
*/
|
||||||
async function fetchFromGitHub(config) {
|
async function fetchFromGitHub(config) {
|
||||||
const { owner, repository, componentName, version } = config;
|
const { owner, repository, componentName, version, branch } = config;
|
||||||
|
|
||||||
// Try both tag formats: "1.0.0" and "v1.0.0"
|
// If it's a branch, fetch from branch
|
||||||
|
if (branch) {
|
||||||
|
const archiveUrl = `https://api.github.com/repos/${owner}/${repository}/tarball/${branch}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[IOR Resolver] Fetching GitHub branch: ${branch}`);
|
||||||
|
const archiveBuffer = await fetchUrlBuffer(archiveUrl);
|
||||||
|
console.log(`[IOR Resolver] Downloaded archive from GitHub branch ${branch} (${archiveBuffer.length} bytes)`);
|
||||||
|
return { archive: archiveBuffer, componentName };
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to fetch branch ${branch} from GitHub: ${owner}/${repository} - ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try tag formats: "1.0.0" and "v1.0.0"
|
||||||
const tagVariants = [version, `v${version}`];
|
const tagVariants = [version, `v${version}`];
|
||||||
|
|
||||||
for (const tag of tagVariants) {
|
for (const tag of tagVariants) {
|
||||||
// GitHub API endpoint for tarball
|
// GitHub API endpoint for tarball
|
||||||
const archiveUrl = `https://api.github.com/repos/${owner}/${repository}/tarball/${tag}`;
|
const archiveUrl = `https://api.github.com/repos/${owner}/${repository}/tarball/${tag}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[IOR Resolver] Fetching GitHub archive: ${archiveUrl}`);
|
console.log(`[IOR Resolver] Fetching GitHub archive: ${archiveUrl}`);
|
||||||
const archiveBuffer = await fetchUrlBuffer(archiveUrl);
|
const archiveBuffer = await fetchUrlBuffer(archiveUrl);
|
||||||
@@ -218,7 +259,7 @@ async function fetchFromGitHub(config) {
|
|||||||
// Try next tag variant
|
// Try next tag variant
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Failed to fetch component from GitHub: ${owner}/${repository}@${version} (tried tags: ${tagVariants.join(', ')})`);
|
throw new Error(`Failed to fetch component from GitHub: ${owner}/${repository}@${version} (tried tags: ${tagVariants.join(', ')})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,15 +267,29 @@ async function fetchFromGitHub(config) {
|
|||||||
* Fetch component archive from Gitea
|
* Fetch component archive from Gitea
|
||||||
*/
|
*/
|
||||||
async function fetchFromGitea(config) {
|
async function fetchFromGitea(config) {
|
||||||
const { instance, owner, repository, componentName, version } = config;
|
const { instance, owner, repository, componentName, version, branch } = config;
|
||||||
|
|
||||||
// Try both tag formats: "1.0.0" and "v1.0.0"
|
// If it's a branch, fetch from branch
|
||||||
|
if (branch) {
|
||||||
|
const archiveUrl = `https://${instance}/api/v1/repos/${owner}/${repository}/archive/${branch}.tar.gz`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[IOR Resolver] Fetching Gitea branch: ${branch}`);
|
||||||
|
const archiveBuffer = await fetchUrlBuffer(archiveUrl);
|
||||||
|
console.log(`[IOR Resolver] Downloaded archive from Gitea branch ${branch} (${archiveBuffer.length} bytes)`);
|
||||||
|
return { archive: archiveBuffer, componentName };
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to fetch branch ${branch} from Gitea: ${instance}/${owner}/${repository} - ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try tag formats: "1.0.0" and "v1.0.0"
|
||||||
const tagVariants = [version, `v${version}`];
|
const tagVariants = [version, `v${version}`];
|
||||||
|
|
||||||
for (const tag of tagVariants) {
|
for (const tag of tagVariants) {
|
||||||
// Try to download the archive
|
// Try to download the archive
|
||||||
const archiveUrl = `https://${instance}/api/v1/repos/${owner}/${repository}/archive/${tag}.tar.gz`;
|
const archiveUrl = `https://${instance}/api/v1/repos/${owner}/${repository}/archive/${tag}.tar.gz`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[IOR Resolver] Fetching Gitea archive: ${archiveUrl}`);
|
console.log(`[IOR Resolver] Fetching Gitea archive: ${archiveUrl}`);
|
||||||
const archiveBuffer = await fetchUrlBuffer(archiveUrl);
|
const archiveBuffer = await fetchUrlBuffer(archiveUrl);
|
||||||
@@ -245,7 +300,7 @@ async function fetchFromGitea(config) {
|
|||||||
// Try next tag variant
|
// Try next tag variant
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Failed to fetch component from Gitea: ${instance}/${owner}/${repository}@${version} (tried tags: ${tagVariants.join(', ')})`);
|
throw new Error(`Failed to fetch component from Gitea: ${instance}/${owner}/${repository}@${version} (tried tags: ${tagVariants.join(', ')})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const syncFetch = require('sync-fetch');
|
||||||
const {
|
const {
|
||||||
parseRemoteIOR,
|
parseRemoteIOR,
|
||||||
parsePackageJson,
|
parsePackageJson,
|
||||||
@@ -28,6 +29,26 @@ const remoteCache = new Map();
|
|||||||
const typeCache = new Map();
|
const typeCache = new Map();
|
||||||
const processedTypes = new Set();
|
const processedTypes = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine cache TTL based on version type
|
||||||
|
*/
|
||||||
|
function determineCacheTTL(ior) {
|
||||||
|
const parts = ior.split('@');
|
||||||
|
const versionOrBranch = parts[parts.length - 1];
|
||||||
|
|
||||||
|
if (versionOrBranch.includes('-beta') || versionOrBranch.includes('-alpha')) {
|
||||||
|
return 5 * 60 * 1000; // 5 minutes for beta/alpha
|
||||||
|
} else if (versionOrBranch.includes('-rc')) {
|
||||||
|
return 60 * 60 * 1000; // 1 hour for RC
|
||||||
|
} else if (versionOrBranch.includes('/')) {
|
||||||
|
return 60 * 1000; // 1 minute for branches
|
||||||
|
} else {
|
||||||
|
return process.env.IOR_CACHE_TTL !== undefined
|
||||||
|
? parseInt(process.env.IOR_CACHE_TTL)
|
||||||
|
: 3600000; // Default 1 hour for stable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remote cache configuration
|
* Remote cache configuration
|
||||||
*/
|
*/
|
||||||
@@ -90,12 +111,13 @@ async function cacheRemoteComponent(ior, archiveBuffer, componentName) {
|
|||||||
throw new Error(`No entry point found for component ${componentName} in ${cacheDir}`);
|
throw new Error(`No entry point found for component ${componentName} in ${cacheDir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cache map with entry point
|
// Update cache map with entry point using dynamic TTL
|
||||||
|
const ttl = determineCacheTTL(ior);
|
||||||
remoteCache.set(ior, {
|
remoteCache.set(ior, {
|
||||||
path: entryPath,
|
path: entryPath,
|
||||||
directory: cacheDir,
|
directory: cacheDir,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
ttl: CACHE_TTL,
|
ttl: ttl,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[IOR Resolver] Cached remote component: ${ior}`);
|
console.log(`[IOR Resolver] Cached remote component: ${ior}`);
|
||||||
@@ -335,6 +357,131 @@ async function fetchRemoteComponent(ior) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously fetch and cache remote component
|
||||||
|
*/
|
||||||
|
function fetchRemoteComponentSync(ior, config) {
|
||||||
|
// Check cache first
|
||||||
|
const cached = getCachedRemoteComponent(ior);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[IOR Resolver] Sync fetching remote component: ${ior}`);
|
||||||
|
|
||||||
|
// Determine URL based on config
|
||||||
|
let archiveUrl;
|
||||||
|
|
||||||
|
if (config.branch) {
|
||||||
|
// Branch-based URL
|
||||||
|
if (config.type === 'github') {
|
||||||
|
archiveUrl = `https://api.github.com/repos/${config.owner}/${config.repository}/tarball/${config.branch}`;
|
||||||
|
} else if (config.type === 'gitea') {
|
||||||
|
archiveUrl = `https://${config.instance}/api/v1/repos/${config.owner}/${config.repository}/archive/${config.branch}.tar.gz`;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Branch fetching not supported for type: ${config.type}`);
|
||||||
|
}
|
||||||
|
} else if (config.version) {
|
||||||
|
// Version-based URL (try with and without 'v' prefix)
|
||||||
|
const version = config.version;
|
||||||
|
if (config.type === 'github') {
|
||||||
|
archiveUrl = `https://api.github.com/repos/${config.owner}/${config.repository}/tarball/${version}`;
|
||||||
|
} else if (config.type === 'gitea') {
|
||||||
|
archiveUrl = `https://${config.instance}/api/v1/repos/${config.owner}/${config.repository}/archive/${version}.tar.gz`;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Version fetching not supported for type: ${config.type}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`No version or branch specified in config`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Sync fetch using sync-fetch
|
||||||
|
console.log(`[IOR Resolver] Fetching from: ${archiveUrl}`);
|
||||||
|
const response = syncFetch(archiveUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'IOR-Resolver/1.0',
|
||||||
|
'Accept': 'application/octet-stream',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Try with 'v' prefix if it's a version
|
||||||
|
if (config.version && !config.version.startsWith('v')) {
|
||||||
|
const versionWithV = `v${config.version}`;
|
||||||
|
const altUrl = archiveUrl.replace(config.version, versionWithV);
|
||||||
|
|
||||||
|
console.log(`[IOR Resolver] Retrying with v prefix: ${altUrl}`);
|
||||||
|
const altResponse = syncFetch(altUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'IOR-Resolver/1.0',
|
||||||
|
'Accept': 'application/octet-stream',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (altResponse.ok) {
|
||||||
|
const buffer = Buffer.from(altResponse.arrayBuffer());
|
||||||
|
return cacheRemoteComponentSync(ior, buffer, config.componentName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(response.arrayBuffer());
|
||||||
|
return cacheRemoteComponentSync(ior, buffer, config.componentName);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[IOR Resolver] Sync fetch failed: ${err.message}`);
|
||||||
|
throw new Error(`Failed to fetch remote component: ${ior} - ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously cache remote component
|
||||||
|
*/
|
||||||
|
function cacheRemoteComponentSync(ior, archiveBuffer, componentName) {
|
||||||
|
ensureCacheDir();
|
||||||
|
|
||||||
|
const hash = generateCacheHash(ior);
|
||||||
|
const cacheDir = path.join(CACHE_DIR, hash);
|
||||||
|
|
||||||
|
// Extract archive to cache directory
|
||||||
|
try {
|
||||||
|
extractArchive(archiveBuffer, cacheDir);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[IOR Resolver] Failed to extract archive: ${err.message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the entry point
|
||||||
|
const entryPath = findEntryPoint(cacheDir, componentName);
|
||||||
|
|
||||||
|
if (!entryPath) {
|
||||||
|
throw new Error(`No entry point found for component ${componentName} in ${cacheDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache map with entry point using dynamic TTL
|
||||||
|
const ttl = determineCacheTTL(ior);
|
||||||
|
remoteCache.set(ior, {
|
||||||
|
path: entryPath,
|
||||||
|
directory: cacheDir,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
ttl: ttl,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[IOR Resolver] Sync cached remote component: ${ior}`);
|
||||||
|
|
||||||
|
// Generate type declarations asynchronously (non-blocking)
|
||||||
|
generateTypeDeclaration(ior, cacheDir).catch(err => {
|
||||||
|
console.warn(`[IOR Resolver] Type generation failed for ${ior}:`, err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update persistent mappings
|
||||||
|
updatePersistentMappings(ior, entryPath, cacheDir);
|
||||||
|
|
||||||
|
return entryPath;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced resolver function for Metro with remote support
|
* Enhanced resolver function for Metro with remote support
|
||||||
*/
|
*/
|
||||||
@@ -384,12 +531,20 @@ function createEnhancedIORResolver(projectRoot) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a remote IOR (synchronously for now)
|
// Check if it's a remote IOR - now with sync support!
|
||||||
const remoteConfig = parseRemoteIOR(moduleName);
|
const remoteConfig = parseRemoteIOR(moduleName);
|
||||||
if (remoteConfig) {
|
if (remoteConfig) {
|
||||||
throw new Error(`Remote IOR components not yet supported in sync mode: ${moduleName}`);
|
try {
|
||||||
|
const entryPath = fetchRemoteComponentSync(moduleName, remoteConfig);
|
||||||
|
return {
|
||||||
|
type: 'sourceFile',
|
||||||
|
filePath: path.resolve(entryPath),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to resolve remote IOR: ${moduleName} - ${err.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IOR not found
|
// IOR not found
|
||||||
throw new Error(`Cannot resolve IOR module: ${moduleName}`);
|
throw new Error(`Cannot resolve IOR module: ${moduleName}`);
|
||||||
};
|
};
|
||||||
@@ -599,9 +754,16 @@ module.exports = {
|
|||||||
// Main exports
|
// Main exports
|
||||||
createIORResolver: createEnhancedIORResolver,
|
createIORResolver: createEnhancedIORResolver,
|
||||||
createHotReloadableResolver: createHotReloadableEnhancedResolver,
|
createHotReloadableResolver: createHotReloadableEnhancedResolver,
|
||||||
|
|
||||||
|
// Sync functions for Metro
|
||||||
|
fetchRemoteComponentSync,
|
||||||
|
cacheRemoteComponentSync,
|
||||||
|
|
||||||
|
// Existing exports
|
||||||
buildIORMappings: buildLocalIORMappings,
|
buildIORMappings: buildLocalIORMappings,
|
||||||
buildLocalIORMappings,
|
buildLocalIORMappings,
|
||||||
fetchRemoteComponent,
|
fetchRemoteComponent,
|
||||||
parseRemoteIOR,
|
parseRemoteIOR,
|
||||||
clearRemoteCache,
|
clearRemoteCache,
|
||||||
|
determineCacheTTL,
|
||||||
};
|
};
|
||||||
@@ -56,7 +56,8 @@
|
|||||||
"author": "Metatrom",
|
"author": "Metatrom",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0",
|
||||||
|
"sync-fetch": "^0.5.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@react-native/metro-config": ">=0.80.0"
|
"@react-native/metro-config": ">=0.80.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user