commit fe17014affbdc561eda00c3a2e06333778d5376b Author: ToxicCrzay270 <185776014+ToxicCrzay270@users.noreply.github.com> Date: Fri May 15 00:41:37 2026 +0200 Add Opera cache cleaner extension baseline diff --git a/opera-cache-cleaner-extension/.codex/project.md b/opera-cache-cleaner-extension/.codex/project.md new file mode 100644 index 0000000..4027059 --- /dev/null +++ b/opera-cache-cleaner-extension/.codex/project.md @@ -0,0 +1,74 @@ +# Codex Project Notes + +## Project + +`Opera Cache Cleaner` is an Opera-compatible Chromium extension for clearing only the browser cache from a toolbar popup. It supports immediate cleanup and an optional timer. + +Repository: + +```text +Toxic/Opera-Extensions +``` + +Gitea URL: + +```text +https://git.wilkensxl.de/Toxic/Opera-Extensions.git +``` + +## Commands + +Use these commands as the source of truth from the repository root: + +```text +Syntax check background: node --check background.js +Syntax check popup: node --check popup/popup.js +Build package: mkdir -p dist && zip -r dist/opera-cache-cleaner-extension.zip manifest.json background.js popup icons -x "*.DS_Store" +``` + +No install, lint, test, README generation, or dependency audit command exists. The project has no package manifest and no vendored dependencies. + +## Stack + +```text +Manifest V3 Chromium extension using plain HTML, CSS, and JavaScript. +``` + +Package manager or build tool: + +```text +None. +``` + +## Build Artifacts + +Release artifacts are produced in: + +```text +dist/ +``` + +Expected file: + +```text +opera-cache-cleaner-extension.zip +``` + +## Security Rules + +- Keep the extension limited to `browsingData`, `storage`, and `alarms` permissions unless a feature explicitly requires more. +- The extension must clear only cache data through `chrome.browsingData.remove(..., { cache: true })`. +- Do not request cookie, history, downloads, tabs, or host permissions without a documented user request. +- Do not add external network calls. +- Do not commit secrets, tokens, `.env` files, certificates, or private keys. + +## Release Rules + +Before a release: + +1. run the syntax checks, +2. review `docs/security-review.md`, +3. update `CHANGELOG.md`, +4. build `dist/opera-cache-cleaner-extension.zip`, +5. load the unpacked extension in Opera for a manual smoke test, +6. create a tag and release only when the user explicitly asks for it. diff --git a/opera-cache-cleaner-extension/.gitea/workflows/build.yml b/opera-cache-cleaner-extension/.gitea/workflows/build.yml new file mode 100644 index 0000000..3b93ef7 --- /dev/null +++ b/opera-cache-cleaner-extension/.gitea/workflows/build.yml @@ -0,0 +1,37 @@ +name: Build + +on: + push: + branches: + - main + - master + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check background script syntax + run: node --check background.js + + - name: Check popup script syntax + run: node --check popup/popup.js + + - name: Build extension archive + shell: bash + run: | + rm -rf dist + mkdir -p dist/package + cp manifest.json background.js dist/package/ + cp -R popup icons dist/package/ + cd dist/package + zip -r ../opera-cache-cleaner-extension.zip . + + - name: Upload extension archive + uses: actions/upload-artifact@v3 + with: + name: opera-cache-cleaner-extension + path: dist/opera-cache-cleaner-extension.zip diff --git a/opera-cache-cleaner-extension/.gitea/workflows/release-dry-run.yml b/opera-cache-cleaner-extension/.gitea/workflows/release-dry-run.yml new file mode 100644 index 0000000..e9817bc --- /dev/null +++ b/opera-cache-cleaner-extension/.gitea/workflows/release-dry-run.yml @@ -0,0 +1,43 @@ +name: Release Dry Run + +on: + push: + branches: + - main + - master + workflow_dispatch: + +jobs: + release-dry-run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify release documentation + shell: bash + run: | + test -f README.md + test -f CHANGELOG.md + test -f SECURITY.md + test -f docs/release-checklist.md + test -f docs/security-review.md + + - name: Check for unresolved template placeholders + shell: bash + run: | + if grep -RInE 'PROJECT[_]NAME|PROJECT[_]DESCRIPTION|REPOSITORY[_]OWNER|REPOSITORY[_]NAME|PACKAGE[_]NAME|ARTIFACT[_]NAME|ARTIFACT[_]OUTPUT[_]DIRECTORY|BUILD[_]COMMAND|TEST[_]COMMAND|LINT[_]COMMAND|AUDIT[_]COMMAND' . --exclude-dir=.git --exclude-dir=dist; then + echo "Unresolved template placeholders found." + exit 1 + fi + + - name: Build extension archive + shell: bash + run: | + rm -rf dist + mkdir -p dist/package + cp manifest.json background.js dist/package/ + cp -R popup icons dist/package/ + cd dist/package + zip -r ../opera-cache-cleaner-extension.zip . + test -s ../opera-cache-cleaner-extension.zip diff --git a/opera-cache-cleaner-extension/.gitea/workflows/repo-cleanup.yml b/opera-cache-cleaner-extension/.gitea/workflows/repo-cleanup.yml new file mode 100644 index 0000000..85cb86e --- /dev/null +++ b/opera-cache-cleaner-extension/.gitea/workflows/repo-cleanup.yml @@ -0,0 +1,31 @@ +name: Repository Cleanup + +on: + schedule: + - cron: "0 5 * * 1" + workflow_dispatch: + +jobs: + cleanup-report: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Report generated or sensitive tracked files + shell: bash + run: | + git ls-files | grep -E '(^dist/|^build/|^out/|\.log$|\.tmp$|\.env|\.pem$|\.key$|\.token$)' && { + echo "Tracked generated or sensitive-looking files found." + exit 1 + } || true + + - name: Report large tracked files + shell: bash + run: | + large_files="$(git ls-files -z | xargs -0 du -k | awk '$1 > 1024 { print }')" + if [ -n "$large_files" ]; then + echo "$large_files" + echo "Tracked files above 1 MiB should be reviewed." + exit 1 + fi diff --git a/opera-cache-cleaner-extension/.gitea/workflows/security-scan.yml b/opera-cache-cleaner-extension/.gitea/workflows/security-scan.yml new file mode 100644 index 0000000..1b49480 --- /dev/null +++ b/opera-cache-cleaner-extension/.gitea/workflows/security-scan.yml @@ -0,0 +1,34 @@ +name: Security Scan + +on: + schedule: + - cron: "0 4 * * 1" + workflow_dispatch: + +jobs: + security-scan: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check JavaScript syntax + run: | + node --check background.js + node --check popup/popup.js + + - name: Scan for risky patterns + shell: bash + run: | + if grep -RInE 'eval\s*\(|new Function|innerHTML\s*=|insertAdjacentHTML|fetch\s*\(|XMLHttpRequest|chrome\.tabs|chrome\.cookies|chrome\.history' background.js popup manifest.json; then + echo "Review the matches above before release." + exit 1 + fi + + - name: Check manifest permissions + shell: bash + run: | + if grep -qE '"(tabs|cookies|history|downloads|)"' manifest.json; then + echo "Unexpected broad permission found in manifest.json." + exit 1 + fi diff --git a/opera-cache-cleaner-extension/.gitea/workflows/template-compliance.yml b/opera-cache-cleaner-extension/.gitea/workflows/template-compliance.yml new file mode 100644 index 0000000..d0b1bcd --- /dev/null +++ b/opera-cache-cleaner-extension/.gitea/workflows/template-compliance.yml @@ -0,0 +1,38 @@ +name: Template Compliance + +on: + push: + branches: + - main + - master + pull_request: + workflow_dispatch: + +jobs: + template-compliance: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify Codex baseline files + shell: bash + run: | + test -f AGENTS.md + test -f .codex/project.md + test -f SECURITY.md + test -f CHANGELOG.md + test -f docs/security-review.md + test -f docs/release-checklist.md + test -f .gitea/workflows/build.yml + test -f .gitea/workflows/security-scan.yml + test -f .gitea/workflows/repo-cleanup.yml + test -f .gitea/workflows/release-dry-run.yml + + - name: Check for unresolved template placeholders + shell: bash + run: | + if grep -RInE 'PROJECT[_]NAME|PROJECT[_]DESCRIPTION|REPOSITORY[_]OWNER|REPOSITORY[_]NAME|PACKAGE[_]NAME|ARTIFACT[_]NAME|ARTIFACT[_]OUTPUT[_]DIRECTORY|BUILD[_]COMMAND|TEST[_]COMMAND|LINT[_]COMMAND|AUDIT[_]COMMAND' . --exclude-dir=.git --exclude-dir=dist; then + echo "Unresolved template placeholders found." + exit 1 + fi diff --git a/opera-cache-cleaner-extension/.gitignore b/opera-cache-cleaner-extension/.gitignore new file mode 100644 index 0000000..939ecdb --- /dev/null +++ b/opera-cache-cleaner-extension/.gitignore @@ -0,0 +1,32 @@ +# Build outputs +dist/ +build/ +out/ +release/ + +# Logs and temporary files +*.log +*.tmp +*.temp +.cache/ + +# Local environment and secrets +.env +.env.* +!.env.example +*.pem +*.key +*.pfx +*.p12 +*.crt +*.cer +*.token +secrets/ + +# OS and editor files +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*.swo diff --git a/opera-cache-cleaner-extension/AGENTS.md b/opera-cache-cleaner-extension/AGENTS.md new file mode 100644 index 0000000..888321c --- /dev/null +++ b/opera-cache-cleaner-extension/AGENTS.md @@ -0,0 +1,41 @@ +# Agent Instructions + +## Project + +Opera Cache Cleaner is an Opera-compatible Chromium extension that clears only the browser cache for a selected time range and can optionally schedule repeated cache cleanup. + +## Repository Rules + +- Preserve the plain Manifest V3 extension structure. Do not add a framework unless the user explicitly asks for it. +- Keep runtime code in `background.js` and `popup/`. +- Keep permissions minimal. The extension currently uses only `browsingData`, `storage`, and `alarms`. +- Do not add host permissions, network calls, cookie/history access, or broad browser permissions without documenting the reason. +- Do not create a release unless the user explicitly asks for one. +- Keep `.codex/project.md` aligned when commands, artifact paths, or release rules change. + +## Commands + +Use these commands from the repository root: + +```bash +node --check background.js +node --check popup/popup.js +mkdir -p dist && zip -r dist/opera-cache-cleaner-extension.zip manifest.json background.js popup icons -x "*.DS_Store" +``` + +There is no package manager, dependency install, lint, or dependency audit command for the current project. + +## Artifacts + +Expected release artifact: + +```text +dist/opera-cache-cleaner-extension.zip +``` + +## Finish Checklist + +- `git diff --check` passes when the project is inside a Git repository. +- `node --check background.js` passes. +- `node --check popup/popup.js` passes. +- Release documentation is updated when release behavior changes. diff --git a/opera-cache-cleaner-extension/CHANGELOG.md b/opera-cache-cleaner-extension/CHANGELOG.md new file mode 100644 index 0000000..962825b --- /dev/null +++ b/opera-cache-cleaner-extension/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +All notable changes to this project are documented here. + +## Unreleased + +- Added Codex repository baseline files and Gitea workflow checks. +- Documented release, security, and maintenance expectations. diff --git a/opera-cache-cleaner-extension/CONTRIBUTING.md b/opera-cache-cleaner-extension/CONTRIBUTING.md new file mode 100644 index 0000000..17a17d0 --- /dev/null +++ b/opera-cache-cleaner-extension/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contributing + +## Development + +This extension has no package manager or dependency install step. Edit the plain JavaScript, HTML, CSS, and manifest files directly. + +Run the syntax checks before submitting changes: + +```bash +node --check background.js +node --check popup/popup.js +``` + +## Manual Testing + +1. Open `opera://extensions`. +2. Enable developer mode. +3. Load this folder as an unpacked extension. +4. Open the popup and verify cache clearing, timer save, timer disable, and status display. + +## Security Expectations + +- Do not add host permissions unless required by a documented feature. +- Do not add cookie, history, downloads, or tabs permissions without explicit review. +- Do not add external network calls. diff --git a/opera-cache-cleaner-extension/README.md b/opera-cache-cleaner-extension/README.md new file mode 100644 index 0000000..6c99549 --- /dev/null +++ b/opera-cache-cleaner-extension/README.md @@ -0,0 +1,66 @@ +# Opera Cache Cleaner Extension + +Eine Opera-kompatible Chromium-Erweiterung zum Loeschen des Browser-Caches ueber ein Toolbar-Popup. Zusaetzlich kann ein einmaliger oder wiederholter Timer eingerichtet werden. + +## Funktionen + +- Cache fuer einen auswaehlbaren Zeitraum loeschen. +- Zeitraeume: letzte Stunde, letzte 24 Stunden, letzte 7 Tage, letzte 4 Wochen, gesamter Zeitraum. +- Timer mit Intervall in Minuten, Stunden oder Tagen. +- Optionaler Repeat-Modus fuer wiederholtes Loeschen. +- Statusanzeige fuer letzte Cache-Loeschung, Timer-Status und naechsten geplanten Lauf. + +## Datenschutz + +Die Erweiterung loescht ausschliesslich den Browser-Cache ueber `chrome.browsingData.remove({ since }, { cache: true })`. + +Sie loescht keine Cookies, keinen Verlauf, keine Downloads und keine Passwoerter. + +## Installation in Opera + +1. Oeffne `opera://extensions`. +2. Aktiviere den Entwicklermodus. +3. Klicke auf `Entpackte Erweiterung laden`. +4. Waehle diesen Ordner aus: + + ```text + D:\Codex\Opera-Extensions\opera-cache-cleaner-extension + ``` + +5. Oeffne die Erweiterung ueber das Toolbar-Icon. + +## Entwicklung + +Die Extension verwendet plain HTML, CSS und JavaScript ohne Paketmanager. Fuehre vor Aenderungen oder Releases diese Checks aus: + +```bash +node --check background.js +node --check popup/popup.js +``` + +Ein Release-Archiv kann so gebaut werden: + +```bash +mkdir -p dist && zip -r dist/opera-cache-cleaner-extension.zip manifest.json background.js popup icons -x "*.DS_Store" +``` + +## Verwendung + +1. Waehle im Popup den gewuenschten Zeitraum aus. +2. Klicke auf `Cache jetzt leeren`, um den Cache sofort zu loeschen. +3. Fuer einen Timer: + - Intervall eingeben. + - Einheit auswaehlen. + - Optional `Wiederholen` aktivieren. + - `Timer speichern` klicken. +4. Mit `Timer deaktivieren` wird der aktive Timer entfernt. + +## Berechtigungen + +Die Erweiterung verwendet nur diese Permissions: + +- `browsingData` +- `storage` +- `alarms` + +Es werden keine Host-Permissions verwendet. Die Erweiterung fordert keine `tabs`, `cookies` oder `history` Permission an. diff --git a/opera-cache-cleaner-extension/SECURITY.md b/opera-cache-cleaner-extension/SECURITY.md new file mode 100644 index 0000000..1c9fc0a --- /dev/null +++ b/opera-cache-cleaner-extension/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| --- | --- | +| Latest | Yes | + +## Reporting A Vulnerability + +Please report security issues privately to the project owner. + +Do not include secrets, production data, or private credentials in public issues. + +## Project Security Principles + +- Keep browser permissions minimal. +- Clear only browser cache data. +- Do not add external network calls. +- Do not persist sensitive browsing data. +- Keep release artifacts reproducible from tracked files. diff --git a/opera-cache-cleaner-extension/background.js b/opera-cache-cleaner-extension/background.js new file mode 100644 index 0000000..677a8c1 --- /dev/null +++ b/opera-cache-cleaner-extension/background.js @@ -0,0 +1,373 @@ +const ALARM_NAME = "cacheCleanerTimer"; +const DEFAULT_RANGE_KEY = "last_24h"; +const MAX_INTERVAL_MINUTES = 30 * 24 * 60; +const INTERVAL_UNITS = new Set(["minutes", "hours", "days"]); + +const RANGE_OPTIONS = { + last_hour: { + label: "Letzte Stunde", + sinceMs: 60 * 60 * 1000 + }, + last_24h: { + label: "Letzte 24 Stunden", + sinceMs: 24 * 60 * 60 * 1000 + }, + last_7d: { + label: "Letzte 7 Tage", + sinceMs: 7 * 24 * 60 * 60 * 1000 + }, + last_4w: { + label: "Letzte 4 Wochen", + sinceMs: 28 * 24 * 60 * 60 * 1000 + }, + all_time: { + label: "Gesamter Zeitraum", + sinceMs: null + } +}; + +const DEFAULT_TIMER_CONFIG = { + enabled: false, + repeat: false, + rangeKey: DEFAULT_RANGE_KEY, + intervalValue: 1, + intervalUnit: "hours", + intervalMinutes: 60, + nextRun: null +}; + +function getSinceByRange(rangeKey) { + const option = RANGE_OPTIONS[rangeKey]; + + if (!option) { + throw new Error("Unbekannter Zeitraum."); + } + + return option.sinceMs === null ? 0 : Date.now() - option.sinceMs; +} + +function getStorage(keys) { + return new Promise((resolve, reject) => { + chrome.storage.local.get(keys, (result) => { + const error = chrome.runtime.lastError; + + if (error) { + reject(new Error(error.message)); + return; + } + + resolve(result || {}); + }); + }); +} + +function setStorage(values) { + return new Promise((resolve, reject) => { + chrome.storage.local.set(values, () => { + const error = chrome.runtime.lastError; + + if (error) { + reject(new Error(error.message)); + return; + } + + resolve(); + }); + }); +} + +function createAlarm(details) { + return new Promise((resolve, reject) => { + try { + const result = chrome.alarms.create(ALARM_NAME, details); + const error = chrome.runtime.lastError; + + if (error) { + reject(new Error(error.message)); + return; + } + + if (result && typeof result.then === "function") { + result.then(resolve).catch(reject); + return; + } + + resolve(); + } catch (error) { + reject(error); + } + }); +} + +function clearAlarmOnly() { + return new Promise((resolve, reject) => { + chrome.alarms.clear(ALARM_NAME, () => { + const error = chrome.runtime.lastError; + + if (error) { + reject(new Error(error.message)); + return; + } + + resolve(); + }); + }); +} + +function validateTimerConfig(config) { + if (!config || typeof config !== "object") { + throw new Error("Timer-Konfiguration fehlt."); + } + + if (!RANGE_OPTIONS[config.rangeKey]) { + throw new Error("Unbekannter Zeitraum."); + } + + if (!INTERVAL_UNITS.has(config.intervalUnit)) { + throw new Error("Unbekannte Timer-Einheit."); + } + + if (!Number.isFinite(config.intervalValue)) { + throw new Error("Das Intervall muss eine Zahl sein."); + } + + if (config.intervalValue <= 0) { + throw new Error("Das Intervall muss groesser als 0 sein."); + } + + if (!Number.isFinite(config.intervalMinutes)) { + throw new Error("Das Intervall konnte nicht berechnet werden."); + } + + if (config.intervalMinutes < 1) { + throw new Error("Das Mindestintervall betraegt 1 Minute."); + } + + if (config.intervalMinutes > MAX_INTERVAL_MINUTES) { + throw new Error("Das Maximalintervall betraegt 30 Tage."); + } +} + +async function clearCacheByRange(rangeKey) { + const resolvedRangeKey = RANGE_OPTIONS[rangeKey] ? rangeKey : DEFAULT_RANGE_KEY; + const since = getSinceByRange(resolvedRangeKey); + + // Only the cache flag is set so cookies, history, downloads, and passwords stay untouched. + await new Promise((resolve, reject) => { + chrome.browsingData.remove( + { since }, + { cache: true }, + () => { + const error = chrome.runtime.lastError; + + if (error) { + reject(new Error(error.message)); + return; + } + + resolve(); + } + ); + }); + + const lastRun = Date.now(); + await setStorage({ + selectedRange: resolvedRangeKey, + lastRun + }); + + return { + lastRun, + rangeKey: resolvedRangeKey + }; +} + +async function setupAlarm(config) { + validateTimerConfig(config); + + const intervalMinutes = config.intervalMinutes; + const nextRun = Date.now() + intervalMinutes * 60 * 1000; + const timerConfig = { + enabled: true, + repeat: Boolean(config.repeat), + rangeKey: config.rangeKey, + intervalValue: config.intervalValue, + intervalUnit: config.intervalUnit, + intervalMinutes, + nextRun + }; + const alarmDetails = timerConfig.repeat + ? { + delayInMinutes: intervalMinutes, + periodInMinutes: intervalMinutes + } + : { + delayInMinutes: intervalMinutes + }; + + // Replacing the alarm avoids duplicate timers after repeated saves. + await clearAlarmOnly(); + await createAlarm(alarmDetails); + await setStorage({ + selectedRange: timerConfig.rangeKey, + timerConfig + }); + + return timerConfig; +} + +async function clearAlarm() { + const { timerConfig } = await getStorage(["timerConfig"]); + const disabledConfig = { + ...DEFAULT_TIMER_CONFIG, + ...(timerConfig || {}), + enabled: false, + nextRun: null + }; + + await clearAlarmOnly(); + await setStorage({ timerConfig: disabledConfig }); + + return disabledConfig; +} + +async function restoreAlarmOnStartup() { + const { timerConfig } = await getStorage(["timerConfig"]); + + if (!timerConfig || timerConfig.enabled !== true) { + return; + } + + validateTimerConfig(timerConfig); + + const now = Date.now(); + let nextRun = Number(timerConfig.nextRun); + let delayInMinutes; + + // Extension restarts can miss an alarm, so restart from a future run instead of firing immediately. + if (Number.isFinite(nextRun) && nextRun > now) { + delayInMinutes = Math.max(1, Math.ceil((nextRun - now) / 60000)); + nextRun = now + delayInMinutes * 60000; + } else { + delayInMinutes = timerConfig.intervalMinutes; + nextRun = now + timerConfig.intervalMinutes * 60000; + } + + const restoredConfig = { + ...timerConfig, + nextRun + }; + const alarmDetails = restoredConfig.repeat + ? { + delayInMinutes, + periodInMinutes: restoredConfig.intervalMinutes + } + : { + delayInMinutes + }; + + await createAlarm(alarmDetails); + await setStorage({ timerConfig: restoredConfig }); +} + +async function handleTimerAlarm(alarm) { + if (!alarm || alarm.name !== ALARM_NAME) { + return; + } + + const { timerConfig } = await getStorage(["timerConfig"]); + + if (!timerConfig || timerConfig.enabled !== true) { + return; + } + + const rangeKey = RANGE_OPTIONS[timerConfig.rangeKey] ? timerConfig.rangeKey : DEFAULT_RANGE_KEY; + await clearCacheByRange(rangeKey); + + if (timerConfig.repeat) { + const nextRun = Date.now() + timerConfig.intervalMinutes * 60 * 1000; + await setStorage({ + timerConfig: { + ...timerConfig, + rangeKey, + nextRun + } + }); + return; + } + + await setStorage({ + timerConfig: { + ...timerConfig, + rangeKey, + enabled: false, + nextRun: null + } + }); +} + +function sendError(sendResponse, error) { + sendResponse({ + ok: false, + error: error && error.message ? error.message : "Unbekannter Fehler." + }); +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + (async () => { + if (!message || typeof message.type !== "string") { + throw new Error("Unbekannte Nachricht."); + } + + if (message.type === "CLEAR_CACHE") { + const result = await clearCacheByRange(message.rangeKey); + sendResponse({ + ok: true, + ...result + }); + return; + } + + if (message.type === "SET_TIMER") { + const timerConfig = await setupAlarm(message.config); + sendResponse({ + ok: true, + timerConfig + }); + return; + } + + if (message.type === "CLEAR_TIMER") { + const timerConfig = await clearAlarm(); + sendResponse({ + ok: true, + timerConfig + }); + return; + } + + throw new Error("Unbekannter Nachrichtentyp."); + })().catch((error) => { + sendError(sendResponse, error); + }); + + return true; +}); + +chrome.alarms.onAlarm.addListener((alarm) => { + handleTimerAlarm(alarm).catch((error) => { + console.error("Timer konnte den Cache nicht loeschen.", error); + }); +}); + +chrome.runtime.onInstalled.addListener(() => { + restoreAlarmOnStartup().catch((error) => { + console.error("Timer konnte nicht wiederhergestellt werden.", error); + }); +}); + +chrome.runtime.onStartup.addListener(() => { + restoreAlarmOnStartup().catch((error) => { + console.error("Timer konnte nicht wiederhergestellt werden.", error); + }); +}); diff --git a/opera-cache-cleaner-extension/docs/agent-handoff.md b/opera-cache-cleaner-extension/docs/agent-handoff.md new file mode 100644 index 0000000..ede6ee4 --- /dev/null +++ b/opera-cache-cleaner-extension/docs/agent-handoff.md @@ -0,0 +1,17 @@ +# Agent Handoff + +## Current State + +The Codex repository baseline has been applied to the local Opera Cache Cleaner extension. + +## Notes For Next Agent + +- The project is not currently a Git repository in this workspace. +- Expected Gitea repository: `https://git.wilkensxl.de/Toxic/Opera-Extensions.git`. +- The project has no package manager and no external dependencies. +- Use syntax checks and manual Opera extension testing as the main verification path. + +## Open Items + +- Initialize or connect a Git repository if the project should be pushed to Gitea. +- Confirm whether this extension should live at the repository root or inside `opera-cache-cleaner-extension/` before enabling package publishing or Gitea API polling. diff --git a/opera-cache-cleaner-extension/docs/release-checklist.md b/opera-cache-cleaner-extension/docs/release-checklist.md new file mode 100644 index 0000000..f00a1d7 --- /dev/null +++ b/opera-cache-cleaner-extension/docs/release-checklist.md @@ -0,0 +1,34 @@ +# Release Checklist + +## Version + +- [ ] Version number updated in `manifest.json`. +- [ ] `CHANGELOG.md` updated. +- [ ] README checked for current installation and usage instructions. + +## Quality + +- [ ] Working tree is clean. +- [ ] `node --check background.js` passes. +- [ ] `node --check popup/popup.js` passes. +- [ ] Manual unpacked-extension smoke test in Opera passes. + +## Security + +- [ ] `docs/security-review.md` is current. +- [ ] No new permissions were added without review. +- [ ] No secrets are committed. +- [ ] Release artifact does not contain local config files. + +## Artifacts + +- [ ] `dist/opera-cache-cleaner-extension.zip` exists. +- [ ] Zip contains `manifest.json`, `background.js`, `popup/`, and `icons/`. +- [ ] Zip does not contain `dist/`, `.git/`, `.codex/`, `.gitea/`, or docs-only files. + +## Release + +- [ ] Git tag created. +- [ ] Release notes written. +- [ ] Release published. +- [ ] Post-release download smoke test completed. diff --git a/opera-cache-cleaner-extension/docs/security-review.md b/opera-cache-cleaner-extension/docs/security-review.md new file mode 100644 index 0000000..e30fc04 --- /dev/null +++ b/opera-cache-cleaner-extension/docs/security-review.md @@ -0,0 +1,54 @@ +# Security Review + +## Scope + +Project: + +```text +Opera Cache Cleaner +``` + +Reviewed version or commit: + +```text +1.0.0 local workspace +``` + +## Code Patterns Checked + +- [x] No `eval`. +- [x] No dynamic `Function` constructor. +- [x] No unsafe HTML injection found in the reviewed code. +- [x] No shell execution. +- [x] No external network calls. +- [x] No secrets committed in the current source files. +- [x] No unsafe file writes. Browser data changes are limited to cache removal. + +## Dependency Review + +Command: + +```bash +No dependency audit command exists because the project has no package manifest or external dependencies. +``` + +Result: + +```text +Not applicable. +``` + +## Runtime Review + +- [x] Manifest uses least-privilege permissions for the current feature set. +- [x] No host permissions are declared. +- [x] Local storage is used only for selected range, timer settings, and last run timestamp. +- [x] Cache clearing uses `chrome.browsingData.remove({ since }, { cache: true })`. + +## Release Notes + +Known residual risks: + +```text +No automated browser-extension integration tests exist. Perform an unpacked-extension smoke test in Opera before release. +``` diff --git a/opera-cache-cleaner-extension/icons/icon128.png b/opera-cache-cleaner-extension/icons/icon128.png new file mode 100644 index 0000000..75cba10 Binary files /dev/null and b/opera-cache-cleaner-extension/icons/icon128.png differ diff --git a/opera-cache-cleaner-extension/icons/icon16.png b/opera-cache-cleaner-extension/icons/icon16.png new file mode 100644 index 0000000..d4f2149 Binary files /dev/null and b/opera-cache-cleaner-extension/icons/icon16.png differ diff --git a/opera-cache-cleaner-extension/icons/icon48.png b/opera-cache-cleaner-extension/icons/icon48.png new file mode 100644 index 0000000..853e5e6 Binary files /dev/null and b/opera-cache-cleaner-extension/icons/icon48.png differ diff --git a/opera-cache-cleaner-extension/manifest.json b/opera-cache-cleaner-extension/manifest.json new file mode 100644 index 0000000..8b1c837 --- /dev/null +++ b/opera-cache-cleaner-extension/manifest.json @@ -0,0 +1,28 @@ +{ + "manifest_version": 3, + "name": "Opera Cache Cleaner", + "version": "1.0.0", + "description": "Clears only the browser cache for a selected time range and can run on a configurable timer.", + "permissions": [ + "browsingData", + "storage", + "alarms" + ], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_title": "Opera Cache Cleaner", + "default_popup": "popup/popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/opera-cache-cleaner-extension/popup/popup.css b/opera-cache-cleaner-extension/popup/popup.css new file mode 100644 index 0000000..56453fa --- /dev/null +++ b/opera-cache-cleaner-extension/popup/popup.css @@ -0,0 +1,195 @@ +:root { + color-scheme: light; + --background: #f7f8fa; + --surface: #ffffff; + --border: #d7dce2; + --text: #20242a; + --muted: #5f6875; + --primary: #cc0f2f; + --primary-hover: #a90d28; + --secondary: #edf0f4; + --secondary-hover: #dfe4ea; + --error: #9f1d22; + --success: #176b3a; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-width: 320px; + max-width: 320px; + background: var(--background); + color: var(--text); + font-family: Arial, Helvetica, sans-serif; + font-size: 14px; +} + +button, +input, +select { + font: inherit; +} + +.popup-shell { + width: 320px; + padding: 14px; +} + +.header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.header-icon { + flex: 0 0 auto; +} + +h1, +h2 { + margin: 0; + letter-spacing: 0; +} + +h1 { + font-size: 18px; + line-height: 1.2; +} + +h2 { + margin-bottom: 10px; + font-size: 14px; + line-height: 1.25; +} + +.section, +.status-box { + margin-top: 12px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +.field { + display: grid; + gap: 6px; + margin-bottom: 10px; + color: var(--muted); + font-weight: 600; +} + +.field input, +.field select { + width: 100%; + min-height: 36px; + padding: 7px 9px; + border: 1px solid var(--border); + border-radius: 6px; + background: #ffffff; + color: var(--text); +} + +.field input:focus, +.field select:focus, +.button:focus { + outline: 2px solid rgba(204, 15, 47, 0.28); + outline-offset: 2px; +} + +.button { + min-height: 38px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; + font-weight: 700; +} + +.button:disabled { + cursor: wait; + opacity: 0.68; +} + +.button-primary { + width: 100%; + background: var(--primary); + color: #ffffff; +} + +.button-primary:hover:not(:disabled) { + background: var(--primary-hover); +} + +.button-secondary { + background: var(--secondary); + color: var(--text); +} + +.button-secondary:hover:not(:disabled) { + background: var(--secondary-hover); +} + +.timer-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 8px; + margin: 2px 0 12px; + color: var(--text); + font-weight: 600; +} + +.checkbox-row input { + width: 16px; + height: 16px; +} + +.button-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; +} + +.status-box dl { + display: grid; + gap: 8px; + margin: 0; +} + +.status-box div { + display: grid; + gap: 2px; +} + +.status-box dt { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.status-box dd { + margin: 0; + overflow-wrap: anywhere; + line-height: 1.35; +} + +.message-error { + color: var(--error); + font-weight: 700; +} + +.message-success { + color: var(--success); + font-weight: 700; +} diff --git a/opera-cache-cleaner-extension/popup/popup.html b/opera-cache-cleaner-extension/popup/popup.html new file mode 100644 index 0000000..4d560a4 --- /dev/null +++ b/opera-cache-cleaner-extension/popup/popup.html @@ -0,0 +1,78 @@ + + + + + + Opera Cache Cleaner + + + +
+
+ +

