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
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ FROM node:24-bookworm-slim

RUN apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/*

ENV INXT_RUNTIME=docker

WORKDIR /app
COPY . .

Expand Down
63 changes: 51 additions & 12 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ session_alive() {
[ -n "$WHOAMI_EMAIL" ] && [ "$WHOAMI_EMAIL" = "$INXT_USER_LOWER" ]
}

relogin() {
LOGIN_OUTPUT=$(internxt login-legacy $LOGIN_ARGS --json 2>/dev/null)
}

# $1: JSON output of a CLI command run with --json
version_blocked() {
[ "$(echo "$1" | jq -r '.code // empty' 2>/dev/null)" = "CLIENT_VERSION_UNSUPPORTED" ]
}

webdav_online() {
STATUS_OUTPUT=$(internxt webdav status --json 2>/dev/null || true)
WEBDAV_STATUS=$(echo "$STATUS_OUTPUT" | jq -r '.message | split(" ") | last // empty')
Expand Down Expand Up @@ -100,29 +109,59 @@ else
AUTO_HEAL=false
fi

# $1: failure reason, prefixed to the standard stop message
stop_if_no_autoheal() {
if [ "$AUTO_HEAL" != true ]; then
echo "[keepalive] ERROR: $1 and WEBDAV_KEEPALIVE_ENABLED=false (auto-renewal disabled). Stopping container so it can be restarted."
exit 1
fi
}

# $1: JSON output carrying the version error. The flag only dedupes the log;
# checks keep running so a lifted block self-heals.
handle_version_blocked() {
if [ "$VERSION_BLOCKED" = false ]; then
echo "[keepalive] ERROR: $(echo "$1" | jq -r '.message')"
VERSION_BLOCKED=true
fi
stop_if_no_autoheal "Client version unsupported"
}

VERSION_BLOCKED=false
Comment thread
jzunigax2 marked this conversation as resolved.
Resolved

while true; do
sleep "$KEEPALIVE_INTERVAL"

if ! session_alive; then
if [ "$AUTO_HEAL" = true ]; then
if session_alive; then
VERSION_BLOCKED=false
elif version_blocked "$WHOAMI_OUTPUT"; then
handle_version_blocked "$WHOAMI_OUTPUT"
else
stop_if_no_autoheal "Session expired"
if [ "$VERSION_BLOCKED" = false ]; then
echo "[keepalive] Session expired. Re-authenticating..."
login
webdav_enable
echo "[keepalive] Session restored."
fi
if relogin; then
VERSION_BLOCKED=false
if webdav_enable; then
echo "[keepalive] Session restored."
else
echo "[keepalive] ERROR: Logged in, but the WebDAV server failed to start. Retrying in ${KEEPALIVE_INTERVAL}s."
fi
elif version_blocked "$LOGIN_OUTPUT"; then
handle_version_blocked "$LOGIN_OUTPUT"
else
echo "[keepalive] ERROR: Session expired and WEBDAV_KEEPALIVE_ENABLED=false (auto-renewal disabled). Stopping container so it can be restarted."
exit 1
echo "[keepalive] ERROR: Re-authentication failed: $(echo "$LOGIN_OUTPUT" | jq -r '.message // empty' 2>/dev/null). Retrying in ${KEEPALIVE_INTERVAL}s."
fi
fi

if ! webdav_online; then
if [ "$AUTO_HEAL" = true ]; then
echo "[keepalive] WebDAV server is not online. Re-enabling..."
webdav_enable
stop_if_no_autoheal "WebDAV server is not online"
echo "[keepalive] WebDAV server is not online. Re-enabling..."
if webdav_enable; then
echo "[keepalive] WebDAV server re-enabled."
else
echo "[keepalive] ERROR: WebDAV server is not online and WEBDAV_KEEPALIVE_ENABLED=false (auto-renewal disabled). Stopping container so it can be restarted."
exit 1
echo "[keepalive] ERROR: Failed to re-enable the WebDAV server. Retrying in ${KEEPALIVE_INTERVAL}s."
fi
fi
done
5 changes: 3 additions & 2 deletions src/commands/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ValidationService } from '../services/validation.service';
import { LoginCredentials } from '../types/command.types';
import { AuthService } from '../services/auth.service';
import { TokenStatus } from '@internxt/lib';
import { ErrorUtils } from '../utils/errors.utils';

export default class Whoami extends Command {
static readonly args = {};
Expand Down Expand Up @@ -39,8 +40,8 @@ export default class Whoami extends Command {
userCredentials.user.keys.ecc.privateKey,
);
await ConfigService.instance.saveUser(refreshedCreds);
} catch {
/* noop */
} catch (error) {
if (ErrorUtils.isClientVersionUnsupported(error)) throw error;
}
}
const message = `You are logged in as: ${userCredentials.user.email}.`;
Expand Down
7 changes: 7 additions & 0 deletions src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '../types/command.types';
import { ValidationService } from './validation.service';
import { TokenStatus } from '@internxt/lib';
import { ErrorUtils } from '../utils/errors.utils';

