Compare commits
10 Commits
65623e486f
...
f843270aab
| Author | SHA1 | Date | |
|---|---|---|---|
| f843270aab | |||
| 5c23a347ad | |||
| e692423da2 | |||
| fdc66efe1b | |||
| 69dfdce4f5 | |||
| 668f48897f | |||
| 8e7f5bc920 | |||
| a54d57a7c7 | |||
| 5acbf697fa | |||
| 3b0fea6379 |
143
.gitea/workflows/ci.yml
Normal file
143
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.example.com
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: gitea-job-image
|
||||||
|
steps:
|
||||||
|
- name: Clone repo
|
||||||
|
run: |
|
||||||
|
rm -rf $GITHUB_WORKSPACE/*
|
||||||
|
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||||
|
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Run ruff (Python lint)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f pyproject.toml ]]; then
|
||||||
|
pip3 install ruff
|
||||||
|
ruff check .
|
||||||
|
else
|
||||||
|
echo "No Python project detected, skipping ruff"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run npm lint (JS/TS)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f package.json ]]; then
|
||||||
|
npm ci
|
||||||
|
npm run lint --if-present || true
|
||||||
|
else
|
||||||
|
echo "No Node.js project detected, skipping npm lint"
|
||||||
|
fi
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: gitea-job-image
|
||||||
|
steps:
|
||||||
|
- name: Clone repo
|
||||||
|
run: |
|
||||||
|
rm -rf $GITHUB_WORKSPACE/*
|
||||||
|
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||||
|
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Run pytest (Python)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f pyproject.toml ]]; then
|
||||||
|
python3 -m pip install --upgrade pip
|
||||||
|
pip3 install -e ".[dev]" 2>/dev/null || pip3 install -e . 2>/dev/null || true
|
||||||
|
pip3 install pytest
|
||||||
|
pytest tests/ -v --tb=short 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo "No Python project detected, skipping pytest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run npm test (JS/TS)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f package.json ]]; then
|
||||||
|
npm ci
|
||||||
|
npm run test --if-present || true
|
||||||
|
else
|
||||||
|
echo "No Node.js project detected, skipping npm test"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run Go tests
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f go.mod ]]; then
|
||||||
|
go test ./...
|
||||||
|
else
|
||||||
|
echo "No Go project detected, skipping go test"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Clone repo
|
||||||
|
run: |
|
||||||
|
rm -rf $GITHUB_WORKSPACE/*
|
||||||
|
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||||
|
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f Dockerfile ]]; then
|
||||||
|
docker build -t $GITHUB_REPOSITORY:test .
|
||||||
|
else
|
||||||
|
echo "No Dockerfile found, skipping docker build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: gitea-job-image
|
||||||
|
steps:
|
||||||
|
- name: Clone repo
|
||||||
|
run: |
|
||||||
|
rm -rf $GITHUB_WORKSPACE/*
|
||||||
|
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||||
|
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Run bandit (Python SAST)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f pyproject.toml ]]; then
|
||||||
|
pip3 install bandit
|
||||||
|
bandit -r . --severity-level high --confidence-level high --exclude tests/,test_*
|
||||||
|
else
|
||||||
|
echo "No Python project detected, skipping bandit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run npm audit (JS/TS)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f package.json ]]; then
|
||||||
|
npm ci
|
||||||
|
npm audit --audit-level=high 2>/dev/null || echo "npm audit: vulnerabilities found (non-blocking)"
|
||||||
|
else
|
||||||
|
echo "No Node.js project detected, skipping npm audit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-result:
|
||||||
|
needs: [lint, test, docker-build, security]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: gitea-job-image
|
||||||
|
if: always()
|
||||||
|
steps:
|
||||||
|
- name: Summary
|
||||||
|
run: echo "All CI checks completed"
|
||||||
@ -42,20 +42,26 @@ Dependencies
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import datetime
|
import datetime
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
||||||
def tqdm(iterable, *args, **kwargs):
|
def tqdm(iterable, *args, **kwargs):
|
||||||
return iterable
|
return iterable
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Configuration
|
# Configuration
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -65,7 +71,7 @@ API_ASSET_URL = "https://images-api.nasa.gov/asset"
|
|||||||
IMG_DIR = Path("images")
|
IMG_DIR = Path("images")
|
||||||
META_DIR = Path("metadata")
|
META_DIR = Path("metadata")
|
||||||
STATE_FILE = Path("last_run.txt")
|
STATE_FILE = Path("last_run.txt")
|
||||||
MAX_RUN_TIME = 2 * 60 * 60 # 2 hours in seconds
|
MAX_RUN_TIME = 4 * 60 * 60 # 4 hours in seconds
|
||||||
|
|
||||||
# Ensure the output directories exist
|
# Ensure the output directories exist
|
||||||
IMG_DIR.mkdir(parents=True, exist_ok=True)
|
IMG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
@ -77,7 +83,9 @@ META_DIR.mkdir(parents=True, exist_ok=True)
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
"""Parse CLI arguments."""
|
"""Parse CLI arguments."""
|
||||||
parser = argparse.ArgumentParser(description="Download NASA images for a date range")
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download NASA images for a date range"
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--start",
|
"--start",
|
||||||
type=str,
|
type=str,
|
||||||
@ -160,21 +168,61 @@ def fetch_items(start: datetime.datetime, end: datetime.datetime) -> List[Dict]:
|
|||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_url(url: str) -> bool:
|
||||||
|
"""Check that a URL points to a safe (non-internal) destination."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
# Block private and reserved IP ranges
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(host)
|
||||||
|
if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved:
|
||||||
|
return False
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# Block internal hostnames
|
||||||
|
if host.endswith((".local", ".internal", ".home.arpa")) or host in ("localhost", "metadata.google.internal"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def download_file(url: str, dest: Path) -> bool:
|
def download_file(url: str, dest: Path) -> bool:
|
||||||
"""Stream‑download the file at *url* to *dest* if it does not already exist."""
|
"""Stream‑download the file at *url* to *dest* if it does not already exist."""
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
return False
|
return False
|
||||||
|
if not _is_safe_url(url):
|
||||||
|
raise RuntimeError(f"Unsafe download URL blocked (SSRF protection): {url}")
|
||||||
try:
|
try:
|
||||||
with requests.get(url, stream=True, timeout=30) as r:
|
with requests.get(url, stream=True, timeout=30, allow_redirects=False) as r:
|
||||||
r.raise_for_status()
|
if r.status_code in (301, 302, 303, 307, 308):
|
||||||
with dest.open("wb") as f:
|
location = r.headers.get("Location", "")
|
||||||
for chunk in r.iter_content(chunk_size=8192):
|
if not _is_safe_url(location):
|
||||||
f.write(chunk)
|
raise RuntimeError(f"Redirect to unsafe URL blocked (SSRF protection): {location}")
|
||||||
|
# Follow single validated redirect
|
||||||
|
with requests.get(location, stream=True, timeout=30, allow_redirects=False) as r2:
|
||||||
|
r2.raise_for_status()
|
||||||
|
with dest.open("wb") as f:
|
||||||
|
for chunk in r2.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
else:
|
||||||
|
r.raise_for_status()
|
||||||
|
with dest.open("wb") as f:
|
||||||
|
for chunk in r.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Download error for {url}: {e}") from e
|
raise RuntimeError(f"Download error for {url}: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(name: str) -> str:
|
||||||
|
"""Sanitize a string for use as a filename, preventing path traversal."""
|
||||||
|
# Strip path separators and parent directory references
|
||||||
|
sanitized = re.sub(r'[\/\\:\*\?"<>|]', "_", name)
|
||||||
|
sanitized = re.sub(r"^\.{1,2}($|_)", "item_", sanitized)
|
||||||
|
# Collapse multiple underscores
|
||||||
|
sanitized = re.sub(r"_+", "_", sanitized)
|
||||||
|
return sanitized.strip(".") or "item"
|
||||||
|
|
||||||
|
|
||||||
def save_metadata(item: Dict, dest: Path) -> None:
|
def save_metadata(item: Dict, dest: Path) -> None:
|
||||||
"""Persist the full API item as formatted JSON."""
|
"""Persist the full API item as formatted JSON."""
|
||||||
with dest.open("w", encoding="utf-8") as f:
|
with dest.open("w", encoding="utf-8") as f:
|
||||||
@ -187,8 +235,23 @@ def save_metadata(item: Dict, dest: Path) -> None:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
# Resolve output base directory and override global paths
|
# Resolve output base directory and validate it is safe
|
||||||
base_dir = Path(args.output).resolve()
|
base_dir = Path(args.output).resolve()
|
||||||
|
# Prevent writing to system-critical paths
|
||||||
|
_BLOCKED_PREFIXES = {
|
||||||
|
str(Path(p).resolve())
|
||||||
|
for p in ("/etc", "/usr", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys")
|
||||||
|
}
|
||||||
|
base_str = str(base_dir)
|
||||||
|
if base_str == "/" or any(
|
||||||
|
base_str == blocked or base_str.startswith(blocked + "/")
|
||||||
|
for blocked in _BLOCKED_PREFIXES
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
f"Error: --output cannot point to system directory: {base_dir}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
global IMG_DIR, META_DIR, STATE_FILE
|
global IMG_DIR, META_DIR, STATE_FILE
|
||||||
IMG_DIR = base_dir / "images"
|
IMG_DIR = base_dir / "images"
|
||||||
META_DIR = base_dir / "metadata"
|
META_DIR = base_dir / "metadata"
|
||||||
@ -219,12 +282,13 @@ def main() -> None:
|
|||||||
|
|
||||||
for idx, item in enumerate(tqdm(items, desc="Downloading"), start=1):
|
for idx, item in enumerate(tqdm(items, desc="Downloading"), start=1):
|
||||||
data = item.get("data", [{}])[0]
|
data = item.get("data", [{}])[0]
|
||||||
nasa_id = data.get("nasa_id") or data.get("title", f"item_{idx}")
|
raw_nasa_id = data.get("nasa_id") or data.get("title", f"item_{idx}")
|
||||||
|
nasa_id = sanitize_filename(str(raw_nasa_id))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Asset lookup: fetch high‑resolution image URL
|
# Asset lookup: fetch high‑resolution image URL
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
asset_url = f"{API_ASSET_URL}/{nasa_id}"
|
asset_url = f"{API_ASSET_URL}/{raw_nasa_id}"
|
||||||
try:
|
try:
|
||||||
asset_resp = requests.get(asset_url, timeout=20)
|
asset_resp = requests.get(asset_url, timeout=20)
|
||||||
asset_resp.raise_for_status()
|
asset_resp.raise_for_status()
|
||||||
@ -238,7 +302,7 @@ def main() -> None:
|
|||||||
print(f"[{idx}] Failed asset lookup for {nasa_id}: {e}", file=sys.stderr)
|
print(f"[{idx}] Failed asset lookup for {nasa_id}: {e}", file=sys.stderr)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
filename = Path(asset_href).name
|
filename = sanitize_filename(Path(asset_href).name)
|
||||||
image_path = IMG_DIR / filename
|
image_path = IMG_DIR / filename
|
||||||
meta_path = META_DIR / f"index-{nasa_id}.json"
|
meta_path = META_DIR / f"index-{nasa_id}.json"
|
||||||
|
|
||||||
@ -252,7 +316,9 @@ def main() -> None:
|
|||||||
try:
|
try:
|
||||||
save_metadata(item, meta_path)
|
save_metadata(item, meta_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[{idx}] Failed to write metadata for {nasa_id}: {e}", file=sys.stderr)
|
print(
|
||||||
|
f"[{idx}] Failed to write metadata for {nasa_id}: {e}", file=sys.stderr
|
||||||
|
)
|
||||||
|
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
if downloaded:
|
if downloaded:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user