feat: neo4j migrations

This commit is contained in:
dextmorgn
2026-01-24 19:08:02 +01:00
parent 531bcf970e
commit 1ce2cbd7a5
5 changed files with 548 additions and 4 deletions

View File

@@ -0,0 +1,56 @@
// 001_indexes.cypher
// Foundation indexes for query performance
// All statements are idempotent (IF NOT EXISTS)
// Index for filtering nodes by sketch_id (most common query pattern)
CREATE INDEX idx_sketch_id IF NOT EXISTS FOR (n:domain) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_email IF NOT EXISTS FOR (n:email) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_ip IF NOT EXISTS FOR (n:ip) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_phone IF NOT EXISTS FOR (n:phone) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_username IF NOT EXISTS FOR (n:username) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_organization IF NOT EXISTS FOR (n:organization) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_individual IF NOT EXISTS FOR (n:individual) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_socialaccount IF NOT EXISTS FOR (n:socialaccount) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_asn IF NOT EXISTS FOR (n:asn) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_cidr IF NOT EXISTS FOR (n:cidr) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_cryptowallet IF NOT EXISTS FOR (n:cryptowallet) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_cryptowallettransaction IF NOT EXISTS FOR (n:cryptowallettransaction) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_cryptonft IF NOT EXISTS FOR (n:cryptonft) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_website IF NOT EXISTS FOR (n:website) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_port IF NOT EXISTS FOR (n:port) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_phrase IF NOT EXISTS FOR (n:phrase) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_breach IF NOT EXISTS FOR (n:breach) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_credential IF NOT EXISTS FOR (n:credential) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_device IF NOT EXISTS FOR (n:device) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_document IF NOT EXISTS FOR (n:document) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_file IF NOT EXISTS FOR (n:file) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_malware IF NOT EXISTS FOR (n:malware) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_sslcertificate IF NOT EXISTS FOR (n:sslcertificate) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_location IF NOT EXISTS FOR (n:location) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_affiliation IF NOT EXISTS FOR (n:affiliation) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_alias IF NOT EXISTS FOR (n:alias) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_bankaccount IF NOT EXISTS FOR (n:bankaccount) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_creditcard IF NOT EXISTS FOR (n:creditcard) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_dnsrecord IF NOT EXISTS FOR (n:dnsrecord) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_gravatar IF NOT EXISTS FOR (n:gravatar) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_leak IF NOT EXISTS FOR (n:leak) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_message IF NOT EXISTS FOR (n:message) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_reputationscore IF NOT EXISTS FOR (n:reputationscore) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_riskprofile IF NOT EXISTS FOR (n:riskprofile) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_script IF NOT EXISTS FOR (n:script) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_session IF NOT EXISTS FOR (n:session) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_webtracker IF NOT EXISTS FOR (n:webtracker) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_weapon IF NOT EXISTS FOR (n:weapon) ON (n.sketch_id);
CREATE INDEX idx_sketch_id_whois IF NOT EXISTS FOR (n:whois) ON (n.sketch_id);
// Index for searching by nodeLabel (text search on common types)
CREATE INDEX idx_nodeLabel_domain IF NOT EXISTS FOR (n:domain) ON (n.nodeLabel);
CREATE INDEX idx_nodeLabel_email IF NOT EXISTS FOR (n:email) ON (n.nodeLabel);
CREATE INDEX idx_nodeLabel_ip IF NOT EXISTS FOR (n:ip) ON (n.nodeLabel);
CREATE INDEX idx_nodeLabel_phone IF NOT EXISTS FOR (n:phone) ON (n.nodeLabel);
CREATE INDEX idx_nodeLabel_username IF NOT EXISTS FOR (n:username) ON (n.nodeLabel);
CREATE INDEX idx_nodeLabel_individual IF NOT EXISTS FOR (n:individual) ON (n.nodeLabel);
CREATE INDEX idx_nodeLabel_organization IF NOT EXISTS FOR (n:organization) ON (n.nodeLabel);
CREATE INDEX idx_nodeLabel_socialaccount IF NOT EXISTS FOR (n:socialaccount) ON (n.nodeLabel);
CREATE INDEX idx_nodeLabel_website IF NOT EXISTS FOR (n:website) ON (n.nodeLabel);
CREATE INDEX idx_nodeLabel_cryptowallet IF NOT EXISTS FOR (n:cryptowallet) ON (n.nodeLabel);