export class AuthService {
public static readonly instance: AuthService = new AuthService();
Expand Down Expand Up @@ -65,6 +66,7 @@ export class AuthService {
public is2FANeeded = async (email: string): Promise<boolean> => {
const authClient = SdkManager.instance.getAuth();
const securityDetails = await authClient.securityDetails(email).catch((error) => {
if (ErrorUtils.isClientVersionUnsupported(error)) throw error;
throw new Error(error.message ?? 'Login error');
});
return securityDetails.tfaEnabled;
Expand Down Expand Up @@ -125,6 +127,11 @@ export class AuthService {
return true;
}

// The session is still valid, so updating shouldn't require logging in again
if (ErrorUtils.isClientVersionUnsupported(error)) {
return false;
}

const status = this.getHttpStatusFromError(error);
if (status === undefined) {
return false;
Expand Down
4 changes: 3 additions & 1 deletion src/services/universal-link.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { LoginCredentials } from '../types/command.types';
import { ConfigService } from './config.service';
import { AuthService } from './auth.service';
import { CLIUtils, LogReporter } from '../utils/cli.utils';
import { CLIENT_VERSION_UNSUPPORTED, ErrorUtils } from '../utils/errors.utils';

export class UniversalLinkService {
public static readonly instance: UniversalLinkService = new UniversalLinkService();
Expand Down Expand Up @@ -71,8 +72,9 @@ export class UniversalLinkService {

resolve(loginCredentials);
} catch (error) {
const reason = ErrorUtils.isClientVersionUnsupported(error) ? `?reason=${CLIENT_VERSION_UNSUPPORTED}` : '';
res.writeHead(302, {
Location: `${driveUrl}/auth-link-error`,
Location: `${driveUrl}/auth-link-error${reason}`,
});
res.end();
reject(error);
Expand Down
44 changes: 24 additions & 20 deletions src/utils/cli.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import cliProgress from 'cli-progress';
import Table, { Header } from 'tty-table';
import { LoginCredentials, LoginUserDetails, NotValidFolderUuidError, PromptOptions } from '../types/command.types';
import { InquirerUtils } from './inquirer.utils';
import { ErrorUtils } from './errors.utils';
import { CLIENT_VERSION_UNSUPPORTED, ErrorUtils } from './errors.utils';
import { ValidationService } from '../services/validation.service';
import { SdkManager } from '../services/sdk-manager.service';
import { Environment } from '@internxt/inxt-js';
Expand Down Expand Up @@ -247,6 +247,21 @@ export class CLIUtils {
return megabytes / seconds;
};

private static readonly getErrorMessage = (error: Error | AppError | AxiosResponseError): string => {
let message = '';

if ('message' in error && typeof error.message === 'string' && error.message?.trim?.().length > 0) {
message = error.message;
}

const apiMessage = ErrorUtils.getResponseMessage(error);
if (apiMessage) {
message += ' [' + apiMessage + ']';
}

return message.length > 0 ? message : JSON.stringify(error);
};

static readonly catchError = ({
error,
logReporter,
Expand All @@ -260,36 +275,25 @@ export class CLIUtils {
jsonFlag?: boolean;
debugMode?: boolean;
}) => {
let message = '';
const requestId = ErrorUtils.getRequestId(error);

if ('message' in error && typeof error.message === 'string' && error.message?.trim?.().length > 0) {
message = error.message;
}

if ('data' in error) {
const errorData = error.data as { message?: string | Array<string> };
if (typeof errorData.message === 'string' && errorData.message?.trim?.().length > 0) {
message += ' [' + errorData.message + ']';
} else if (Array.isArray(errorData.message) && errorData.message.length > 0) {
message += ' [' + errorData.message.join(', ') + ']';
}
}

if (message.length === 0) {
message = JSON.stringify(error);
}
const versionUnsupported = ErrorUtils.isClientVersionUnsupported(error);
const message = versionUnsupported
? ErrorUtils.toClientVersionUnsupportedError(error).message
: CLIUtils.getErrorMessage(error);

CLIUtils.failed(jsonFlag);

const jsonLog: { success: boolean; message: string; command?: string; requestId?: string } = {
const jsonLog: { success: boolean; message: string; command?: string; requestId?: string; code?: string } = {
success: false,
message,
command,
};
if (requestId) {
jsonLog.requestId = requestId;
}
if (versionUnsupported) {
jsonLog.code = CLIENT_VERSION_UNSUPPORTED;
}
if (command) {
jsonLog.command = command;
}
Expand Down
50 changes: 50 additions & 0 deletions src/utils/errors.utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Logger } from 'winston';
import { logger } from './logger.utils';
import { DriveItemType } from '../types/drive.types';
import { UpdateUtils } from './update.utils';

export type LookupErrorKind = 'not-found' | 'auth' | 'inconclusive';

export const DEFAULT_RETRY_AFTER_SECONDS = 5;

export const CLIENT_VERSION_UNSUPPORTED = 'CLIENT_VERSION_UNSUPPORTED';

export class ErrorUtils {
static readonly isError = (error: unknown): error is Error => {
return typeof Error.isError === 'function'
Expand Down Expand Up @@ -72,6 +75,34 @@ export class ErrorUtils {
return typeof error === 'object' && error !== null && 'data' in error;
};

private static readonly getResponseBody = (error: unknown): Record<string, unknown> | undefined => {
if (!this.hasResponseBody(error)) return undefined;
const data = (error as { data: unknown }).data;
return typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : undefined;
};

static readonly getResponseMessage = (error: unknown): string | undefined => {
const message = this.getResponseBody(error)?.message;
if (typeof message === 'string' && message.trim().length > 0) return message;
if (Array.isArray(message) && message.length > 0) return message.join(', ');
return undefined;
};

static readonly isClientVersionUnsupported = (error: unknown): boolean => {
if (error instanceof ClientVersionUnsupportedError) return true;
return this.getResponseBody(error)?.errorCode === CLIENT_VERSION_UNSUPPORTED;
};

static readonly toClientVersionUnsupportedError = (error: unknown): ClientVersionUnsupportedError => {
if (error instanceof ClientVersionUnsupportedError) return error;

const recommendedVersion = this.getResponseBody(error)?.recommendedVersion;
return new ClientVersionUnsupportedError(
this.getResponseMessage(error),
typeof recommendedVersion === 'string' ? recommendedVersion : undefined,
);
};

static readonly classifyLookupError = (error: unknown): LookupErrorKind => {
const status = this.getStatusCode(error);

Expand All @@ -88,6 +119,8 @@ export class ErrorUtils {
/** Maps a failed path lookup to the error the WebDAV client should see, so only a
* confirmed absence surfaces as 404. */
static readonly toLookupError = (itemType: DriveItemType, path: string, error: unknown): Error => {
if (this.isClientVersionUnsupported(error)) return this.toClientVersionUnsupportedError(error);

const label = itemType === 'file' ? 'File' : 'Folder';
switch (this.classifyLookupError(error)) {
case 'auth':
Expand Down Expand Up @@ -182,6 +215,23 @@ export class BadGatewayError extends Error {
}
}

export class ClientVersionUnsupportedError extends Error {
public statusCode = 403;

constructor(apiMessage?: string, recommendedVersion?: string) {
const recommended = recommendedVersion ? `, recommended version: ${recommendedVersion}` : '';
super(
[
apiMessage ?? 'This version of the Internxt CLI is no longer supported.',
`Installed version: ${UpdateUtils.getCurrentVersion()}${recommended}.`,
UpdateUtils.getUpdateInstructions(),
].join(' '),
);
this.name = 'ClientVersionUnsupportedError';
Object.setPrototypeOf(this, ClientVersionUnsupportedError.prototype);
}
}

export class BadRequestError extends Error {
public statusCode = 400;

Expand Down
13 changes: 13 additions & 0 deletions src/utils/update.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import packageJson from '../../package.json';

export class UpdateUtils {
static readonly isDockerRuntime = (): boolean => process.env.INXT_RUNTIME === 'docker';

static readonly getCurrentVersion = (): string => packageJson.version;

static readonly getUpdateInstructions = (): string => {
return this.isDockerRuntime()
? 'Pull the latest image with: docker pull internxt/webdav:latest'
: 'Update with: npm install -g @internxt/cli@latest';
};
}
1 change: 1 addition & 0 deletions src/webdav/handlers/PUT.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { UploadUtils } from '../../utils/upload.utils';

export class PUTRequestHandler implements WebDavMethodHandler {
handle = async (req: Request, res: Response) => {

Check failure on line 17 in src/webdav/handlers/PUT.handler.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=internxt_cli&issues=AaGneSsj7KsIerDK9OKs&open=AaGneSsj7KsIerDK9OKs&pullRequest=738
let contentLength = Number(req.headers['content-length']);
if (!contentLength || Number.isNaN(contentLength) || contentLength <= 0) {
contentLength = 0;
Expand Down Expand Up @@ -113,6 +113,7 @@
try {
file = await DriveFileService.instance.replaceFile(driveFileItem.uuid, filePayload);
} catch (error) {
if (ErrorUtils.isClientVersionUnsupported(error)) throw error;
webdavLogger.warn(
ErrorUtils.withRequestId(
`[PUT] File replace failed for '${resource.url}', falling back to delete and create: ${
Expand Down
5 changes: 5 additions & 0 deletions src/webdav/middewares/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export const AuthMiddleware = (): RequestHandler => {
CacheService.instance.set(CacheService.AUTH_CACHE_KEY, authDetails);
next();
} catch (error) {
if (ErrorUtils.isClientVersionUnsupported(error)) {
next(error);
return;
}

let message = 'Authentication required to access this resource.';
if (ErrorUtils.isError(error)) {
message = error.message;
Expand Down
Loading
Loading