Opera Cache Cleaner

+
+ +
+

Cache löschen

+ + +
+ +
+

Timer

+
+ + +
+ + + +
+ + +
+
+ +
+

Status

+
+
+
Letzte Cache-Löschung
+
Noch nie
+
+
+
Timer
+
Inaktiv
+
+
+
Nächster Lauf
+
Nicht geplant
+
+
+
Meldung
+
Keine Fehler.
+
+
+
+
+ + + + diff --git a/opera-cache-cleaner-extension/popup/popup.js b/opera-cache-cleaner-extension/popup/popup.js new file mode 100644 index 0000000..47a69bf --- /dev/null +++ b/opera-cache-cleaner-extension/popup/popup.js @@ -0,0 +1,312 @@ +const DEFAULT_RANGE_KEY = "last_24h"; +const MAX_INTERVAL_MINUTES = 30 * 24 * 60; + +const RANGE_OPTIONS = { + last_hour: { + label: "Letzte Stunde", + sinceMs: 60 * 60 * 1000 + }, + last_24h: { + label: "Letzte 24 Stunden", + sinceMs: 24 * 60 * 60 * 1000 + }, + last_7d: { + label: "Letzte 7 Tage", + sinceMs: 7 * 24 * 60 * 60 * 1000 + }, + last_4w: { + label: "Letzte 4 Wochen", + sinceMs: 28 * 24 * 60 * 60 * 1000 + }, + all_time: { + label: "Gesamter Zeitraum", + sinceMs: null + } +}; + +const UNIT_FACTORS = { + minutes: 1, + hours: 60, + days: 24 * 60 +}; + +const elements = {}; + +function getStorage(keys) { + return new Promise((resolve, reject) => { + chrome.storage.local.get(keys, (result) => { + const error = chrome.runtime.lastError; + + if (error) { + reject(new Error(error.message)); + return; + } + + resolve(result || {}); + }); + }); +} + +function setStorage(values) { + return new Promise((resolve, reject) => { + chrome.storage.local.set(values, () => { + const error = chrome.runtime.lastError; + + if (error) { + reject(new Error(error.message)); + return; + } + + resolve(); + }); + }); +} + +function sendMessage(message) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(message, (response) => { + const error = chrome.runtime.lastError; + + if (error) { + reject(new Error(error.message)); + return; + } + + if (!response || response.ok !== true) { + reject(new Error(response && response.error ? response.error : "Die Aktion konnte nicht ausgefuehrt werden.")); + return; + } + + resolve(response); + }); + }); +} + +function populateRangeOptions() { + elements.rangeSelect.textContent = ""; + + Object.entries(RANGE_OPTIONS).forEach(([value, option]) => { + const item = document.createElement("option"); + item.value = value; + item.textContent = option.label; + elements.rangeSelect.appendChild(item); + }); +} + +function formatTimestamp(timestamp, fallbackText) { + if (!Number.isFinite(timestamp)) { + return fallbackText; + } + + const date = new Date(timestamp); + + if (Number.isNaN(date.getTime())) { + return fallbackText; + } + + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "short", + timeStyle: "medium" + }).format(date); +} + +function setMessage(text, state) { + elements.messageValue.textContent = text; + elements.messageValue.classList.remove("message-error", "message-success"); + + if (state === "error") { + elements.messageValue.classList.add("message-error"); + } + + if (state === "success") { + elements.messageValue.classList.add("message-success"); + } +} + +function setBusy(isBusy) { + elements.clearNowButton.disabled = isBusy; + elements.saveTimerButton.disabled = isBusy; + elements.clearTimerButton.disabled = isBusy; +} + +function renderStatus(lastRun, timerConfig) { + const timer = timerConfig || {}; + + elements.lastRunValue.textContent = formatTimestamp(Number(lastRun), "Noch nie"); + elements.timerStateValue.textContent = timer.enabled + ? `Aktiv (${timer.repeat ? "Wiederholung" : "einmalig"})` + : "Inaktiv"; + elements.nextRunValue.textContent = timer.enabled + ? formatTimestamp(Number(timer.nextRun), "Nicht geplant") + : "Nicht geplant"; +} + +function applyState(state) { + const selectedRange = RANGE_OPTIONS[state.selectedRange] ? state.selectedRange : DEFAULT_RANGE_KEY; + const timerConfig = state.timerConfig || {}; + + elements.rangeSelect.value = selectedRange; + + if (Number.isFinite(timerConfig.intervalValue) && timerConfig.intervalValue > 0) { + elements.intervalValue.value = String(timerConfig.intervalValue); + } + + if (UNIT_FACTORS[timerConfig.intervalUnit]) { + elements.intervalUnit.value = timerConfig.intervalUnit; + } + + elements.repeatCheckbox.checked = Boolean(timerConfig.repeat); + renderStatus(state.lastRun, timerConfig); +} + +async function loadState() { + const state = await getStorage(["selectedRange", "lastRun", "timerConfig"]); + applyState(state); +} + +function validateTimerInput() { + const rangeKey = elements.rangeSelect.value; + const intervalText = elements.intervalValue.value.trim(); + const intervalValue = Number(intervalText); + const intervalUnit = elements.intervalUnit.value; + + if (!RANGE_OPTIONS[rangeKey]) { + throw new Error("Bitte einen gueltigen Zeitraum auswaehlen."); + } + + if (intervalText === "" || !Number.isFinite(intervalValue)) { + throw new Error("Das Intervall muss eine Zahl sein."); + } + + if (intervalValue <= 0) { + throw new Error("Das Intervall muss groesser als 0 sein."); + } + + if (!UNIT_FACTORS[intervalUnit]) { + throw new Error("Bitte eine gueltige Einheit auswaehlen."); + } + + const intervalMinutes = intervalValue * UNIT_FACTORS[intervalUnit]; + + if (intervalMinutes < 1) { + throw new Error("Das Mindestintervall betraegt 1 Minute."); + } + + if (intervalMinutes > MAX_INTERVAL_MINUTES) { + throw new Error("Das Maximalintervall betraegt 30 Tage."); + } + + return { + rangeKey, + repeat: elements.repeatCheckbox.checked, + intervalValue, + intervalUnit, + intervalMinutes + }; +} + +async function saveSelectedRange() { + try { + await setStorage({ selectedRange: elements.rangeSelect.value }); + } catch (error) { + setMessage(error.message, "error"); + } +} + +async function handleClearNow() { + setBusy(true); + setMessage("Cache wird geloescht.", "success"); + + try { + const rangeKey = elements.rangeSelect.value; + await sendMessage({ + type: "CLEAR_CACHE", + rangeKey + }); + await loadState(); + setMessage("Cache wurde geleert.", "success"); + } catch (error) { + setMessage(error.message, "error"); + } finally { + setBusy(false); + } +} + +async function handleSaveTimer() { + setBusy(true); + + try { + const config = validateTimerInput(); + await sendMessage({ + type: "SET_TIMER", + config + }); + await loadState(); + setMessage("Timer gespeichert.", "success"); + } catch (error) { + setMessage(error.message, "error"); + } finally { + setBusy(false); + } +} + +async function handleClearTimer() { + setBusy(true); + + try { + await sendMessage({ type: "CLEAR_TIMER" }); + await loadState(); + setMessage("Timer deaktiviert.", "success"); + } catch (error) { + setMessage(error.message, "error"); + } finally { + setBusy(false); + } +} + +function cacheElements() { + elements.rangeSelect = document.getElementById("rangeSelect"); + elements.clearNowButton = document.getElementById("clearNowButton"); + elements.intervalValue = document.getElementById("intervalValue"); + elements.intervalUnit = document.getElementById("intervalUnit"); + elements.repeatCheckbox = document.getElementById("repeatCheckbox"); + elements.saveTimerButton = document.getElementById("saveTimerButton"); + elements.clearTimerButton = document.getElementById("clearTimerButton"); + elements.lastRunValue = document.getElementById("lastRunValue"); + elements.timerStateValue = document.getElementById("timerStateValue"); + elements.nextRunValue = document.getElementById("nextRunValue"); + elements.messageValue = document.getElementById("messageValue"); +} + +function bindEvents() { + elements.rangeSelect.addEventListener("change", saveSelectedRange); + elements.clearNowButton.addEventListener("click", handleClearNow); + elements.saveTimerButton.addEventListener("click", handleSaveTimer); + elements.clearTimerButton.addEventListener("click", handleClearTimer); + + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== "local") { + return; + } + + if (changes.lastRun || changes.timerConfig || changes.selectedRange) { + loadState().catch((error) => { + setMessage(error.message, "error"); + }); + } + }); +} + +async function init() { + cacheElements(); + populateRangeOptions(); + bindEvents(); + + try { + await loadState(); + } catch (error) { + setMessage(error.message, "error"); + } +} + +document.addEventListener("DOMContentLoaded", init);