Compare commits

..

10 Commits

Author SHA1 Message Date
kelly cfa4fc005b correcting gitignore
Build and Push Docker Image / build (push) Successful in 29s
2026-05-11 16:44:44 -05:00
kelly f68297d231 Delete .postmate/postmate-history.json
Build and Push Docker Image / build (push) Successful in 31s
2026-05-11 16:42:23 -05:00
kelly 62680a0321 updated api call to use variables
Build and Push Docker Image / build (push) Successful in 37s
2026-05-11 06:16:59 -05:00
kelly dd81fcff6f updating dockerfile for best practices
Build and Push Docker Image / build (push) Successful in 29s
2026-05-10 22:34:59 -05:00
kelly 3799c3df33 changing escape to backtick for newlines
Build and Push Docker Image / build (push) Successful in 32s
2026-05-07 21:37:09 -05:00
kelly 985837bc2c fighting escapes
Build and Push Docker Image / build (push) Successful in 29s
2026-05-07 17:03:27 -05:00
kelly 5216703423 corrected newline escape character and enforced datetime format
Build and Push Docker Image / build (push) Successful in 36s
2026-05-07 16:53:40 -05:00
kelly 23d2e1da33 grouped notifications into a single notification instead of one per task
Build and Push Docker Image / build (push) Successful in 33s
2026-05-07 16:49:14 -05:00
kelly 55cb32ed6d first mvp
Build and Push Docker Image / build (push) Successful in 32s
2026-05-06 22:50:23 -05:00
kelly d06bed555c skeleton ready
Build and Push Docker Image / build (push) Successful in 1m2s
2026-05-06 22:24:34 -05:00
17 changed files with 112 additions and 887 deletions
+22 -10
View File
@@ -1,10 +1,9 @@
name: Build and Push Docker Image
on:
workflow_dispatch:
permissions:
contents: read
push:
branches:
- "**"
jobs:
build:
@@ -14,6 +13,20 @@ jobs:
- name: Checkout
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
uses: docker/setup-buildx-action@v3
@@ -36,8 +49,7 @@ jobs:
TAG="test"
fi
echo "tag=$TAG" >> "$GITEA_OUTPUT"
echo "branch=$BRANCH" >> "$GITEA_OUTPUT"
echo "tag=$TAG" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
@@ -54,9 +66,9 @@ jobs:
-d "{
\"tags\": \"all\",
\"title\": \"Gitea Build Succeeded\",
\"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ steps.tag.outputs.branch }}\\nImage tag built successfully\"
\"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ gitea.ref_name }}\\nImage tag built successfully\"
}" \
${{ secrets.APPRISE_URL }}
http://10.47.0.213:4444/notify/926263506803e21d72e382edd0caf3fb510a9629d860601dfb79506b5758c133
- name: Notify Apprise (failure)
if: failure()
@@ -66,6 +78,6 @@ jobs:
-d "{
\"tags\": \"all\",
\"title\": \"Gitea Build Failed\",
\"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ steps.tag.outputs.branch }}\\nCheck logs in Gitea\"
\"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ gitea.ref_name }}\\nCheck logs in Gitea\"
}" \
${{ secrets.APPRISE_URL }}
http://10.47.0.213:4444/notify/926263506803e21d72e382edd0caf3fb510a9629d860601dfb79506b5758c133
-400
View File
@@ -1,400 +0,0 @@
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,19 +4,3 @@
debug.ps1
.postmate/postmate-history.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
@@ -0,0 +1,39 @@
{
"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
@@ -0,0 +1,7 @@
[
{
"key": "content-type",
"value": "application/json",
"default": false
}
]
-29
View File
@@ -1,29 +0,0 @@
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
+1 -2
View File
@@ -1,4 +1,3 @@
{
"trivy.secretScanning": true,
"semgrep.scan.onlyGitDirty": false
"trivy.secretScanning": true
}
-33
View File
@@ -1,33 +0,0 @@
[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])"
}
}
+3 -18
View File
@@ -1,23 +1,8 @@
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/*
FROM mcr.microsoft.com/powershell
USER 1000:1000
WORKDIR /data
COPY ["crontab", "/data/"]
COPY --chmod=755 ["main.sh", "/data/"]
COPY ["Start-DoneTickNotifier.ps1", "Start-DoneTickConsumer.ps1", "AppriseNotification.psm1", "/data/"]
COPY ["Start-DoneTickNotifier.ps1", "/data/"]
ENTRYPOINT ["/data/main.sh"]
EXPOSE 8080
ENTRYPOINT ["pwsh", "-Command", "/data/Start-DoneTickNotifier.ps1"]
-20
View File
@@ -1,20 +0,0 @@
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
@@ -1,13 +0,0 @@
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,170 +1,2 @@
# 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
@@ -1,152 +0,0 @@
[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()
}
+39 -6
View File
@@ -1,8 +1,40 @@
[string] $dtHost = $ENV:DONETICKHOST
[string] $dtPort = $ENV:DONETICKPORT
[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
{
@@ -28,7 +60,7 @@ foreach($chore in $chores)
{
if ($chore.nextDueDate)
{
$dueDate = (Get-Date $chore.nextDueDate).ToLocalTime()
$dueDate = Get-Date $chore.nextDueDate
if (($dueDate - $today).Days -lt 0) #OVERDUE
{
write-host "$($chore.name) $dueDate is overdue!"
@@ -38,6 +70,7 @@ foreach($chore in $chores)
{
write-host "$($chore.name) $dueDate is due today!"
$todaysTasks += $chore
# Send-Notification -title "TASK DUE TODAY" -content "$($chore.Name) is due today!"
}
else
{
@@ -53,9 +86,9 @@ if ($overdueTasks.Count -ne 0)
$content = "The following tasks are overdue!`n"
foreach($overdueTask in $overdueTasks)
{
$content += "$($overdueTask.Name) was due $((Get-Date $overdueTask.nextDueDate).ToLocalTime())`n"
$content += "$($overdueTask.Name) was due $(Get-Date $overdueTask.nextDueDate -Format "MM/dd/yyyy HH:mm")`n"
}
Send-AppriseNotification -title "OVERDUE TASKS" -content $content
Send-Notification -title "OVERDUE TASKS" -content $content
}
if ($todaysTasks.Count -ne 0)
@@ -64,7 +97,7 @@ if ($todaysTasks.Count -ne 0)
$content = "The following tasks are due today!`n"
foreach($task in $todaysTasks)
{
$content += "$($task.Name) is due $((Get-Date $task.nextDueDate).ToLocalTime())`n"
$content += "$($task.Name) is due $(Get-Date $task.nextDueDate -Format "MM/dd/yyyy HH:mm")`n"
}
Send-AppriseNotification -title "TODAY'S TASKS" -content $content
Send-Notification -title "TODAY'S TASKS" -content $content
}
-2
View File
@@ -1,2 +0,0 @@
# Default schedule (overridden at runtime by JOB_SCHEDULE env var)
0 * * * * pwsh /data/Start-DoneTickNotifier.ps1
+1 -5
View File
@@ -3,14 +3,10 @@ services:
donetick-notifier:
container_name: donetick-notifier
image: docker.io/blinkfink182/donetick-notifier
ports:
- 8080:8080
environment:
# BELOW ARE ALL REQUIRED
- DONETICKHOST=host.docker.internal # donetick host
- DONETICKPORT=8787 # donetick port
- DONETICKAPIKEY=adminpass # donetick API key
- APPRISEWEBHOOKURL=https://apprisehost/notify/config # apprise notification url
- 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
- APPRISEWEBHOOKTAG=all # apprise notification tag
-13
View File
@@ -1,13 +0,0 @@
#!/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