View File

@@ -0,0 +1,204 @@
/**
* 003_migrate_v1_format.js
*
* Migrates nodes from V1 format to V2 format.
*
* V1 format (old):
* label: "example.com"
* type: "domain"
* created_at: "2026-01-23T18:28:46.048223+00:00"
* domain: "example.com"
* root: true
* sketch_id: "..."
* x, y: coordinates
*
* V2 format (new):
* nodeLabel: "example.com"
* nodeType: "domain"
* nodeMetadata.created_at: "2026-01-23T18:28:46.048223+00:00"
* nodeProperties.domain: "example.com"
* nodeProperties.root: true
* sketch_id: "..."
* x, y: coordinates
*
* This migration is IDEMPOTENT:
* - Only processes nodes that have V1 format (have `label` or `type` but NOT `nodeLabel`)
* - Safe to run multiple times
* - Processes in batches to handle large datasets
*/
// Reserved properties that should NOT be moved to nodeProperties
const RESERVED_PROPERTIES = new Set([
"id",
"x",
"y",
"nodeLabel",
"label",
"nodeType",
"type",
"nodeImage",
"nodeIcon",
"nodeColor",
"nodeSize",
"nodeFlag",
"nodeShape",
"nodeMetadata",
"nodeProperties",
"created_at",
"sketch_id",
]);
// Properties that are part of nodeMetadata
const METADATA_PROPERTIES = new Set(["created_at"]);
const BATCH_SIZE = 500;
/**
* Main migration function
* @param {import('neo4j-driver').Driver} driver
* @param {import('neo4j-driver').Session} session
* @param {boolean} dryRun
* @returns {Promise<string>} Summary message
*/
export async function migrate(driver, session, dryRun) {
// Count nodes needing migration (V1 format: has `type` but no `nodeType`)
const countResult = await session.run(`
MATCH (n)
WHERE n.type IS NOT NULL AND n.nodeType IS NULL
RETURN count(n) AS count
`);
const totalCount = countResult.records[0].get("count").toNumber();
if (totalCount === 0) {
return "No V1 format nodes found - nothing to migrate";
}
console.log(`[INFO] Found ${totalCount} nodes in V1 format to migrate`);
if (dryRun) {
// In dry-run, show sample of what would be migrated
const sampleResult = await session.run(`
MATCH (n)
WHERE n.type IS NOT NULL AND n.nodeType IS NULL
RETURN n, labels(n) AS labels
LIMIT 5
`);
console.log("[DRY-RUN] Sample nodes that would be migrated:");
for (const record of sampleResult.records) {
const node = record.get("n").properties;
const labels = record.get("labels");
console.log(` - [${labels.join(":")}] label="${node.label}", type="${node.type}"`);
}
return `Would migrate ${totalCount} nodes from V1 to V2 format`;
}
// Process in batches
let migratedCount = 0;
let batchNum = 0;
while (migratedCount < totalCount) {
batchNum++;
console.log(
`[INFO] Processing batch ${batchNum} (${migratedCount}/${totalCount} done)`
);
// Fetch a batch of V1 nodes
const batchResult = await session.run(
`
MATCH (n)
WHERE n.type IS NOT NULL AND n.nodeType IS NULL
RETURN elementId(n) AS elementId, n, labels(n) AS labels
LIMIT $limit
`,
{ limit: BATCH_SIZE }
);
if (batchResult.records.length === 0) {
break;
}
// Process each node in the batch
for (const record of batchResult.records) {
const elementId = record.get("elementId");
const node = record.get("n").properties;
// Build the new properties
const updates = buildV2Properties(node);
// Apply the update
await session.run(
`
MATCH (n)
WHERE elementId(n) = $elementId
SET n += $updates
REMOVE n.label, n.type, n.created_at
`,
{ elementId, updates }
);
// Remove old dynamic properties that were moved to nodeProperties
const propsToRemove = Object.keys(node).filter(
(key) =>
!RESERVED_PROPERTIES.has(key) &&
!key.startsWith("nodeProperties.") &&
!key.startsWith("nodeMetadata.")
);
if (propsToRemove.length > 0) {
// Build dynamic REMOVE clause
const removeClause = propsToRemove.map((p) => `n.\`${p}\``).join(", ");
await session.run(
`
MATCH (n)
WHERE elementId(n) = $elementId
REMOVE ${removeClause}
`,
{ elementId }
);
}
migratedCount++;
}
}
return `Migrated ${migratedCount} nodes from V1 to V2 format`;
}
/**
* Builds V2 format properties from V1 node
* @param {Record<string, any>} node - V1 node properties
* @returns {Record<string, any>} - V2 format properties to SET
*/
function buildV2Properties(node) {
const updates = {};
// Map core fields
updates.nodeLabel = node.label || node.nodeLabel || "";
updates.nodeType = node.type || node.nodeType || "";
// Handle created_at -> nodeMetadata.created_at
if (node.created_at) {
updates["nodeMetadata.created_at"] = node.created_at;
} else if (!node["nodeMetadata.created_at"]) {
// Set current timestamp if no created_at exists
updates["nodeMetadata.created_at"] = new Date().toISOString();
}
// Move non-reserved properties to nodeProperties.*
for (const [key, value] of Object.entries(node)) {
// Skip reserved properties
if (RESERVED_PROPERTIES.has(key)) continue;
// Skip properties already in nodeProperties/nodeMetadata namespace
if (key.startsWith("nodeProperties.") || key.startsWith("nodeMetadata.")) {
continue;
}
// Move to nodeProperties
updates[`nodeProperties.${key}`] = value;
}
return updates;
}

