Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/dappmanager/src/api/startHttpApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { EventBus } from "@dappnode/eventbus";
import { subscriptionsFactory } from "@dappnode/common";
import { RpcPayload, RpcResponse, LoggerMiddleware, Routes } from "@dappnode/types";
import { getRpcHandler } from "./handler/index.js";
import { params as dappnodeParams } from "@dappnode/params";

export interface HttpApiParams extends ClientSideCookiesParams, AuthPasswordSessionParams {
AUTH_IP_ALLOW_LOCAL_IP: boolean;
Expand Down Expand Up @@ -98,6 +99,9 @@ export function startHttpApi({
app.use(bodyParser.json());
app.use(bodyParser.text());
app.use(bodyParser.urlencoded({ extended: true }));
// Serve locally-downloaded package avatars (non-core from REPO_DIR, core from DNCORE_DIR)
app.use("/avatars", express.static(path.resolve(dappnodeParams.avatarStaticDir), { maxAge: "1d" }));
app.use("/avatars", express.static(path.resolve(dappnodeParams.coreAvatarStaticDir), { maxAge: "1d" }));
// Intercept UI requests. Must go before express.static
app.use(counterViewsMiddleware);
// Express uses "ETags" (hashes of the files requested) to know when the file changed
Expand Down
29 changes: 23 additions & 6 deletions packages/dockerApi/src/list/parseContainerInfo.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import fs from "fs";
import { ContainerInfo } from "dockerode";
import { params } from "@dappnode/params";
import { PackageContainer, VolumeMapping, ContainerState } from "@dappnode/types";
import { parsePortMappings, parseVolumeMappings, readContainerLabels } from "@dappnode/dockercompose";
import { parseExitCodeFromStatus } from "./parseExitCodeFromStatus.js";
import { ensureUniquePortsFromDockerApi } from "../utils.js";
import { fileToGatewayUrl, normalizeHash, parseEnvironment } from "@dappnode/utils";
import { fileToGatewayUrl, getAvatarPath, normalizeHash, parseEnvironment } from "@dappnode/utils";

const CONTAINER_NAME_PREFIX = params.CONTAINER_NAME_PREFIX;
const CONTAINER_CORE_NAME_PREFIX = params.CONTAINER_CORE_NAME_PREFIX;
Expand All @@ -31,6 +32,9 @@ export function parseContainerInfo(container: ContainerInfo): PackageContainer {
// NOTE: /containers/json will always return Aliases: null even if there are aliases
// aliases: network.Aliases || []
}));
// Declared here for reusability purposes
const isDnp = Boolean(labels.dnpName) || containerName.includes(CONTAINER_NAME_PREFIX);
const isCore = typeof labels.isCore === "boolean" ? labels.isCore : containerName.includes(CONTAINER_CORE_NAME_PREFIX);

return {
// Identification
Expand All @@ -40,8 +44,8 @@ export function parseContainerInfo(container: ContainerInfo): PackageContainer {
instanceName: labels.instanceName || "",
dnpName,
version: labels.version || (container.Image || "").split(":")[1] || "0.0.0",
isDnp: Boolean(labels.dnpName) || containerName.includes(CONTAINER_NAME_PREFIX),
isCore: typeof labels.isCore === "boolean" ? labels.isCore : containerName.includes(CONTAINER_CORE_NAME_PREFIX),
isDnp,
isCore,

// Docker data
created: container.Created,
Expand All @@ -65,7 +69,7 @@ export function parseContainerInfo(container: ContainerInfo): PackageContainer {

// Additional package metadata to avoid having to read the manifest
dependencies: labels.dependencies || {},
avatarUrl: labels.avatar ? resolveAvatarUrl(labels.avatar) : "",
avatarUrl: resolveAvatarUrl(labels.avatar, dnpName, isCore),
origin: labels.origin,
chain: labels.chain,
canBeFullnode: allowedFullnodeDnpNames.includes(dnpName),
Expand Down Expand Up @@ -101,10 +105,23 @@ export function parseDnpNameFromContainerName(containerName: string): string {

/**
* Resolves an avatar label value to a usable URL.
* Accepts either an IPFS multiaddress ("/ipfs/Qm...") or a plain HTTP URL.
* Prefers a locally-downloaded avatar file when one exists on disk.
* Otherwise falls back to the remote IPFS gateway or HTTP mirror URL.
* @param avatar - The raw Docker label value ("/ipfs/Qm..." or HTTP URL). May be empty.
* @param dnpName - Package name, used to locate the local avatar file.
* @param isCore - Whether the package is a core DAppNode package.
* @returns A URL string that can be used to fetch the avatar image.
*/
export function resolveAvatarUrl(avatar: string): string {
export function resolveAvatarUrl(avatar: string | undefined, dnpName: string, isCore: boolean): string {
// Prefer locally-downloaded avatar if it exists on disk
try {
const localPath = getAvatarPath(dnpName, isCore);
if (fs.existsSync(localPath)) return `/avatars/${dnpName}.png`;
} catch {
// Ignore filesystem errors — fall through to remote resolution
}

if (!avatar) return "";
if (avatar.startsWith("http")) return avatar; // Avatar URL is already an HTTP URL. Package was likely installed from mirror
return fileToGatewayUrl({ source: "ipfs", hash: normalizeHash(avatar), size: 0 }); // Convert IPFS multiaddress to a gateway URL.
}
2 changes: 2 additions & 0 deletions packages/installer/src/calls/packageInstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getInstallerPackagesData } from "../installer/getInstallerPackageData.j
import createVolumeDevicePaths from "../installer/createVolumeDevicePaths.js";
// Utils
import {
downloadAvatars,
downloadImages,
loadImages,
flagPackagesAreInstalling,
Expand Down Expand Up @@ -86,6 +87,7 @@ export async function packageInstall(
flagPackagesAreInstalling(dnpNames);

await downloadImages(dappnodeInstaller, packagesData, log);
await downloadAvatars(dappnodeInstaller, packagesData, log);
await loadImages(packagesData, log);

await createVolumeDevicePaths(packagesData);
Expand Down
12 changes: 11 additions & 1 deletion packages/installer/src/calls/packageRemove.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from "fs";
import { eventBus } from "@dappnode/eventbus";
import { params } from "@dappnode/params";
import { getRepoDirPath, getDockerComposePath, shell } from "@dappnode/utils";
import { getRepoDirPath, getDockerComposePath, getAvatarPath, shell } from "@dappnode/utils";
import { logs } from "@dappnode/logger";
import {
getDockerTimeoutMax,
Expand Down Expand Up @@ -97,6 +97,16 @@ export async function packageRemove({
// Remove DNP folder and files
if (fs.existsSync(packageRepoDir)) await shell(`rm -r ${packageRepoDir}`);

// Remove cached avatar:
// If we get here, the container and repo are already removed, so even if this fails
// the package is effectively removed.
try {
const avatarPath = getAvatarPath(dnp.dnpName, dnp.isCore);
if (fs.existsSync(avatarPath)) fs.unlinkSync(avatarPath);
} catch (e) {
logs.debug(`Failed to remove avatar for ${dnp.dnpName}: ${e}`);
}

// Emit packages update
eventBus.requestPackages.emit();
eventBus.packagesModified.emit({ dnpNames: [dnp.dnpName], removed: true });
Expand Down
63 changes: 63 additions & 0 deletions packages/installer/src/installer/downloadAvatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import fs from "fs";
import path from "path";
import { DistributedFile, InstallPackageData } from "@dappnode/types";
import { logs, Log } from "@dappnode/logger";
import { getAvatarPath, validatePath } from "@dappnode/utils";
import { DappnodeInstaller } from "../dappnodeInstaller.js";

/**
* Downloads the avatar PNG for each package that has an avatarFile and saves it
* to the local avatars directory.
* Core packages → DNCORE_DIR/avatars/, non-core → REPO_DIR/avatars/.
*
* This is a best-effort operation — failures are logged but never block installation.
*/
export async function downloadAvatars(
dappnodeInstaller: DappnodeInstaller,
packagesData: InstallPackageData[],
log: Log
): Promise<void> {
await Promise.all(
packagesData.map(async (pkg) => {
const { dnpName, isCore, avatarFile } = pkg;
if (!avatarFile) return;

try {
await downloadAvatar(dappnodeInstaller, dnpName, isCore, avatarFile);
log(dnpName, "Avatar saved locally");
} catch (e) {
// Avatar download must never block installation
logs.debug(`Failed to download avatar for ${dnpName}: ${e.message}`);
}
})
);
}

/**
* Downloads a single avatar file to the local avatars directory.
*/
async function downloadAvatar(
dappnodeInstaller: DappnodeInstaller,
dnpName: string,
isCore: boolean,
avatarFile: DistributedFile
): Promise<void> {
const avatarPath = getAvatarPath(dnpName, isCore);

// Ensure the avatars directory exists
const avatarDir = path.dirname(avatarPath);
fs.mkdirSync(avatarDir, { recursive: true });

// Validate target path
validatePath(avatarPath);

const { hash, size, filename, packageHash } = avatarFile;

await dappnodeInstaller.writeFileToFs({
hash,
path: avatarPath,
fileSize: size,
filename,
packageHash
});
}
1 change: 1 addition & 0 deletions packages/installer/src/installer/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./afterInstall.js";
export * from "./downloadAvatar.js";
export * from "./downloadImages.js";
export * from "./loadImages.js";
export * from "./packageIsInstalling.js";
Expand Down
1 change: 1 addition & 0 deletions packages/params/src/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const params = {
userActionLogsFilename: path.join(DNCORE_DIR, "userActionLogs.log"),
// Static files serve
avatarStaticDir: path.join(REPO_DIR, "avatars"),
coreAvatarStaticDir: path.join(DNCORE_DIR, "avatars"),
// lowdb requires an absolute path
DB_MAIN_PATH: path.resolve(DNCORE_DIR, "maindb.json"),
DB_CACHE_PATH: path.resolve(DNCORE_DIR, "dappmanagerdb.json"),
Expand Down
14 changes: 14 additions & 0 deletions packages/utils/src/getAvatarPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import path from "path";
import { params } from "@dappnode/params";

/**
* Returns the local filesystem path where the avatar for a package should be stored.
* Core packages store avatars under DNCORE_DIR/avatars/, non-core under REPO_DIR/avatars/.
* @param dnpName - e.g. "bitcoin.dnp.dappnode.eth"
* @param isCore - whether the package is a core package
* @returns e.g. "/usr/src/app/dnp_repo/avatars/bitcoin.dnp.dappnode.eth.png"
*/
export function getAvatarPath(dnpName: string, isCore: boolean): string {
const dir = isCore ? params.coreAvatarStaticDir : params.avatarStaticDir;
return path.join(dir, `${dnpName}.png`);
}
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export { parseEnvironment, stringifyEnvironment, mergeEnvs } from "./environment
export { writeEnvFile, createGlobalEnvsEnvFile } from "./globalEnvs.js";
export { getManifestPath } from "./getManifestPath.js";
export { getImagePath } from "./getImagePath.js";
export { getAvatarPath } from "./getAvatarPath.js";
export { getIsMonoService } from "./getIsMonoService.js";
export { getEnvFilePath } from "./getEnvFilePath.js";
export { getBackupPath } from "./getBackupPath.js";
Expand Down
Loading