Compare commits
15 Commits
feat/preac
...
b1957f2c19
| Author | SHA1 | Date | |
|---|---|---|---|
| b1957f2c19 | |||
| efc9e5c3dd | |||
| 65c6946e7f | |||
| 3129fa6531 | |||
| 82da27cf8d | |||
| c4dce04d42 | |||
| c2b3356022 | |||
| d067e0b95c | |||
| aec7a331c6 | |||
| 0d636ab680 | |||
| 69c6c6fe09 | |||
| fd552c48cd | |||
| 95b7be5219 | |||
| b8e1468a46 | |||
| 199d92f733 |
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
.claude/
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
name: Build and Release
|
name: Build and Release to Staging
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY_HOST: git.ivanch.me
|
||||||
|
REGISTRY_USERNAME: ivanch
|
||||||
|
IMAGE_NAME: ${{ env.REGISTRY_HOST }}/ivanch/vision-start
|
||||||
|
IMAGE_TAG: staging
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
name: Build Vision Start
|
||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-amd64
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -16,3 +24,47 @@ jobs:
|
|||||||
run: npm install
|
run: npm install
|
||||||
- name: Run build
|
- name: Run build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
build_vision_start:
|
||||||
|
name: Build Vision Start Image
|
||||||
|
runs-on: ubuntu-amd64
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" \
|
||||||
|
| docker login "${{ env.REGISTRY_HOST }}" \
|
||||||
|
-u "${{ env.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and Push Multi-Arch Image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
|
||||||
|
|
||||||
|
deploy_vision_start:
|
||||||
|
name: Deploy Vision Start (staging)
|
||||||
|
runs-on: ubuntu-amd64
|
||||||
|
needs: build_vision_start
|
||||||
|
steps:
|
||||||
|
- name: Recreate Container
|
||||||
|
uses: appleboy/ssh-action@v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.HOST }}
|
||||||
|
username: ${{ secrets.USERNAME }}
|
||||||
|
key: ${{ secrets.KEY }}
|
||||||
|
port: ${{ secrets.PORT }}
|
||||||
|
script: |
|
||||||
|
cd ${{ secrets.STAGING_DIR }}
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- v*
|
- v*
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY_HOST: git.ivanch.me
|
||||||
|
REGISTRY_USERNAME: ivanch
|
||||||
|
IMAGE_NAME: ${{ env.REGISTRY_HOST }}/ivanch/vision-start
|
||||||
|
IMAGE_TAG: latest
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -20,7 +26,7 @@ jobs:
|
|||||||
- name: Run build
|
- name: Run build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Prepare release
|
- name: Prepare release
|
||||||
run: |
|
run: |
|
||||||
bash scripts/prepare_release.sh
|
bash scripts/prepare_release.sh
|
||||||
mv dist vision-start/
|
mv dist vision-start/
|
||||||
mv manifest.json vision-start/
|
mv manifest.json vision-start/
|
||||||
@@ -53,19 +59,17 @@ jobs:
|
|||||||
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
|
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
|
||||||
VIRUS_TOTAL_FILE: vision-start-${{ gitea.ref_name }}.zip
|
VIRUS_TOTAL_FILE: vision-start-${{ gitea.ref_name }}.zip
|
||||||
run: |
|
run: |
|
||||||
# Run the VirusTotal check script and capture output
|
# Run the VirusTotal check script and capture output in real-time
|
||||||
bash scripts/check_virustotal.sh > vt_output.txt 2>&1
|
set -o pipefail
|
||||||
|
bash scripts/check_virustotal.sh 2>&1 | tee vt_output.txt
|
||||||
|
|
||||||
# Extract analysis URL and detection ratio from output
|
# Extract analysis URL and detection ratio from output
|
||||||
ANALYSIS_URL=$(grep "Analysis URL:" vt_output.txt | cut -d' ' -f3- || echo "Not available")
|
ANALYSIS_URL=$(grep "Analysis URL:" vt_output.txt | cut -d' ' -f3- || echo "Not available")
|
||||||
DETECTION_RATIO=$(grep "Detection ratio:" vt_output.txt | cut -d' ' -f3- || echo "Not available")
|
DETECTION_RATIO=$(grep "Detection ratio:" vt_output.txt | cut -d' ' -f3- || echo "Not available")
|
||||||
|
|
||||||
# Set outputs for next job
|
# Set outputs for next job
|
||||||
echo "analysis-url=$ANALYSIS_URL" >> $GITEA_OUTPUT
|
echo "analysis-url=$ANALYSIS_URL" >> $GITEA_OUTPUT
|
||||||
echo "detection-ratio=$DETECTION_RATIO" >> $GITEA_OUTPUT
|
echo "detection-ratio=$DETECTION_RATIO" >> $GITEA_OUTPUT
|
||||||
|
|
||||||
# Display the full output
|
|
||||||
cat vt_output.txt
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -82,9 +86,53 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
body: |
|
body: |
|
||||||
This is the release for version ${{ gitea.ref_name }}.
|
This is the release for version ${{ gitea.ref_name }}.
|
||||||
|
|
||||||
**Virus Total Analysis URL:** ${{ needs.virus-total-check.outputs.analysis-url }}
|
**Virus Total Analysis URL:** ${{ needs.virus-total-check.outputs.analysis-url }}
|
||||||
**Virus Total Detection Ratio:** ${{ needs.virus-total-check.outputs.detection-ratio }}
|
**Virus Total Detection Ratio:** ${{ needs.virus-total-check.outputs.detection-ratio }}
|
||||||
name: ${{ gitea.ref_name }}
|
name: ${{ gitea.ref_name }}
|
||||||
tag_name: ${{ gitea.ref_name }}
|
tag_name: ${{ gitea.ref_name }}
|
||||||
files: vision-start-${{ gitea.ref_name }}.zip
|
files: vision-start-${{ gitea.ref_name }}.zip
|
||||||
|
|
||||||
|
build_vision_start:
|
||||||
|
name: Build Vision Start Image
|
||||||
|
runs-on: ubuntu-amd64
|
||||||
|
needs: [build, virus-total-check]
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" \
|
||||||
|
| docker login "${{ env.REGISTRY_HOST }}" \
|
||||||
|
-u "${{ env.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and Push Multi-Arch Image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
|
||||||
|
|
||||||
|
deploy_vision_start:
|
||||||
|
name: Deploy Vision Start (production)
|
||||||
|
runs-on: ubuntu-amd64
|
||||||
|
needs: build_vision_start
|
||||||
|
steps:
|
||||||
|
- name: Recreate Container
|
||||||
|
uses: appleboy/ssh-action@v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.HOST }}
|
||||||
|
username: ${{ secrets.USERNAME }}
|
||||||
|
key: ${{ secrets.KEY }}
|
||||||
|
port: ${{ secrets.PORT }}
|
||||||
|
script: |
|
||||||
|
cd ${{ secrets.PROD_DIR }}
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN sh scripts/prepare_release.sh
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY manifest.json /usr/share/nginx/html/
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -7,6 +7,26 @@ import Dropdown from './Dropdown';
|
|||||||
import { baseWallpapers } from './utils/baseWallpapers';
|
import { baseWallpapers } from './utils/baseWallpapers';
|
||||||
import { addWallpaperToChromeStorageLocal, removeWallpaperFromChromeStorageLocal, checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
|
import { addWallpaperToChromeStorageLocal, removeWallpaperFromChromeStorageLocal, checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
|
||||||
|
|
||||||
|
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
|
||||||
|
|
||||||
|
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
||||||
|
|
||||||
|
const safeParse = (value: string | null): unknown => {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toStorageString = (value: unknown): string => {
|
||||||
|
return typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
};
|
||||||
|
|
||||||
interface ConfigurationModalProps {
|
interface ConfigurationModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (config: any) => void;
|
onSave: (config: any) => void;
|
||||||
@@ -51,6 +71,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const importInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isSaving = useRef(false);
|
const isSaving = useRef(false);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
@@ -236,6 +257,86 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExportConfig = () => {
|
||||||
|
const exportPayload = {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
requiredLocalStorageKeys: [...REQUIRED_LOCAL_STORAGE_KEYS],
|
||||||
|
localStorage: REQUIRED_LOCAL_STORAGE_KEYS.reduce((acc, key) => {
|
||||||
|
acc[key] = safeParse(localStorage.getItem(key));
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<RequiredLocalStorageKey, unknown>),
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(exportPayload, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `vision-start-config-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportClick = () => {
|
||||||
|
importInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileContent = await file.text();
|
||||||
|
const parsed = JSON.parse(fileContent);
|
||||||
|
const localStorageData = parsed?.localStorage && typeof parsed.localStorage === 'object'
|
||||||
|
? parsed.localStorage
|
||||||
|
: parsed;
|
||||||
|
|
||||||
|
if (!localStorageData || typeof localStorageData !== 'object') {
|
||||||
|
throw new Error('Invalid import file format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let importedAny = false;
|
||||||
|
|
||||||
|
REQUIRED_LOCAL_STORAGE_KEYS.forEach((key) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(localStorageData, key)) {
|
||||||
|
const rawValue = (localStorageData as Record<string, unknown>)[key];
|
||||||
|
localStorage.setItem(key, toStorageString(rawValue));
|
||||||
|
importedAny = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!importedAny) {
|
||||||
|
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const importedConfig = (localStorageData as Record<string, unknown>).config;
|
||||||
|
const importedUserWallpapers = (localStorageData as Record<string, unknown>).userWallpapers;
|
||||||
|
|
||||||
|
if (importedConfig && typeof importedConfig === 'object') {
|
||||||
|
setConfig(importedConfig as typeof config);
|
||||||
|
onWallpaperChange({ currentWallpapers: (importedConfig as { currentWallpapers?: string[] }).currentWallpapers || [] });
|
||||||
|
onSave(importedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(importedUserWallpapers)) {
|
||||||
|
setUserWallpapers(importedUserWallpapers as Wallpaper[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Configuration imported successfully. The page will reload to apply all data.');
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Could not import configuration. Please use a valid export JSON file.');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const allWallpapers = [...baseWallpapers, ...userWallpapers];
|
const allWallpapers = [...baseWallpapers, ...userWallpapers];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -641,13 +742,30 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-8 border-t border-white/10">
|
<div className="p-8 border-t border-white/10">
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleExportConfig} className="bg-slate-700 hover:bg-slate-600 active:scale-95 text-white text-sm font-semibold py-1.5 px-3 rounded-lg transition-all duration-150 ease-ios">
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
<button onClick={handleImportClick} className="bg-slate-700 hover:bg-slate-600 active:scale-95 text-white text-sm font-semibold py-1.5 px-3 rounded-lg transition-all duration-150 ease-ios">
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={importInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImportConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
<button onClick={() => { isSaving.current = true; onSave(config); }} className="bg-green-500 hover:bg-green-400 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios">
|
<button onClick={() => { isSaving.current = true; onSave(config); }} className="bg-green-500 hover:bg-green-400 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios">
|
||||||
Save & Close
|
Save & Close
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleClose} className="bg-gray-600 hover:bg-gray-500 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios">
|
<button onClick={handleClose} className="bg-gray-600 hover:bg-gray-500 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -655,4 +773,4 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ConfigurationModal;
|
export default ConfigurationModal;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ set -e
|
|||||||
# Configuration
|
# Configuration
|
||||||
FILE_PATH="${VIRUS_TOTAL_FILE:-vision-start.zip}"
|
FILE_PATH="${VIRUS_TOTAL_FILE:-vision-start.zip}"
|
||||||
API_KEY="${virustotal_apikey}"
|
API_KEY="${virustotal_apikey}"
|
||||||
BASE_URL="https://www.virustotal.com/vtapi/v2"
|
BASE_URL="https://www.virustotal.com/api/v3"
|
||||||
|
|
||||||
# Check if API key is set
|
# Check if API key is set
|
||||||
if [ -z "$API_KEY" ]; then
|
if [ -z "$API_KEY" ]; then
|
||||||
@@ -38,12 +38,12 @@ echo "Uploading $FILE_PATH to VirusTotal for analysis..."
|
|||||||
|
|
||||||
# Upload file to VirusTotal
|
# Upload file to VirusTotal
|
||||||
UPLOAD_RESPONSE=$(curl -s -X POST \
|
UPLOAD_RESPONSE=$(curl -s -X POST \
|
||||||
-F "apikey=$API_KEY" \
|
-H "x-apikey: $API_KEY" \
|
||||||
-F "file=@$FILE_PATH" \
|
-F "file=@$FILE_PATH" \
|
||||||
"$BASE_URL/file/scan")
|
"$BASE_URL/files")
|
||||||
|
|
||||||
# Extract scan_id from response
|
# Extract scan_id from response
|
||||||
SCAN_ID=$(echo "$UPLOAD_RESPONSE" | jq -r '.scan_id')
|
SCAN_ID=$(echo "$UPLOAD_RESPONSE" | jq -r '.data.id')
|
||||||
|
|
||||||
if [ "$SCAN_ID" == "null" ] || [ -z "$SCAN_ID" ]; then
|
if [ "$SCAN_ID" == "null" ] || [ -z "$SCAN_ID" ]; then
|
||||||
echo "Error: Failed to upload file or get scan ID"
|
echo "Error: Failed to upload file or get scan ID"
|
||||||
@@ -55,54 +55,54 @@ echo "File uploaded successfully. Scan ID: $SCAN_ID"
|
|||||||
echo "Waiting for analysis to complete..."
|
echo "Waiting for analysis to complete..."
|
||||||
|
|
||||||
# Wait for analysis to complete and get results
|
# Wait for analysis to complete and get results
|
||||||
MAX_ATTEMPTS=30
|
MAX_ATTEMPTS=60
|
||||||
ATTEMPT=0
|
ATTEMPT=0
|
||||||
SLEEP_INTERVAL=10
|
SLEEP_INTERVAL=10
|
||||||
|
|
||||||
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
|
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
|
||||||
echo "Checking analysis status (attempt $((ATTEMPT + 1))/$MAX_ATTEMPTS)..."
|
echo "Checking analysis status (attempt $((ATTEMPT + 1))/$MAX_ATTEMPTS)..."
|
||||||
|
|
||||||
# Get scan report
|
# Get scan report
|
||||||
REPORT_RESPONSE=$(curl -s -X POST \
|
REPORT_RESPONSE=$(curl -s -X GET \
|
||||||
-d "apikey=$API_KEY" \
|
-H "x-apikey: $API_KEY" \
|
||||||
-d "resource=$SCAN_ID" \
|
"$BASE_URL/analyses/$SCAN_ID")
|
||||||
"$BASE_URL/file/report")
|
|
||||||
|
|
||||||
# Check if analysis is complete
|
# Check if analysis is complete
|
||||||
RESPONSE_CODE=$(echo "$REPORT_RESPONSE" | jq -r '.response_code')
|
RESPONSE_CODE=$(echo "$REPORT_RESPONSE" | jq -r '.data.attributes.status')
|
||||||
|
|
||||||
if [ "$RESPONSE_CODE" == "1" ]; then
|
if [ "$RESPONSE_CODE" == "completed" ]; then
|
||||||
# Analysis complete
|
# Analysis complete
|
||||||
echo "Analysis completed!"
|
echo "Analysis completed!"
|
||||||
|
|
||||||
# Extract results
|
# Extract results
|
||||||
POSITIVES=$(echo "$REPORT_RESPONSE" | jq -r '.positives')
|
POSITIVES=$(echo "$REPORT_RESPONSE" | jq -r '.data.attributes.stats.malicious')
|
||||||
TOTAL=$(echo "$REPORT_RESPONSE" | jq -r '.total')
|
SUSPICIOUS=$(echo "$REPORT_RESPONSE" | jq -r '.data.attributes.stats.suspicious')
|
||||||
PERMALINK=$(echo "$REPORT_RESPONSE" | jq -r '.permalink')
|
# The v3 analyses object has no 'total' field — compute it by summing all stat categories
|
||||||
|
TOTAL=$(echo "$REPORT_RESPONSE" | jq '[.data.attributes.stats | to_entries[].value] | add')
|
||||||
|
ANALYSIS_ID=$(echo "$REPORT_RESPONSE" | jq -r '.data.id')
|
||||||
|
PERMALINK="https://www.virustotal.com/gui/file-analysis/${ANALYSIS_ID}"
|
||||||
|
|
||||||
echo "Analysis URL: $PERMALINK"
|
echo "Analysis URL: $PERMALINK"
|
||||||
echo "Detection ratio: $POSITIVES/$TOTAL"
|
echo "Detection ratio: $POSITIVES/$TOTAL"
|
||||||
|
|
||||||
# Check if file is safe
|
# Check if file is safe
|
||||||
if [ "$POSITIVES" -eq 0 ]; then
|
if [ "$POSITIVES" -eq 0 ] && [ "$SUSPICIOUS" -eq 0 ]; then
|
||||||
echo "✅ File is clean (no threats detected)"
|
echo "✅ File is clean (no threats detected)"
|
||||||
exit 0
|
exit 0
|
||||||
else
|
else
|
||||||
echo "❌ File contains threats ($POSITIVES detections out of $TOTAL scanners)"
|
echo "❌ File flagged: $POSITIVES malicious, $SUSPICIOUS suspicious (out of $TOTAL scanners)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
elif [ "$RESPONSE_CODE" == "0" ]; then
|
elif [ "$RESPONSE_CODE" == "queued" ]; then
|
||||||
# File not found or analysis not complete yet
|
|
||||||
echo "Analysis still in progress..."
|
|
||||||
elif [ "$RESPONSE_CODE" == "-2" ]; then
|
|
||||||
# Still queued for analysis
|
|
||||||
echo "File still queued for analysis..."
|
echo "File still queued for analysis..."
|
||||||
|
elif [ "$RESPONSE_CODE" == "in-progress" ]; then
|
||||||
|
echo "Analysis still in progress..."
|
||||||
else
|
else
|
||||||
echo "Unexpected response code: $RESPONSE_CODE"
|
echo "Unexpected response code: $RESPONSE_CODE"
|
||||||
echo "Response: $REPORT_RESPONSE"
|
echo "Response: $REPORT_RESPONSE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ATTEMPT=$((ATTEMPT + 1))
|
ATTEMPT=$((ATTEMPT + 1))
|
||||||
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
|
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
|
||||||
sleep $SLEEP_INTERVAL
|
sleep $SLEEP_INTERVAL
|
||||||
|
|||||||
Reference in New Issue
Block a user