Compare commits

..

43 Commits

Author SHA1 Message Date
kelly 74b2b2b8ab Merge pull request 'V0.1 release' (#11) from v0.1 into main
Security / security (push) Successful in 59s
Reviewed-on: #11
2026-06-02 20:49:07 -05:00
kelly 2d45156a08 updated readme for v0.1 release
Security / security (push) Successful in 58s
Security / security (pull_request) Successful in 53s
2026-06-02 20:45:52 -05:00
kelly 075c6079c3 resolving #10
Security / security (push) Successful in 58s
completing #8
2026-06-02 20:35:10 -05:00
kelly 68be07e01d resolving #9
Security / security (push) Successful in 1m2s
2026-06-02 20:30:58 -05:00
kelly a3c65ecdef added detailed handling of event types
Security / security (push) Successful in 1m10s
2026-06-02 20:20:15 -05:00
kelly e343545254 simple handling of events
Security / security (push) Successful in 57s
2026-05-31 20:30:37 -05:00
kelly 76052a87e4 broke apprise notification function into its own module to be used by multiple scripts
Security / security (push) Successful in 58s
2026-05-31 20:23:49 -05:00
kelly d0ac1d8e4c timezone bugs, changed webhook listener to use + since posh doesn't allow 0.0.0.0
Security / security (push) Successful in 58s
2026-05-31 20:06:54 -05:00
kelly 9a1b61cfd1 corrected listening url from localhost to all adapters
Security / security (push) Successful in 1m2s
2026-05-31 19:59:34 -05:00
kelly 1e01db0889 more time zone corrections and removing the loop in the notifier script as it now runs via a cron schedule
Security / security (push) Successful in 1m1s
2026-05-31 19:52:09 -05:00
kelly d3d22892d2 updated dockerfile, example compose, and corrected typo
Security / security (push) Successful in 1m4s
2026-05-31 18:29:31 -05:00
kelly 505fe5fa7d correcting dockerfile errors
Security / security (push) Successful in 42s
2026-05-30 19:58:33 -05:00
kelly 7bce5cabc9 first attempt at a webhook consumer
Security / security (push) Failing after 1m1s
2026-05-30 19:54:46 -05:00
kelly 7a0506bb5f correctly changing task due timestamp to local time instead of leaving it in UTC
Security / security (push) Successful in 2m14s
fixes #7
2026-05-27 20:38:47 -05:00
kelly 55a14dbc4b updates to readme 2026-05-14 21:54:55 -05:00
kelly 3a39a39be8 allowing for setting timezone
Security / security (push) Successful in 2m5s
2026-05-14 21:20:00 -05:00
kelly 1dc15708b9 correcting notification times not being seen as ints
Security / security (push) Successful in 1m4s
2026-05-13 21:36:58 -05:00
kelly adf42df4a7 fixed a bug for when the next notification time is tomorrow
Security / security (push) Successful in 1m6s
2026-05-13 21:21:06 -05:00
kelly 998f639a55 enable looping so the script can handle scheduling
Security / security (push) Successful in 1m7s
2026-05-13 21:09:52 -05:00
kelly 90a2805339 first draft of readme 2026-05-13 13:52:05 -05:00
kelly 160d180b00 Merge pull request 'adding security tool stack, pre-commit steps, and gitignore additions' (#6) from fresh-copy into main
Security / security (push) Successful in 58s
Reviewed-on: #6
2026-05-13 11:15:46 -05:00
kelly 1f80031715 optimized pipeline to only build the docker image once
Security / security (push) Successful in 1m8s
Security / security (pull_request) Successful in 55s
2026-05-13 11:11:47 -05:00
kelly 8046e78bb6 fixing build pipeline to do security scan and docker build together
Security / security (push) Successful in 59s
Security / build (push) Successful in 2m13s
2026-05-13 11:00:41 -05:00
kelly b92dd4ee30 resolving DS-0017 finding
Security / security (push) Successful in 2m11s
2026-05-13 10:53:02 -05:00
kelly ef66b632b5 added forced OS updates within the docker container
Security / security (push) Failing after 2m10s
2026-05-13 10:47:15 -05:00
kelly 9bd06a85ab updating dockerfile to use an ubuntu 24.04 based image to resolve security vulnerabilities
Security / security (push) Failing after 1m11s
2026-05-13 10:41:48 -05:00
kelly 4d711859ac fixing security scan mount points
Security / security (push) Failing after 57s
2026-05-13 08:00:08 -05:00
kelly d32cc1c1db updating security scan as required before build and issue creation for findings
Security / security (push) Failing after 54s
2026-05-13 07:47:03 -05:00
kelly acce7f7424 updated security actions to include all branches
Build and Push Docker Image / build (push) Successful in 29s
Security / security (push) Failing after 1m27s
2026-05-12 22:38:29 -05:00
kelly 9e78e4ab66 adding security tool stack, pre-commit steps, and gitignore additions
Build and Push Docker Image / build (push) Successful in 32s
2026-05-12 22:35:49 -05:00
kelly e6464dfdf8 Merge pull request 'alpha testing complete' (#1) from alpha-testing into main
Build and Push Docker Image / build (push) Successful in 35s
Reviewed-on: #1
2026-05-11 17:17:04 -05:00
kelly ed122d1fee migrated api handling to missio for security 2026-05-11 17:15:08 -05:00
kelly 0a5268d18b updating build to use secret for notification url 2026-05-11 16:52:34 -05:00
kelly 0d7842da4e correcting gitignore 2026-05-11 16:44:44 -05:00
kelly 4d3421949e Delete .postmate/postmate-history.json 2026-05-11 16:42:23 -05:00
kelly 27e31ac2a0 updated api call to use variables 2026-05-11 06:16:59 -05:00
kelly 5cc265583d updating dockerfile for best practices 2026-05-10 22:34:59 -05:00
kelly 8d37b966ae changing escape to backtick for newlines 2026-05-07 21:37:09 -05:00
kelly 1957e17680 fighting escapes 2026-05-07 17:03:27 -05:00
kelly 84f1360f69 corrected newline escape character and enforced datetime format 2026-05-07 16:53:40 -05:00
kelly 1472501a1f grouped notifications into a single notification instead of one per task 2026-05-07 16:49:14 -05:00
kelly 6658043fcc first mvp 2026-05-06 22:50:23 -05:00
kelly 66b9730108 skeleton ready 2026-05-06 22:24:34 -05:00
17 changed files with 887 additions and 112 deletions
+10 -22
View File
@@ -1,9 +1,10 @@
name: Build and Push Docker Image name: Build and Push Docker Image
on: on:
push: workflow_dispatch:
branches:
- "**" permissions:
contents: read
jobs: jobs:
build: build:
@@ -13,20 +14,6 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# - name: Run Trivy vulnerability scanner in repo mode
# uses: aquasecurity/trivy-action@v0.36.0
# with:
# scan-type: 'fs'
# ignore-unfixed: true
# format: 'sarif'
# output: 'trivy-results.sarif'
# severity: 'CRITICAL'
# - name: Upload Trivy scan results to GitHub Security tab
# uses: github/codeql-action/upload-sarif@v4
# with:
# sarif_file: 'trivy-results.sarif'
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -49,7 +36,8 @@ jobs:
TAG="test" TAG="test"
fi fi
echo "tag=$TAG" >> $GITHUB_OUTPUT echo "tag=$TAG" >> "$GITEA_OUTPUT"
echo "branch=$BRANCH" >> "$GITEA_OUTPUT"
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -66,9 +54,9 @@ jobs:
-d "{ -d "{
\"tags\": \"all\", \"tags\": \"all\",
\"title\": \"Gitea Build Succeeded\", \"title\": \"Gitea Build Succeeded\",
\"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ gitea.ref_name }}\\nImage tag built successfully\" \"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ steps.tag.outputs.branch }}\\nImage tag built successfully\"
}" \ }" \
http://10.47.0.213:4444/notify/926263506803e21d72e382edd0caf3fb510a9629d860601dfb79506b5758c133 ${{ secrets.APPRISE_URL }}
- name: Notify Apprise (failure) - name: Notify Apprise (failure)
if: failure() if: failure()
@@ -78,6 +66,6 @@ jobs:
-d "{ -d "{
\"tags\": \"all\", \"tags\": \"all\",
\"title\": \"Gitea Build Failed\", \"title\": \"Gitea Build Failed\",
\"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ gitea.ref_name }}\\nCheck logs in Gitea\" \"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ steps.tag.outputs.branch }}\\nCheck logs in Gitea\"
}" \ }" \
http://10.47.0.213:4444/notify/926263506803e21d72e382edd0caf3fb510a9629d860601dfb79506b5758c133 ${{ secrets.APPRISE_URL }}
+400
View File
@@ -0,0 +1,400 @@
name: Security
on:
pull_request:
push:
branches:
- "**"
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
security:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare security reports
run: |
mkdir -p security-results
- name: Compute image tag
id: tag
run: |
BRANCH="${{ gitea.ref_name }}"
if [ "$BRANCH" = "main" ]; then
TAG="latest"
elif [[ "$BRANCH" == v* ]]; then
TAG="$BRANCH"
else
TAG="test"
fi
echo "tag=$TAG" >> "$GITEA_OUTPUT"
###########################################################
# GITLEAKS
###########################################################
- name: Gitleaks
run: |
set +e
cid="$(docker create \
ghcr.io/gitleaks/gitleaks:latest \
detect \
--source /repo \
--report-format json \
--report-path /repo/security-results/gitleaks.json \
--exit-code 1)"
docker cp . "$cid:/repo"
docker start -a "$cid"
status="$?"
docker cp "$cid:/repo/security-results/gitleaks.json" security-results/gitleaks.json || true
docker rm -f "$cid" >/dev/null 2>&1 || true
echo "$status" > security-results/gitleaks.exit
###########################################################
# SEMGREP
###########################################################
- name: Semgrep
run: |
set +e
cid="$(docker create \
--workdir /src \
semgrep/semgrep \
semgrep scan \
--config auto \
--json \
--output /src/security-results/semgrep.json \
--error \
/src)"
docker cp . "$cid:/src"
docker start -a "$cid"
status="$?"
docker cp "$cid:/src/security-results/semgrep.json" security-results/semgrep.json || true
docker rm -f "$cid" >/dev/null 2>&1 || true
echo "$status" > security-results/semgrep.exit
###########################################################
# TRIVY FS
###########################################################
- name: Trivy Filesystem
run: |
set +e
cid="$(docker create \
aquasec/trivy:latest \
fs \
--scanners vuln,secret,misconfig \
--severity HIGH,CRITICAL \
--format json \
--output /workspace/security-results/trivy-fs.json \
--exit-code 1 \
/workspace)"
docker cp . "$cid:/workspace"
docker start -a "$cid"
status="$?"
docker cp "$cid:/workspace/security-results/trivy-fs.json" security-results/trivy-fs.json || true
docker rm -f "$cid" >/dev/null 2>&1 || true
echo "$status" > security-results/trivy-fs.exit
###########################################################
# DOCKER IMAGE BUILD
###########################################################
- name: Build Image for scan
run: |
set +e
docker build \
-t app:${{ gitea.sha }} \
-t blinkfink182/donetick-notifier:${{ steps.tag.outputs.tag }} \
.
echo "$?" > security-results/docker-build.exit
###########################################################
# TRIVY IMAGE
###########################################################
- name: Trivy Image Scan
run: |
set +e
if [ "$(cat security-results/docker-build.exit)" != "0" ]; then
echo "Skipping image scan because the image build failed."
echo "1" > security-results/trivy-image.exit
exit 0
fi
cid="$(docker create \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest \
image \
--severity HIGH,CRITICAL \
--format json \
--output /tmp/trivy-image.json \
--exit-code 1 \
app:${{ gitea.sha }})"
docker start -a "$cid"
status="$?"
docker cp "$cid:/tmp/trivy-image.json" security-results/trivy-image.json || true
docker rm -f "$cid" >/dev/null 2>&1 || true
echo "$status" > security-results/trivy-image.exit
- name: Create Gitea issues for security findings
if: always()
env:
GITEA_API_URL: ${{ gitea.api_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_RUN_ID: ${{ gitea.run_id }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_SHA: ${{ gitea.sha }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
python3 <<'PY'
import json
import os
import urllib.parse
import urllib.request
token = os.environ.get("GITEA_TOKEN", "")
api_url = os.environ["GITEA_API_URL"].rstrip("/")
repo = os.environ["GITEA_REPOSITORY"]
ref_name = os.environ.get("GITEA_REF_NAME", "")
sha = os.environ.get("GITEA_SHA", "")
run_id = os.environ.get("GITEA_RUN_ID", "")
server_url = os.environ.get("GITEA_SERVER_URL", "").rstrip("/")
if not token:
print("GITEA_TOKEN is unavailable; skipping issue creation.")
raise SystemExit(0)
owner, name = repo.split("/", 1)
issues_url = f"{api_url}/repos/{urllib.parse.quote(owner)}/{urllib.parse.quote(name)}/issues"
headers = {
"Authorization": f"token {token}",
"Content-Type": "application/json",
}
def load_json(path, fallback):
try:
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
except FileNotFoundError:
return fallback
except json.JSONDecodeError as exc:
print(f"Could not parse {path}: {exc}")
return fallback
def trim(value, limit):
value = str(value or "unknown")
return value if len(value) <= limit else value[: limit - 3] + "..."
def find_existing(title):
query = urllib.parse.urlencode({"state": "open", "type": "issues", "q": title})
request = urllib.request.Request(f"{issues_url}?{query}", headers=headers)
with urllib.request.urlopen(request, timeout=30) as response:
issues = json.load(response)
return any(issue.get("title") == title for issue in issues)
def create_issue(title, body):
if find_existing(title):
print(f"Open issue already exists: {title}")
return
payload = json.dumps({"title": title, "body": body}).encode("utf-8")
request = urllib.request.Request(issues_url, data=payload, headers=headers, method="POST")
with urllib.request.urlopen(request, timeout=30) as response:
created = json.load(response)
print(f"Created issue #{created.get('number')}: {title}")
def body(scanner, summary, details):
run_url = f"{server_url}/{repo}/actions/runs/{run_id}" if server_url and run_id else "Unavailable"
lines = [
f"Security scanner: `{scanner}`",
f"Summary: {summary}",
f"Repository: `{repo}`",
f"Branch/ref: `{ref_name}`",
f"Commit: `{sha}`",
f"Action run: {run_url}",
"",
"Details:",
]
lines.extend(f"- {key}: {value}" for key, value in details if value not in (None, ""))
return "\n".join(lines)
findings = []
for finding in load_json("security-results/gitleaks.json", []):
rule = finding.get("RuleID") or finding.get("Description") or "secret"
file_path = finding.get("File", "unknown")
line = finding.get("StartLine", "unknown")
title = trim(f"[security][gitleaks] {rule} in {file_path}:{line}", 255)
findings.append((
title,
body("gitleaks", f"{rule} in `{file_path}:{line}`", [
("Rule", rule),
("Description", finding.get("Description")),
("File", file_path),
("Line", line),
("Fingerprint", finding.get("Fingerprint")),
]),
))
semgrep = load_json("security-results/semgrep.json", {})
for result in semgrep.get("results", []):
extra = result.get("extra", {})
start = result.get("start", {})
check_id = result.get("check_id", "semgrep finding")
file_path = result.get("path", "unknown")
line = start.get("line", "unknown")
title = trim(f"[security][semgrep] {check_id} in {file_path}:{line}", 255)
findings.append((
title,
body("semgrep", extra.get("message", check_id), [
("Check", check_id),
("Severity", extra.get("severity")),
("File", file_path),
("Line", line),
("Message", extra.get("message")),
]),
))
def add_trivy_findings(path, scanner):
report = load_json(path, {})
for result in report.get("Results", []):
target = result.get("Target", "unknown")
for vuln in result.get("Vulnerabilities", []) or []:
vuln_id = vuln.get("VulnerabilityID", "vulnerability")
package = vuln.get("PkgName", "unknown package")
severity = vuln.get("Severity", "UNKNOWN")
title = trim(f"[security][{scanner}] {severity} {vuln_id} in {package} on {target}", 255)
findings.append((
title,
body(scanner, vuln.get("Title") or vuln_id, [
("Type", "Vulnerability"),
("Severity", severity),
("Target", target),
("Package", package),
("Installed version", vuln.get("InstalledVersion")),
("Fixed version", vuln.get("FixedVersion")),
("Vulnerability ID", vuln_id),
("Primary URL", vuln.get("PrimaryURL")),
]),
))
for misconfig in result.get("Misconfigurations", []) or []:
misconfig_id = misconfig.get("ID", "misconfiguration")
severity = misconfig.get("Severity", "UNKNOWN")
title = trim(f"[security][{scanner}] {severity} {misconfig_id} in {target}", 255)
findings.append((
title,
body(scanner, misconfig.get("Title") or misconfig_id, [
("Type", "Misconfiguration"),
("Severity", severity),
("Target", target),
("ID", misconfig_id),
("Message", misconfig.get("Message")),
("Resolution", misconfig.get("Resolution")),
]),
))
for secret in result.get("Secrets", []) or []:
rule = secret.get("RuleID", "secret")
line = secret.get("StartLine", "unknown")
title = trim(f"[security][{scanner}] {rule} in {target}:{line}", 255)
findings.append((
title,
body(scanner, f"{rule} in `{target}:{line}`", [
("Type", "Secret"),
("Rule", rule),
("Category", secret.get("Category")),
("Severity", secret.get("Severity")),
("Target", target),
("Line", line),
]),
))
add_trivy_findings("security-results/trivy-fs.json", "trivy-fs")
add_trivy_findings("security-results/trivy-image.json", "trivy-image")
if not findings:
print("No security findings found in scanner reports.")
raise SystemExit(0)
for title, issue_body in findings:
try:
create_issue(title, issue_body)
except Exception as exc:
print(f"Failed to create issue for {title}: {exc}")
PY
- name: Stop on failed security scans
if: always()
run: |
failed=0
for result in security-results/*.exit; do
name="$(basename "$result" .exit)"
code="$(cat "$result")"
if [ "$code" != "0" ]; then
echo "$name failed with exit code $code"
failed=1
fi
done
if [ "$failed" != "0" ]; then
exit 1
fi
- name: Notify Apprise (failure)
if: failure()
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{
\"tags\": \"all\",
\"title\": \"Gitea Security Scan Failed\",
\"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ gitea.ref_name }}\\nSecurity scan failed; check logs and generated issues in Gitea\"
}" \
${{ secrets.APPRISE_URL }}
- name: Login to Docker Hub
id: docker-login
if: ${{ gitea.event_name == 'push' }}
run: |
echo "${{ secrets.DOCKER_PASSWORD }}" | docker login \
--username "${{ secrets.DOCKER_USERNAME }}" \
--password-stdin
- name: Push scanned image
id: push
if: ${{ gitea.event_name == 'push' }}
run: |
docker push blinkfink182/donetick-notifier:${{ steps.tag.outputs.tag }}
- name: Notify Apprise (success)
if: ${{ success() && gitea.event_name == 'push' }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{
\"tags\": \"all\",
\"title\": \"Gitea Build Succeeded\",
\"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ gitea.ref_name }}\\nImage tag ${{ steps.tag.outputs.tag }} pushed successfully\"
}" \
${{ secrets.APPRISE_URL }}
- name: Notify Apprise (failure)
if: ${{ failure() && (steps.docker-login.outcome == 'failure' || steps.push.outcome == 'failure') }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{
\"tags\": \"all\",
\"title\": \"Gitea Build Failed\",
\"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ gitea.ref_name }}\\nCheck logs in Gitea\"
}" \
${{ secrets.APPRISE_URL }}
+16
View File
@@ -4,3 +4,19 @@
debug.ps1 debug.ps1
.postmate/postmate-history.json .postmate/postmate-history.json
.postmate/postmate-envs.json .postmate/postmate-envs.json
# Secrets
.env
.env.*
*.pem
*.key
*.pfx
*.p12
secrets/
credentials/
config.local.*
# VS Code local settings
.vscode/settings.json
# PowerShell
*.psd1.local
-39
View File
@@ -1,39 +0,0 @@
{
"id": "7214af45-3f3b-4b7b-a9b7-ef6dbca83ab5",
"collectionName": "Donetick API",
"creatredOn": "2026-05-06T22:35:56.440Z",
"folders": [],
"requests": [
{
"id": "2ca9a16c-8b4d-4620-8466-15daa317f7ad",
"collectionId": "7214af45-3f3b-4b7b-a9b7-ef6dbca83ab5",
"containerId": "7214af45-3f3b-4b7b-a9b7-ef6dbca83ab5",
"collectionName": "Donetick API",
"name": "Get Chores",
"url": "https://todo.ktr32.org/eapi/v1/chore",
"method": "GET",
"headers": {
"accept": "*/*",
"user-agent": "https://postmateclient.com",
"secretkey": "78e7fe13c453664e0485c50ae6f2a29748ed750bbd89a7c0f79c7e95ce3b493d"
},
"body": {
"json": {}
},
"tests": [],
"bodyFromat": "json",
"testScript": "",
"bodyFormat": "json",
"preRequestScript": "",
"preRequests": [],
"auth": {
"type": "apikey",
"key": "secretkey",
"value": "{{secretkey}}",
"addTo": "header"
},
"cookies": [],
"dataTags": []
}
]
}
-7
View File
@@ -1,7 +0,0 @@
[
{
"key": "content-type",
"value": "application/json",
"default": false
}
]
+29
View File
@@ -0,0 +1,29 @@
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.2
hooks:
- id: gitleaks
- repo: local
hooks:
- id: semgrep
name: semgrep
entry: bash -c 'docker run --rm -v "$(realpath .)":/src:Z docker.io/semgrep/semgrep semgrep scan --config auto /src'
language: system
pass_filenames: false
- repo: local
hooks:
- id: trivy
name: trivy filesystem scan
entry: bash -c 'docker run --rm -v "$(pwd)":/workspace docker.io/aquasec/trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL /workspace'
language: system
pass_filenames: false
- repo: local
hooks:
- id: psscriptanalyzer
name: powershell static analysis
entry: pwsh -NoProfile -Command "Import-Module PSScriptAnalyzer; Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error"
language: system
pass_filenames: false
+2 -1
View File
@@ -1,3 +1,4 @@
{ {
"trivy.secretScanning": true "trivy.secretScanning": true,
"semgrep.scan.onlyGitDirty": false
} }
+33
View File
@@ -0,0 +1,33 @@
[string] $appriseWebhookURL = $ENV:APPRISEWEBHOOKURL
[string] $appriseWebhookTag = $ENV:APPRISEWEBHOOKTAG
function Send-AppriseNotification
{
param(
# Title of the notification
[Parameter(Mandatory=$true)]
[string]
$title,
# Content of the notification
[Parameter(Mandatory=$true)]
[string]
$content
)
$headers = @{
"Content-Type"="application/json"
}
$body = @{
title=$title
body=$content
tags="$appriseWebhookTag"
}
$json = $body | ConvertTo-Json
try {
Invoke-WebRequest -URI $appriseWebhookURL -Method post -Body $json -Headers $headers
}
catch {
Write-Host "Notification Error Encountered: $($global:Error[0])"
}
}
+18 -3
View File
@@ -1,8 +1,23 @@
FROM mcr.microsoft.com/powershell FROM mcr.microsoft.com/powershell:7.5-ubuntu-24.04
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata wget \
&& wget -q https://github.com/aptible/supercronic/releases/latest/download/supercronic-linux-amd64 -O /usr/local/bin/supercronic \
&& chmod +x /usr/local/bin/supercronic \
&& apt-get dist-upgrade -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
USER 1000:1000 USER 1000:1000
WORKDIR /data WORKDIR /data
COPY ["Start-DoneTickNotifier.ps1", "/data/"] COPY ["crontab", "/data/"]
COPY --chmod=755 ["main.sh", "/data/"]
COPY ["Start-DoneTickNotifier.ps1", "Start-DoneTickConsumer.ps1", "AppriseNotification.psm1", "/data/"]
ENTRYPOINT ["pwsh", "-Command", "/data/Start-DoneTickNotifier.ps1"] ENTRYPOINT ["/data/main.sh"]
EXPOSE 8080
+20
View File
@@ -0,0 +1,20 @@
info:
name: Get-Chore
type: http
seq: 1
http:
method: GET
url: "{{baseUrl}}/eapi/v1/chore"
headers: []
params: []
settings:
encodeUrl: true
timeout: 30000
followRedirects: true
maxRedirects: 5
runtime:
auth:
type: apikey
key: secretkey
value: "{{secretkey}}"
placement: header
+13
View File
@@ -0,0 +1,13 @@
opencollection: 1.0.0
info:
name: Donetick
version: 1.0.0
config:
environments:
- name: Production
variables:
- name: baseUrl
value: https://todo.ktr32.org
- name: secretkey
secret: true
items: []
+168
View File
@@ -1,2 +1,170 @@
# donetick-notifier # donetick-notifier
> AI disclaimer: AI was used solely for creating this README, the Gitea pipelines, and the base of the webhook consumer.
A PowerShell-based notifier and webhook consumer for [Donetick](https://github.com/donetick/donetick) chores.
The project now has two runtime paths:
- A scheduled notifier that checks the Donetick external API for chores and sends Apprise-compatible summary notifications for overdue and due-today tasks.
- A webhook consumer that listens on port `8080`, accepts Donetick webhook payloads, and turns them into Apprise notifications.
## Features
- Fetches chores from `https://<DONETICKHOST>:<DONETICKPORT>/eapi/v1/chore`.
- Authenticates to Donetick with the `secretkey` header.
- Sends one notification for overdue chores when any exist.
- Sends one notification for chores due today when any exist.
- Accepts webhook events for `task.completed`, `task.reminder`, and `task.created`.
- Falls back to a generic notification for unknown webhook event types.
- Runs the scheduler and webhook listener together in the Docker container.
- Includes Gitea pipelines for Docker image publishing and security scanning.
## How It Runs
The container entrypoint is `main.sh`.
- `supercronic` runs `Start-DoneTickNotifier.ps1` on the schedule defined by `JOB_SCHEDULE`.
- `Start-DoneTickConsumer.ps1` runs in the foreground and listens on `http://+:8080/`.
The notifier and consumer both use `AppriseNotification.psm1` to send messages to the configured Apprise webhook.
## Configuration
All configuration is provided through environment variables.
| Variable | Required | Description |
| --- | --- | --- |
| `DONETICKHOST` | Yes | Donetick host name or IP address. Do not include `https://`. |
| `DONETICKPORT` | Yes | Donetick HTTPS port. |
| `DONETICKAPIKEY` | Yes | Donetick external API key. Sent as the `secretkey` header. |
| `APPRISEWEBHOOKURL` | Yes | Apprise webhook URL that accepts notification posts. |
| `APPRISEWEBHOOKTAG` | Yes | Apprise tag value to include with each notification. |
| `JOB_SCHEDULE` | Yes for Docker | Cron expression used by the container scheduler, such as `0 8,17 * * *`. |
| `TZ` | No | Container timezone, such as `America/Chicago`. Recommended so notification hours match your local time. |
## Docker
The published image is `docker.io/blinkfink182/donetick-notifier`.
### Run
```sh
docker run -d \
--name donetick-notifier \
-p 8080:8080 \
-e DONETICKHOST=host.docker.internal \
-e DONETICKPORT=8787 \
-e DONETICKAPIKEY=your-donetick-api-key \
-e APPRISEWEBHOOKURL=https://apprise.example.com/notify/config \
-e APPRISEWEBHOOKTAG=all \
-e JOB_SCHEDULE="0 8,17 * * *" \
-e TZ=America/Chicago \
docker.io/blinkfink182/donetick-notifier
```
### Docker Compose
```yaml
services:
donetick-notifier:
container_name: donetick-notifier
image: docker.io/blinkfink182/donetick-notifier
ports:
- 8080:8080
environment:
- DONETICKHOST=host.docker.internal
- DONETICKPORT=8787
- DONETICKAPIKEY=your-donetick-api-key
- APPRISEWEBHOOKURL=https://apprise.example.com/notify/config
- APPRISEWEBHOOKTAG=all
- JOB_SCHEDULE=0 8,17 * * *
- TZ=America/Chicago
```
Run it with:
```sh
docker compose up -d
```
If Donetick and Apprise are on the same Docker network, use service names instead of `host.docker.internal`.
```yaml
services:
donetick-notifier:
image: docker.io/blinkfink182/donetick-notifier
ports:
- 8080:8080
environment:
- DONETICKHOST=donetick
- DONETICKPORT=8787
- DONETICKAPIKEY=your-donetick-api-key
- APPRISEWEBHOOKURL=http://apprise:8000/notify/config
- APPRISEWEBHOOKTAG=all
- JOB_SCHEDULE=0 8,17 * * *
- TZ=America/Chicago
```
## Build Locally
```sh
docker build -t donetick-notifier .
```
```sh
docker run -d \
--name donetick-notifier \
-p 8080:8080 \
-e DONETICKHOST=host.docker.internal \
-e DONETICKPORT=8787 \
-e DONETICKAPIKEY=your-donetick-api-key \
-e APPRISEWEBHOOKURL=https://apprise.example.com/notify/config \
-e APPRISEWEBHOOKTAG=all \
-e JOB_SCHEDULE="0 8,17 * * *" \
-e TZ=America/Chicago \
donetick-notifier
```
## Run Without Docker
PowerShell 7 or newer is recommended.
Scheduled notifier:
```powershell
$env:DONETICKHOST = "donetick.example.com"
$env:DONETICKPORT = "8787"
$env:DONETICKAPIKEY = "your-donetick-api-key"
$env:APPRISEWEBHOOKURL = "https://apprise.example.com/notify/config"
$env:APPRISEWEBHOOKTAG = "all"
pwsh ./Start-DoneTickNotifier.ps1
```
Webhook consumer:
```powershell
$env:APPRISEWEBHOOKURL = "https://apprise.example.com/notify/config"
$env:APPRISEWEBHOOKTAG = "all"
pwsh ./Start-DoneTickConsumer.ps1
```
The consumer listens on port `8080` by default. Stop either script with `Ctrl+C` when running interactively.
## CI/CD
This repository includes Gitea workflows for:
- Building and pushing the Docker image on manual dispatch.
- Tagging images as `latest` for `main`, as the ref name for `v*` tags, and as `test` for everything else.
- Running security checks with Gitleaks, Semgrep, and Trivy.
- Creating Gitea issues for security findings when `GITEA_TOKEN` is configured.
- Sending Apprise notifications for Docker build success or failure.
The security workflow runs on pushes, pull requests, and manual dispatch. On push, it also builds the image so Trivy can scan the resulting container.
## API References
The `Donetick/` directory contains request examples and collection metadata used to document and exercise the Donetick API while developing the notifier.
+152
View File
@@ -0,0 +1,152 @@
[string] $ListenUrl = "http://+:8080/"
Import-Module ./AppriseNotification.psm1
function Write-JsonResponse
{
param(
[Parameter(Mandatory=$true)]
[System.Net.HttpListenerResponse]
$Response,
[Parameter(Mandatory=$true)]
[int]
$StatusCode,
[Parameter(Mandatory=$true)]
[hashtable]
$Body
)
$json = $Body | ConvertTo-Json -Depth 10
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
$Response.StatusCode = $StatusCode
$Response.ContentType = "application/json"
$Response.ContentEncoding = [System.Text.Encoding]::UTF8
$Response.ContentLength64 = $bytes.Length
$Response.OutputStream.Write($bytes, 0, $bytes.Length)
$Response.OutputStream.Close()
}
function Read-RequestBody
{
param(
[Parameter(Mandatory=$true)]
[System.Net.HttpListenerRequest]
$Request
)
$reader = [System.IO.StreamReader]::new($Request.InputStream, $Request.ContentEncoding)
try {
return $reader.ReadToEnd()
}
finally {
$reader.Close()
}
}
function Receive-Webhook
{
param(
[Parameter(Mandatory=$true)]
[System.Net.HttpListenerContext]
$Context
)
$request = $Context.Request
$response = $Context.Response
if ($request.HttpMethod -ne "POST") {
Write-JsonResponse -Response $response -StatusCode 405 -Body @{
ok = $false
error = "Only POST requests are supported."
}
return
}
$rawBody = Read-RequestBody -Request $request
$payload = $null
try {
if ($rawBody) {
$payload = $rawBody | ConvertFrom-Json
}
}
catch {
Write-JsonResponse -Response $response -StatusCode 400 -Body @{
ok = $false
error = "Request body was not valid JSON."
}
return
}
Write-Host "Webhook received at $(Get-Date -Format o)"
Write-Host "From: $($request.RemoteEndPoint)"
Write-Host "Path: $($request.Url.AbsolutePath)"
if ($payload) {
Write-Host "Payload:"
Write-Host ($payload | ConvertTo-Json -Depth 20)
}
else {
Write-Host "Payload: <empty>"
}
$notificationTitle = ""
$notificationContent = ""
switch($payload.type)
{
"task.completed" {
$notificationTitle = "Donetick Task Completed"
$notificationContent = "$($payload.data.chore.name) marked completed!"
}
"task.reminder" {
$notificationTitle = "Donetick Task Reminder"
$notificationContent = "$($payload.data.name) is due at $((Get-Date $payload.data.due_date).ToLocalTime())"
}
"task.created" {
$notificationTitle = "Donetick Task Created"
if ($null -eq $payload.data.chore.nextDueDate)
{
$notificationContent = "$($payload.data.chore.name) created"
}
else
{
$notificationContent = "$($payload.data.chore.name) created with due date of $((Get-Date $payload.data.chore.nextDueDate).ToLocalTime())"
}
}
default {
$notificationTitle = "Donetick Notification"
$notificationContent = "Donetick event of type $($payload.type) received"
}
}
Send-AppriseNotification -title $notificationTitle -content $notificationContent
Write-JsonResponse -Response $response -StatusCode 200 -Body @{
ok = $true
message = "Webhook accepted."
receivedAt = (Get-Date -Format o)
}
}
$listener = [System.Net.HttpListener]::new()
$listener.Prefixes.Add($ListenUrl)
try {
$listener.Start()
Write-Host "Webhook consumer listening on $ListenUrl"
Write-Host "Press Ctrl+C to stop."
while ($listener.IsListening) {
$context = $listener.GetContext()
Receive-Webhook -Context $context
}
}
finally {
if ($listener.IsListening) {
$listener.Stop()
}
$listener.Close()
}
+6 -39
View File
@@ -1,40 +1,8 @@
[string] $dtHost = $ENV:DONETICKHOST [string] $dtHost = $ENV:DONETICKHOST
[string] $dtPort = $ENV:DONETICKPORT [string] $dtPort = $ENV:DONETICKPORT
[string] $dtAPIKey = $ENV:DONETICKAPIKEY [string] $dtAPIKey = $ENV:DONETICKAPIKEY
[string] $appriseWebhookURL = $ENV:APPRISEWEBHOOKURL
[string] $appriseWebhookTag = $ENV:APPRISEWEBHOOKTAG
Import-Module ./AppriseNotification.psm1
function Send-Notification
{
param(
# Title of the notification
[Parameter(Mandatory=$true)]
[string]
$title,
# Content of the notification
[Parameter(Mandatory=$true)]
[string]
$content
)
$headers = @{
"Content-Type"="application/json"
}
$body = @{
title=$title
body=$content
tags="$appriseWebhookTag"
}
$json = $body | ConvertTo-Json
try {
Invoke-WebRequest -URI $appriseWebhookURL -Method post -Body $json -Headers $headers
}
catch {
Write-Host "Notification Error Encountered: $($global:Error[0])"
}
}
function Get-Chores function Get-Chores
{ {
@@ -60,7 +28,7 @@ foreach($chore in $chores)
{ {
if ($chore.nextDueDate) if ($chore.nextDueDate)
{ {
$dueDate = Get-Date $chore.nextDueDate $dueDate = (Get-Date $chore.nextDueDate).ToLocalTime()
if (($dueDate - $today).Days -lt 0) #OVERDUE if (($dueDate - $today).Days -lt 0) #OVERDUE
{ {
write-host "$($chore.name) $dueDate is overdue!" write-host "$($chore.name) $dueDate is overdue!"
@@ -70,7 +38,6 @@ foreach($chore in $chores)
{ {
write-host "$($chore.name) $dueDate is due today!" write-host "$($chore.name) $dueDate is due today!"
$todaysTasks += $chore $todaysTasks += $chore
# Send-Notification -title "TASK DUE TODAY" -content "$($chore.Name) is due today!"
} }
else else
{ {
@@ -86,9 +53,9 @@ if ($overdueTasks.Count -ne 0)
$content = "The following tasks are overdue!`n" $content = "The following tasks are overdue!`n"
foreach($overdueTask in $overdueTasks) foreach($overdueTask in $overdueTasks)
{ {
$content += "$($overdueTask.Name) was due $(Get-Date $overdueTask.nextDueDate -Format "MM/dd/yyyy HH:mm")`n" $content += "$($overdueTask.Name) was due $((Get-Date $overdueTask.nextDueDate).ToLocalTime())`n"
} }
Send-Notification -title "OVERDUE TASKS" -content $content Send-AppriseNotification -title "OVERDUE TASKS" -content $content
} }
if ($todaysTasks.Count -ne 0) if ($todaysTasks.Count -ne 0)
@@ -97,7 +64,7 @@ if ($todaysTasks.Count -ne 0)
$content = "The following tasks are due today!`n" $content = "The following tasks are due today!`n"
foreach($task in $todaysTasks) foreach($task in $todaysTasks)
{ {
$content += "$($task.Name) is due $(Get-Date $task.nextDueDate -Format "MM/dd/yyyy HH:mm")`n" $content += "$($task.Name) is due $((Get-Date $task.nextDueDate).ToLocalTime())`n"
} }
Send-Notification -title "TODAY'S TASKS" -content $content Send-AppriseNotification -title "TODAY'S TASKS" -content $content
} }
+2
View File
@@ -0,0 +1,2 @@
# Default schedule (overridden at runtime by JOB_SCHEDULE env var)
0 * * * * pwsh /data/Start-DoneTickNotifier.ps1
+5 -1
View File
@@ -3,10 +3,14 @@ services:
donetick-notifier: donetick-notifier:
container_name: donetick-notifier container_name: donetick-notifier
image: docker.io/blinkfink182/donetick-notifier image: docker.io/blinkfink182/donetick-notifier
ports:
- 8080:8080
environment: environment:
# BELOW ARE ALL REQUIRED # BELOW ARE ALL REQUIRED
- DONETICKHOST=host.docker.internal # donetick host - DONETICKHOST=host.docker.internal # donetick host
- DONETICKPORT=8787 # donetick port - DONETICKPORT=8787 # donetick port
- DONETICKAPIKEY=adminpass # donetick API key - DONETICKAPIKEY=adminpass # donetick API key
- APPRISEWEBHOOKURL=https://apprisehost/notify/config # apprise notification url - APPRISEWEBHOOKURL=https://apprisehost/notify/config # apprise notification url
- APPRISEWEBHOOKTAG=all # apprise notification tag - APPRISEWEBHOOKTAG=all # apprise notification tag
- TZ=America/Chicago # set timezone from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
- JOB_SCHEDULE=0 8,17 * * * # when to notify about overdue tasks or tasks due today
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
set -e
# Build the crontab dynamically from the env variable
echo "${JOB_SCHEDULE} pwsh /data/Start-DoneTickNotifier.ps1" > /tmp/crontab-runtime
echo "Cron schedule set to: ${JOB_SCHEDULE}"
# Start supercronic in background using the generated crontab
supercronic /tmp/crontab-runtime &
# Start the HTTP listener in foreground
exec pwsh /data/Start-DoneTickConsumer.ps1