View File

@@ -13,13 +13,19 @@
"release:major": "standard-version --release-as major",
"release:patch": "standard-version --release-as patch",
"release:first": "standard-version --first-release",
"sync-version": "node scripts/sync-versions.js"
"sync-version": "node scripts/sync-versions.js",
"migrate": "node scripts/migrate.js",
"migrate:dry-run": "node scripts/migrate.js --dry-run"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
},
"dependencies": {
"dotenv": "^16.4.7",
"neo4j-driver": "^5.27.0"
},
"devDependencies": {
"husky": "^9.1.7",
"@commitlint/cli": "^20.1.0",

242
scripts/migrate.js Normal file
View File

@@ -0,0 +1,242 @@
#!/usr/bin/env node
/**
* Neo4j Migration Runner
*
* Runs migrations from neo4j-migrations/ directory in order.
* Tracks applied migrations in (:_Migration) nodes to prevent re-running.
* All migrations must be idempotent for safety.
*
* Usage:
* node scripts/migrate.js [--dry-run]
*
* Environment variables (from .env or shell):
* NEO4J_URI_BOLT - Bolt URI (default: bolt://localhost:7687)
* NEO4J_USERNAME - Username (default: neo4j)
* NEO4J_PASSWORD - Password (required)
*/
import "dotenv/config";
import neo4j from "neo4j-driver";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MIGRATIONS_DIR = path.join(__dirname, "..", "neo4j-migrations");
const config = {
uri: "bolt://localhost:7687",
user: process.env.NEO4J_USERNAME || "neo4j",
password: process.env.NEO4J_PASSWORD,
};
const isDryRun = process.argv.includes("--dry-run");
/**
* Logger with consistent formatting
*/
const log = {
info: (msg) => console.log(`[INFO] ${msg}`),
warn: (msg) => console.log(`[WARN] ${msg}`),
error: (msg) => console.error(`[ERROR] ${msg}`),
success: (msg) => console.log(`[OK] ${msg}`),
dry: (msg) => console.log(`[DRY-RUN] ${msg}`),
};
/**
* Ensures the _Migration tracking infrastructure exists
*/
async function ensureMigrationInfrastructure(session) {
await session.run(`
CREATE CONSTRAINT migration_name_unique IF NOT EXISTS
FOR (m:_Migration) REQUIRE m.name IS UNIQUE
`);
}
/**
* Gets list of already applied migrations
*/
async function getAppliedMigrations(session) {
const result = await session.run(`
MATCH (m:_Migration)
RETURN m.name AS name
ORDER BY m.name
`);
return new Set(result.records.map((r) => r.get("name")));
}
/**
* Records a migration as applied
*/
async function recordMigration(session, name) {
await session.run(
`
MERGE (m:_Migration {name: $name})
ON CREATE SET m.applied_at = datetime()
ON MATCH SET m.last_run_at = datetime()
`,
{ name },
);
}
/**
* Gets all migration files sorted by name
*/
async function getMigrationFiles() {
const files = await fs.readdir(MIGRATIONS_DIR);
return files
.filter((f) => f.endsWith(".cypher") || f.endsWith(".js"))
.filter((f) => !f.startsWith("_")) // Skip files starting with _
.sort();
}
/**
* Runs a .cypher migration file
* Splits on semicolons and runs each statement
*/
async function runCypherMigration(session, filePath, dryRun) {
const content = await fs.readFile(filePath, "utf-8");
// Split by semicolons, filter empty statements and comments-only blocks
const statements = content
.split(";")
.map((s) => s.trim())
.filter((s) => {
// Remove comment-only statements
const withoutComments = s
.split("\n")
.filter((line) => !line.trim().startsWith("//"))
.join("\n")
.trim();
return withoutComments.length > 0;
});
for (const statement of statements) {
if (dryRun) {
log.dry(`Would execute: ${statement.substring(0, 80)}...`);
} else {
await session.run(statement);
}
}
return statements.length;
}
/**
* Runs a .js migration file
* The file must export a `migrate(driver, session, dryRun)` function
*/
async function runJsMigration(driver, session, filePath, dryRun) {
const module = await import(
fileURLToPath(new URL(filePath, import.meta.url))
);
if (typeof module.migrate !== "function") {
throw new Error(`Migration ${filePath} must export a 'migrate' function`);
}
return await module.migrate(driver, session, dryRun);
}
/**
* Main migration runner
*/
async function main() {
if (!config.password) {
log.error("NEO4J_PASSWORD environment variable is required");
process.exit(1);
}
if (isDryRun) {
log.info("Running in dry-run mode - no changes will be made");
}
log.info(`Connecting to Neo4j at ${config.uri}`);
const driver = neo4j.driver(
config.uri,
neo4j.auth.basic(config.user, config.password),
);
try {
// Verify connectivity
await driver.verifyConnectivity();
log.success("Connected to Neo4j");
const session = driver.session();
try {
// Setup migration tracking
if (!isDryRun) {
await ensureMigrationInfrastructure(session);
}
// Get applied migrations
const applied = isDryRun
? new Set()
: await getAppliedMigrations(session);
if (applied.size > 0) {
log.info(`Found ${applied.size} previously applied migrations`);
}
// Get all migration files
const files = await getMigrationFiles();
log.info(`Found ${files.length} migration files`);
let appliedCount = 0;
let skippedCount = 0;
for (const file of files) {
const migrationName = file.replace(/\.(cypher|js)$/, "");
const filePath = path.join(MIGRATIONS_DIR, file);
if (applied.has(migrationName)) {
log.info(`Skipping ${file} (already applied)`);
skippedCount++;
continue;
}
log.info(`Running migration: ${file}`);
try {
if (file.endsWith(".cypher")) {
const count = await runCypherMigration(session, filePath, isDryRun);
log.success(`${file}: executed ${count} statements`);
} else if (file.endsWith(".js")) {
const result = await runJsMigration(
driver,
session,
filePath,
isDryRun,
);
log.success(`${file}: ${result || "completed"}`);
}
// Record migration as applied
if (!isDryRun) {
await recordMigration(session, migrationName);
}
appliedCount++;
} catch (err) {
log.error(`Migration ${file} failed: ${err.message}`);
throw err;
}
}
log.info("---");
log.success(
`Migration complete: ${appliedCount} applied, ${skippedCount} skipped`,
);
} finally {
await session.close();
}
} catch (err) {
log.error(`Migration failed: ${err.message}`);
process.exit(1);
} finally {
await driver.close();
}
}
main();

View File

@@ -2972,6 +2972,14 @@ buffer@^5.5.0:
base64-js "^1.3.1"
ieee754 "^1.1.13"
buffer@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
dependencies:
base64-js "^1.3.1"
ieee754 "^1.2.1"
cachedir@2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8"
@@ -4016,6 +4024,11 @@ dot-prop@^5.1.0:
dependencies:
is-obj "^2.0.0"
dotenv@^16.4.7:
version "16.6.1"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020"
integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==
dotgitignore@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/dotgitignore/-/dotgitignore-2.1.0.tgz#a4b15a4e4ef3cf383598aaf1dfa4a04bcc089b7b"
@@ -5113,7 +5126,7 @@ iconv-lite@^0.4.24:
dependencies:
safer-buffer ">= 2.1.2 < 3"
ieee754@^1.1.13:
ieee754@^1.1.13, ieee754@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
@@ -6578,6 +6591,29 @@ neo-async@^2.6.2:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
neo4j-driver-bolt-connection@5.28.3:
version "5.28.3"
resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-5.28.3.tgz#62c5a3a2b6a018fdff422ba2fa379f220d11dd7a"
integrity sha512-wqHBYcU0FVRDmdsoZ+Fk0S/InYmu9/4BT6fPYh45Jimg/J7vQBUcdkiHGU7nop7HRb1ZgJmL305mJb6g5Bv35Q==
dependencies:
buffer "^6.0.3"
neo4j-driver-core "5.28.3"
string_decoder "^1.3.0"
neo4j-driver-core@5.28.3:
version "5.28.3"
resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-5.28.3.tgz#ae1b1ab902db978396a3f4102c63b95f5b053b6e"
integrity sha512-Jk+hAmjFmO5YzVH/U7FyKXigot9zmIfLz6SZQy0xfr4zfTE/S8fOYFOGqKQTHBE86HHOWH2RbTslbxIb+XtU2g==
neo4j-driver@^5.27.0:
version "5.28.3"
resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-5.28.3.tgz#5f7a273a2e157d2c3c19abc1c5189c9c79abe44b"
integrity sha512-k7c0wEh3HoONv1v5AyLp9/BDAbYHJhz2TZvzWstSEU3g3suQcXmKEaYBfrK2UMzxcy3bCT0DrnfRbzsOW5G/Ag==
dependencies:
neo4j-driver-bolt-connection "5.28.3"
neo4j-driver-core "5.28.3"
rxjs "^7.8.2"
next-themes@^0.4.6:
version "0.4.6"
resolved "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz"
@@ -7706,7 +7742,7 @@ rw@1:
resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==
rxjs@^7.5.5:
rxjs@^7.5.5, rxjs@^7.8.2:
version "7.8.2"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b"
integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==
@@ -8065,7 +8101,7 @@ string.prototype.trimstart@^1.0.8:
define-properties "^1.2.1"
es-object-atoms "^1.0.0"
string_decoder@^1.1.1:
string_decoder@^1.1.1, string_decoder@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==