diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ac127d9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/ical-filter-proxy
+/config.yaml
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..c98de28
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,19 @@
+---
+include:
+ - project: gitlab/includes # private gitlab instance
+ file:
+ - docker.yml
+ - go.yml
+ - helm.yml
+variables:
+ CI_DOCKER_BUILD: "true"
+ CI_DOCKER_DHUB_REPO: "yungwood/ical-filter-proxy"
+
+go:build:
+ artifacts:
+ paths:
+ - ical-filter-proxy
+
+docker:build:
+ needs:
+ - go:build
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..2d600a7
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,37 @@
+FROM alpine:3.20.2
+
+# build args and defaults
+ARG BUILD_DATE="not-set"
+ARG REVISION="unknown"
+ARG VERSION="dev-build"
+
+# set some labels
+LABEL org.opencontainers.image.created="$BUILD_DATE" \
+ org.opencontainers.image.base.name="alpine:3.20.2" \
+ org.opencontainers.image.documentation="https://github.com/yungwood/ical-filter-proxy/tree/main/README.md" \
+ org.opencontainers.image.licenses="MIT" \
+ org.opencontainers.image.source="https://github.com/yungwood/ical-filter-proxy" \
+ org.opencontainers.image.revision="$REVISION" \
+ org.opencontainers.image.title="iCal Filter Proxy" \
+ org.opencontainers.image.description="iCal proxy with support for user-defined filtering rules" \
+ org.opencontainers.image.version="$VERSION"
+
+# install dependencies
+RUN apk --no-cache add gcompat=1.1.0-r4
+
+# create a group and user
+RUN addgroup -S icalfilterproxy && adduser -S -G icalfilterproxy icalfilterproxy
+
+# switch to app user
+USER icalfilterproxy
+
+# set working dir
+WORKDIR /app
+
+# copy binary
+COPY ical-filter-proxy /usr/bin/ical-filter-proxy
+
+# expose port, define entrypoint
+EXPOSE 8080/tcp
+ENTRYPOINT ["/usr/bin/ical-filter-proxy"]
+CMD ["-config", "/app/config.yaml"]
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..57e0ebf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,213 @@
+
+
+
+
+
+
+
+
iCal Filter Proxy
+
+
+ iCal proxy with support for user-defined filtering rules
+
+
+
+
+## What's this thing?
+
+Do you have iCal feeds with a bunch of stuff you *don't* need? Do you want to modify events generated by your rostering system?
+
+iCal Filter Proxy is a simple service for proxying multiple iCal feeds while applying a list of filters to remove or modify events to suit your use case.
+
+### Features
+
+* Proxy multiple calendars
+* Define a list of filters per calendar
+* Match events using basic text and regex conditions
+* Remove or modify events as they are proxied
+
+
+### Built With
+
+* Go
+* [golang-ical](https://github.com/arran4/golang-ical)
+* [yaml.v3](https://github.com/go-yaml/yaml/tree/v3.0.1)
+* [DALL-E 2](https://openai.com/index/dall-e-2/) (app icon)
+
+
+## Setup
+
+### Docker
+
+Docker images are published to [Docker Hub](https://hub.docker.com/repository/docker/yungwood/ical-filter-proxy). You'll need a config file (see below) mounted into the container at `/app/config.yaml`.
+
+For example:
+
+```bash
+docker run -d \
+ --name=ical-filter-proxy \
+ -v config.yaml:/app/config.yaml \
+ -p 8080:8080/tcp \
+ --restart unless-stopped \
+ yungwood/ical-filter-proxy:latest
+```
+
+You can also adapt the included [`docker-compose.yaml`](./docker-compose.yaml) example.
+
+### Kubernetes
+
+You can deploy iCal Filter Proxy using the included helm chart from [`charts/ical-filter-proxy`](charts/ical-filter-proxy).
+
+### Build from source
+
+You can also build the app and container from source.
+
+```bash
+# clone this repo
+git clone git@github.com:yungwood/ical-filter-proxy.git
+cd ical-filter-proxy
+
+# build the ical-filter-proxy binary
+go build .
+
+# build container image
+docker build \
+ --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
+ --build-arg REVISION=$(git rev-parse HEAD) \
+ --build-arg VERSION=$(git rev-parse --short HEAD) \
+ -t ical-filter-proxy:latest .
+```
+
+
+## Configuration
+
+Calendars and filters are defined in a yaml config file. By default this is `config.yaml` (use the `-config` switch to change this). The configuration must define at least one calendar for ical-filter-proxy to start.
+
+Example configuration (with comments):
+
+```yaml
+calendars:
+
+ # basic example
+ - name: example # used as slug in URL - e.g. ical-filter-proxy:8080/calendars/example/feed?token=changeme
+ token: "changeme" # token used to pull iCal feed - authentication is disabled when blank
+ feed_url: "https://my-upstream-calendar.url/feed.ics" # URL for the upstream iCal feed
+ filters: # optional - if no filters defined the upstream calendar is proxied as parsed
+ - description: "Remove an event based on a regex"
+ remove: true # events matching this filter will be removed
+ match: # optional - all events will match if no rules defined
+ summary: # match on event summary (title)
+ contains: "deleteme" # must contain 'deleteme'
+ - description: "Remove descriptions from all events"
+ transform: # optional
+ description: # modify event description
+ remove: true # replace with a blank string
+
+ # example: removing noise from an Office 365 calendar
+ - name: outlook
+ token: "changeme"
+ feed_url: "https://outlook.office365.com/owa/calendar/.../reachcalendar.ics"
+ filters:
+ - description: "Remove canceled events" # canceled events remain with a 'Canceled:' prefix until removed
+ remove: true
+ match:
+ summary:
+ prefix: "Canceled: "
+ - description: "Remove optional events"
+ remove: true
+ match:
+ summary:
+ prefix: "[Optional]"
+ - description: "Remove public holidays"
+ remove: true
+ match:
+ summary:
+ regex_match: ".*[Pp]ublic [Hh]oliday.*"
+
+ # example: cleaning up an OpsGenie feed
+ - name: opsgenie
+ token: "changeme"
+ feed_url: "https://company.app.opsgenie.com/webapi/webcal/getRecentSchedule?webcalToken=..."
+ filters:
+ - description: "Keep oncall schedule events and fix names"
+ match:
+ summary:
+ contains: "schedule: oncall"
+ stop: true # stops processing any more filters
+ transform:
+ summary:
+ replace: "On-Call" # replace the event summary (title)
+ - description: "Remove all other events"
+ remove: true
+
+unsafe: false # optional - must be enabled if any calendars do not have a token
+```
+
+
+### Filters
+
+Calendar events are filtered using a similar concept to email filtering. A list of filters is defined for each calendar in the config.
+
+Each event parsed from `feed_url` is evaluated against the filters in sequence.
+
+* All `match` rules for a filter must be true to match an event
+* A filter with no `match` rules will *always* match
+* When a match is found:
+ * if `remove` is `true` the event is discarded
+ * `transform` rules are applied to the event
+ * if `stop` is `true` no more filters are processed
+* If no match is found the event is retained by default
+
+#### Match conditions
+
+Each filter can spcify match conditions against the following event properties:
+
+* `summary` (string value)
+* `location` (string value)
+* `description` (string value)
+
+These match conditions are available for a string value:
+
+* `contains` - property must contain this value
+* `prefix` - property must start with this value
+* `suffix` - property must end with this value
+* `regex_match` - property must match the given regular expression (an invalid regex will result in no matches)
+
+#### Transformations
+
+Transformations can be applied to the following event properties:
+
+* `summary` - string value
+* `location` - string value
+* `description` - string value
+
+The following transformations are available for strings:
+
+* `replace` - the property is replace with this value
+* `remove` - if `true` the property is set to a blank string
+
+
+## Roadmap to 1.0
+
+There are a few more features I would like to add before I call the project "stable" and release version 1.0.
+
+- [ ] Time based event conditions
+- [ ] Caching
+- [ ] Support for `ical_url_file` and `token_file` in config (vault secrets)
+- [ ] Prometheus metrics
+- [ ] Testing
+
+
+## Contributing
+
+If you have a suggestion that would make this better, please feel free to open an issue or send a pull request.
+
+
+## License
+
+This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details.
+
+
+## Acknowledgments
+
+This project was inspired by [darkphnx/ical-filter-proxy](https://github.com/darkphnx/ical-filter-proxy). I needed more flexibility with filtering rules and the ability to modify event descriptions... plus I wanted an excuse to finally write something in Go.
diff --git a/calendar.go b/calendar.go
new file mode 100644
index 0000000..3d4dfa1
--- /dev/null
+++ b/calendar.go
@@ -0,0 +1,259 @@
+package main
+
+import (
+ "bytes"
+ "io"
+ "log/slog"
+ "net/http"
+ "regexp"
+ "strings"
+
+ ics "github.com/arran4/golang-ical"
+)
+
+// All structs defined in this file are used to unmarshall yaml configuration and
+// provide helper functions that are used to fetch and filter events
+
+// CalendarConfig definition
+type CalendarConfig struct {
+ Name string `yaml:"name"`
+ Token string `yaml:"token"`
+ FeedURL string `yaml:"feed_url"`
+ Filters []Filter `yaml:"filters"`
+}
+
+// Downloads iCal feed from the URL and applies filtering rules
+func (calendarConfig CalendarConfig) fetch() ([]byte, error) {
+
+ // get the iCal feed
+ slog.Debug("Fetching iCal feed", "url", calendarConfig.FeedURL)
+ resp, err := http.Get(calendarConfig.FeedURL)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ feedData, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ // parse calendar
+ cal, err := ics.ParseCalendar(strings.NewReader(string(feedData)))
+ if err != nil {
+ return nil, err
+ }
+
+ // process filters
+ if len(calendarConfig.Filters) > 0 {
+ slog.Debug("Processing filters", "calendar", calendarConfig.Name)
+ for _, event := range cal.Events() {
+ if !calendarConfig.ProcessEvent(event) {
+ cal.RemoveEvent(event.Id())
+ }
+ }
+ slog.Debug("Filter processing completed", "calendar", calendarConfig.Name)
+ } else {
+ slog.Debug("No filters to evaluate", "calendar", calendarConfig.Name)
+ }
+
+ // serialize output
+ var buf bytes.Buffer
+ err = cal.SerializeTo(&buf)
+ if err != nil {
+ return nil, err
+ }
+
+ // return
+ return buf.Bytes(), nil
+}
+
+// Evaluate the filters for a calendar against a given VEvent and
+// perform any transformations directly to the VEvent (pointer)
+// This function returns false if an event should be deleted
+func (calendarConfig CalendarConfig) ProcessEvent(event *ics.VEvent) bool {
+
+ // Get the Summary (the "title" of the event)
+ // In case we cannot parse the event summary it should get dropped
+ summary := event.GetProperty(ics.ComponentPropertySummary) // summary only for logging
+ if summary == nil {
+ return false
+ }
+
+ // Iterate through the Filter rules
+ for id, filter := range calendarConfig.Filters {
+
+ // Does the filter match the event?
+ if filter.matchesEvent(*event) {
+ slog.Debug("Filter match found", "rule_id", id, "filter_description", filter.Description, "event_summary", summary.Value)
+
+ // The event should get dropped if RemoveEvent is set
+ if filter.RemoveEvent {
+ slog.Debug("Event to be removed, no more rules will be processed", "action", "DELETE", "rule_id", id, "filter_description", filter.Description, "event_summary", summary.Value)
+ return false
+ }
+
+ // Apply transformation rules to event
+ filter.transformEvent(event)
+
+ // Check if we should stop processing rules
+ if filter.Stop {
+ slog.Debug("Stop option is set, no more rules will be processed", "rule_id", id, "filter_description", filter.Description, "event_summary", summary.Value)
+ return true
+ }
+ }
+ }
+
+ // Keep event by default if all Filter rules are processed
+ slog.Debug("Rule processing complete, event will be kept", "rule_id", nil, "event_summary", summary.Value)
+ return true
+
+}
+
+// Filter definition
+type Filter struct {
+ Description string `yaml:"description"`
+ RemoveEvent bool `yaml:"remove"`
+ Stop bool `yaml:"stop"`
+ Match EventMatchRules `yaml:"match"`
+ Transform EventTransformRules `yaml:"transform"`
+}
+
+// Returns true if a VEvent matches the Filter conditions
+func (filter Filter) matchesEvent(event ics.VEvent) bool {
+
+ // If an event property is not defined golang-ical returns a nil pointer
+
+ // Get event Summary - only used for debug logging
+ eventSummary := event.GetProperty(ics.ComponentPropertySummary)
+ if eventSummary == nil {
+ slog.Warn("Unable to process event summary. Event will be dropped")
+ return false // never match if VEvent has no summary
+ }
+
+ // Check Summary filters against VEvent
+ if filter.Match.Summary.hasConditions() {
+ if !filter.Match.Summary.matchesString(eventSummary.Value) {
+ return false
+ }
+ }
+
+ // Check Description filters against VEvent
+ if filter.Match.Description.hasConditions() {
+ eventDescription := event.GetProperty(ics.ComponentPropertyDescription)
+ if eventDescription == nil {
+ slog.Debug("Event has no Description so cannot not match filter", "event_summary", eventSummary.Value, "filter", filter.Description)
+ return false // if VEvent has no description it cannot match filter
+ }
+ if !filter.Match.Description.matchesString(eventDescription.Value) {
+ slog.Debug("Event Description does not match filter conditions", "event_summary", eventSummary.Value, "filter", filter.Description)
+ return false // event doesn't match
+ }
+ }
+
+ // Check Description filters against VEvent
+ if filter.Match.Location.hasConditions() {
+ eventLocation := event.GetProperty(ics.ComponentPropertyLocation)
+ if eventLocation == nil {
+ slog.Warn("Event has no Location so cannot match filter", "event_summary", eventSummary.Value, "filter", filter.Description)
+ return false // if VEvent has no location it cannot match filter
+ }
+ if !filter.Match.Location.matchesString(eventLocation.Value) {
+ slog.Debug("Event Location does not match filter conditions", "event_summary", eventSummary.Value, "filter", filter.Description)
+ return false // event doesn't match
+
+ }
+ }
+
+ // VEvent must match if we get here
+ slog.Debug("Event matches filter conditions", "event_summary", eventSummary.Value, "filter", filter.Description)
+ return true
+}
+
+// Applies filter transformations to a VEvent pointer
+func (filter Filter) transformEvent(event *ics.VEvent) {
+
+ // Summary transformations
+ if filter.Transform.Summary.Remove {
+ event.SetSummary("")
+ } else if filter.Transform.Summary.Replace != "" {
+ event.SetSummary(filter.Transform.Summary.Replace)
+ }
+
+ // Description transformations
+ if filter.Transform.Description.Remove {
+ event.SetDescription("")
+ } else if filter.Transform.Description.Replace != "" {
+ event.SetDescription(filter.Transform.Description.Replace)
+ }
+}
+
+// EventMatchRules contains VEvent properties that user can match against
+type EventMatchRules struct {
+ Summary StringMatchRule `yaml:"summary"`
+ Description StringMatchRule `yaml:"description"`
+ Location StringMatchRule `yaml:"location"`
+}
+
+// StringMatchRule defines match rules for VEvent properties with string values
+type StringMatchRule struct {
+ Contains string `yaml:"contains"`
+ Prefix string `yaml:"prefix"`
+ Suffix string `yaml:"suffix"`
+ RegexMatch string `yaml:"regex"`
+}
+
+// Returns true if StringMatchRule has any conditions
+func (smr StringMatchRule) hasConditions() bool {
+ return smr.Contains != "" ||
+ smr.Prefix != "" ||
+ smr.Suffix != "" ||
+ smr.RegexMatch != ""
+}
+
+// Returns true if a given string (data) matches ALL StringMatchRule conditions
+func (smr StringMatchRule) matchesString(data string) bool {
+ // check contains if set
+ if smr.Contains != "" {
+ if data == "" || !strings.Contains(data, smr.Contains) {
+ return false
+ }
+ }
+ // check prefix if set
+ if smr.Prefix != "" {
+ if data == "" || !strings.HasPrefix(data, smr.Prefix) {
+ return false
+ }
+ }
+ // check suffix if set
+ if smr.Suffix != "" {
+ if data == "" || !strings.HasSuffix(data, smr.Suffix) {
+ return false
+ }
+ }
+ // check regex match if set
+ if smr.RegexMatch != "" {
+ match, err := regexp.MatchString(data, smr.RegexMatch)
+ if err != nil {
+ slog.Warn("error processing regex rule", "value", smr.RegexMatch)
+ return false // regex error is considered a failure to match
+ }
+ if !match {
+ return false // regex didn't match
+ }
+ }
+ return true
+}
+
+// EventTransformRules contains VEvent properties that user can modify
+type EventTransformRules struct {
+ Summary StringTransformRule `yaml:"summary"`
+ Description StringTransformRule `yaml:"description"`
+ Location StringTransformRule `yaml:"location"`
+}
+
+// StringTransformRule defines changes for VEvent properties with string values
+type StringTransformRule struct {
+ Replace string `yaml:"replace"`
+ Remove bool `yaml:"remove"`
+}
diff --git a/charts/ical-filter-proxy/.helmignore b/charts/ical-filter-proxy/.helmignore
new file mode 100644
index 0000000..0e8a0eb
--- /dev/null
+++ b/charts/ical-filter-proxy/.helmignore
@@ -0,0 +1,23 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/charts/ical-filter-proxy/Chart.yaml b/charts/ical-filter-proxy/Chart.yaml
new file mode 100644
index 0000000..0834935
--- /dev/null
+++ b/charts/ical-filter-proxy/Chart.yaml
@@ -0,0 +1,8 @@
+apiVersion: v2
+name: ical-filter-proxy
+description: iCal proxy with support for user-defined filtering rules
+type: application
+version: 0.1.0
+appVersion: "0.1.0"
+maintainers:
+ - name: Yung Wood
diff --git a/charts/ical-filter-proxy/templates/NOTES.txt b/charts/ical-filter-proxy/templates/NOTES.txt
new file mode 100644
index 0000000..a626be0
--- /dev/null
+++ b/charts/ical-filter-proxy/templates/NOTES.txt
@@ -0,0 +1,22 @@
+1. Get the application URL by running these commands:
+{{- if .Values.ingress.enabled }}
+{{- range $host := .Values.ingress.hosts }}
+ {{- range .paths }}
+ http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
+ {{- end }}
+{{- end }}
+{{- else if contains "NodePort" .Values.service.type }}
+ export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "ical-filter-proxy.fullname" . }})
+ export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
+ echo http://$NODE_IP:$NODE_PORT
+{{- else if contains "LoadBalancer" .Values.service.type }}
+ NOTE: It may take a few minutes for the LoadBalancer IP to be available.
+ You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "ical-filter-proxy.fullname" . }}'
+ export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "ical-filter-proxy.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
+ echo http://$SERVICE_IP:{{ .Values.service.port }}
+{{- else if contains "ClusterIP" .Values.service.type }}
+ export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "ical-filter-proxy.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
+ export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
+ echo "Visit http://127.0.0.1:8080 to use your application"
+ kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
+{{- end }}
diff --git a/charts/ical-filter-proxy/templates/_helpers.tpl b/charts/ical-filter-proxy/templates/_helpers.tpl
new file mode 100644
index 0000000..5c07908
--- /dev/null
+++ b/charts/ical-filter-proxy/templates/_helpers.tpl
@@ -0,0 +1,62 @@
+{{/*
+Expand the name of the chart.
+*/}}
+{{- define "ical-filter-proxy.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create a default fully qualified app name.
+We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
+If release name contains chart name it will be used as a full name.
+*/}}
+{{- define "ical-filter-proxy.fullname" -}}
+{{- if .Values.fullnameOverride }}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- $name := default .Chart.Name .Values.nameOverride }}
+{{- if contains $name .Release.Name }}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+{{- end }}
+{{- end }}
+
+{{/*
+Create chart name and version as used by the chart label.
+*/}}
+{{- define "ical-filter-proxy.chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Common labels
+*/}}
+{{- define "ical-filter-proxy.labels" -}}
+helm.sh/chart: {{ include "ical-filter-proxy.chart" . }}
+{{ include "ical-filter-proxy.selectorLabels" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{/*
+Selector labels
+*/}}
+{{- define "ical-filter-proxy.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "ical-filter-proxy.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{/*
+Create the name of the service account to use
+*/}}
+{{- define "ical-filter-proxy.serviceAccountName" -}}
+{{- if .Values.serviceAccount.create }}
+{{- default (include "ical-filter-proxy.fullname" .) .Values.serviceAccount.name }}
+{{- else }}
+{{- default "default" .Values.serviceAccount.name }}
+{{- end }}
+{{- end }}
diff --git a/charts/ical-filter-proxy/templates/config.yaml b/charts/ical-filter-proxy/templates/config.yaml
new file mode 100644
index 0000000..bac6e3a
--- /dev/null
+++ b/charts/ical-filter-proxy/templates/config.yaml
@@ -0,0 +1,15 @@
+{{- if .Values.config.enabled -}}
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ include "ical-filter-proxy.fullname" . }}-config
+ labels:
+ {{- include "ical-filter-proxy.labels" . | nindent 4 }}
+data:
+ config.yaml: |
+ {{- if .Values.config.insecure }}
+ insecure: true
+ {{- end }}
+ calendars:
+{{ .Values.config.calendars | toYaml | indent 6 }}
+{{- end }}
diff --git a/charts/ical-filter-proxy/templates/deployment.yaml b/charts/ical-filter-proxy/templates/deployment.yaml
new file mode 100644
index 0000000..46ae7c8
--- /dev/null
+++ b/charts/ical-filter-proxy/templates/deployment.yaml
@@ -0,0 +1,79 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "ical-filter-proxy.fullname" . }}
+ labels:
+ {{- include "ical-filter-proxy.labels" . | nindent 4 }}
+spec:
+ {{- if not .Values.autoscaling.enabled }}
+ replicas: {{ .Values.replicaCount }}
+ {{- end }}
+ selector:
+ matchLabels:
+ {{- include "ical-filter-proxy.selectorLabels" . | nindent 6 }}
+ template:
+ metadata:
+ {{- with .Values.podAnnotations }}
+ annotations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ labels:
+ {{- include "ical-filter-proxy.labels" . | nindent 8 }}
+ {{- with .Values.podLabels }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ serviceAccountName: {{ include "ical-filter-proxy.serviceAccountName" . }}
+ securityContext:
+ {{- toYaml .Values.podSecurityContext | nindent 8 }}
+ containers:
+ - name: {{ .Chart.Name }}
+ securityContext:
+ {{- toYaml .Values.securityContext | nindent 12 }}
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ args: {{ .Values.args }}
+ ports:
+ - name: http
+ containerPort: {{ .Values.service.port }}
+ protocol: TCP
+ livenessProbe:
+ {{- toYaml .Values.livenessProbe | nindent 12 }}
+ readinessProbe:
+ {{- toYaml .Values.readinessProbe | nindent 12 }}
+ resources:
+ {{- toYaml .Values.resources | nindent 12 }}
+ volumeMounts:
+ {{- if .Values.config.enabled }}
+ - name: config
+ mountPath: /app/config.yaml
+ subPath: config.yaml
+ {{- end }}
+ {{- with .Values.volumeMounts }}
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ volumes:
+ {{- if .Values.config.enabled }}
+ - name: config
+ configMap:
+ name: {{ include "ical-filter-proxy.fullname" . }}-config
+ {{- end }}
+ {{- with .Values.volumes }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
diff --git a/charts/ical-filter-proxy/templates/hpa.yaml b/charts/ical-filter-proxy/templates/hpa.yaml
new file mode 100644
index 0000000..ee955cd
--- /dev/null
+++ b/charts/ical-filter-proxy/templates/hpa.yaml
@@ -0,0 +1,32 @@
+{{- if .Values.autoscaling.enabled }}
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: {{ include "ical-filter-proxy.fullname" . }}
+ labels:
+ {{- include "ical-filter-proxy.labels" . | nindent 4 }}
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: {{ include "ical-filter-proxy.fullname" . }}
+ minReplicas: {{ .Values.autoscaling.minReplicas }}
+ maxReplicas: {{ .Values.autoscaling.maxReplicas }}
+ metrics:
+ {{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
+ - type: Resource
+ resource:
+ name: cpu
+ target:
+ type: Utilization
+ averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
+ {{- end }}
+ {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
+ - type: Resource
+ resource:
+ name: memory
+ target:
+ type: Utilization
+ averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
+ {{- end }}
+{{- end }}
diff --git a/charts/ical-filter-proxy/templates/ingress.yaml b/charts/ical-filter-proxy/templates/ingress.yaml
new file mode 100644
index 0000000..99f59e7
--- /dev/null
+++ b/charts/ical-filter-proxy/templates/ingress.yaml
@@ -0,0 +1,61 @@
+{{- if .Values.ingress.enabled -}}
+{{- $fullName := include "ical-filter-proxy.fullname" . -}}
+{{- $svcPort := .Values.service.port -}}
+{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
+ {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
+ {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
+ {{- end }}
+{{- end }}
+{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
+apiVersion: networking.k8s.io/v1
+{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
+apiVersion: networking.k8s.io/v1beta1
+{{- else -}}
+apiVersion: extensions/v1beta1
+{{- end }}
+kind: Ingress
+metadata:
+ name: {{ $fullName }}
+ labels:
+ {{- include "ical-filter-proxy.labels" . | nindent 4 }}
+ {{- with .Values.ingress.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
+ ingressClassName: {{ .Values.ingress.className }}
+ {{- end }}
+ {{- if .Values.ingress.tls }}
+ tls:
+ {{- range .Values.ingress.tls }}
+ - hosts:
+ {{- range .hosts }}
+ - {{ . | quote }}
+ {{- end }}
+ secretName: {{ .secretName }}
+ {{- end }}
+ {{- end }}
+ rules:
+ {{- range .Values.ingress.hosts }}
+ - host: {{ .host | quote }}
+ http:
+ paths:
+ {{- range .paths }}
+ - path: {{ .path }}
+ {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
+ pathType: {{ .pathType }}
+ {{- end }}
+ backend:
+ {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
+ service:
+ name: {{ $fullName }}
+ port:
+ number: {{ $svcPort }}
+ {{- else }}
+ serviceName: {{ $fullName }}
+ servicePort: {{ $svcPort }}
+ {{- end }}
+ {{- end }}
+ {{- end }}
+{{- end }}
diff --git a/charts/ical-filter-proxy/templates/service.yaml b/charts/ical-filter-proxy/templates/service.yaml
new file mode 100644
index 0000000..0d36a9c
--- /dev/null
+++ b/charts/ical-filter-proxy/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "ical-filter-proxy.fullname" . }}
+ labels:
+ {{- include "ical-filter-proxy.labels" . | nindent 4 }}
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.port }}
+ targetPort: http
+ protocol: TCP
+ name: http
+ selector:
+ {{- include "ical-filter-proxy.selectorLabels" . | nindent 4 }}
diff --git a/charts/ical-filter-proxy/templates/serviceaccount.yaml b/charts/ical-filter-proxy/templates/serviceaccount.yaml
new file mode 100644
index 0000000..0c954e3
--- /dev/null
+++ b/charts/ical-filter-proxy/templates/serviceaccount.yaml
@@ -0,0 +1,13 @@
+{{- if .Values.serviceAccount.create -}}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ include "ical-filter-proxy.serviceAccountName" . }}
+ labels:
+ {{- include "ical-filter-proxy.labels" . | nindent 4 }}
+ {{- with .Values.serviceAccount.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
+{{- end }}
diff --git a/charts/ical-filter-proxy/values.yaml b/charts/ical-filter-proxy/values.yaml
new file mode 100644
index 0000000..3ff2019
--- /dev/null
+++ b/charts/ical-filter-proxy/values.yaml
@@ -0,0 +1,119 @@
+replicaCount: 1
+
+image:
+ repository: yungwood/ical-filter-proxy
+ pullPolicy: IfNotPresent
+ tag: "" # defaults to chart appVersion
+
+imagePullSecrets: []
+nameOverride: ""
+fullnameOverride: ""
+
+args: []
+
+config:
+ enabled: false # enable to generate config from values (example below)
+ # calendars:
+ # - name: outlook
+ # token: "changeme"
+ # feed_url: "https://outlook.office365.com/owa/calendar/.../reachcalendar.ics"
+ # filters:
+ # - description: "Remove cancelled events"
+ # remove: true
+ # match:
+ # summary:
+ # prefix: "Canceled: "
+ # - description: "Remove optional events"
+ # remove: true
+ # match:
+ # summary:
+ # prefix: "[Optional]"
+ # - description: "Remove public holidays"
+ # remove: true
+ # match:
+ # summary:
+ # regex_match: ".*[Pp]ublic [Hh]oliday.*"
+ # insecure: false
+
+serviceAccount:
+ create: true
+ automount: true
+ annotations: {}
+ name: ""
+
+podAnnotations: {}
+podLabels: {}
+
+podSecurityContext: {}
+ # fsGroup: 2000
+
+securityContext:
+ capabilities:
+ drop:
+ - ALL
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ runAsNonRoot: true
+ runAsUser: 1000
+ runAsGroup: 3000
+
+service:
+ type: ClusterIP
+ port: 8080
+
+ingress:
+ enabled: false
+ className: ""
+ annotations: {}
+ # kubernetes.io/ingress.class: nginx
+ # kubernetes.io/tls-acme: "true"
+ hosts:
+ - host: ical-filter-proxy.local
+ paths:
+ - path: /
+ pathType: ImplementationSpecific
+ tls: []
+ # - secretName: ical-filter-proxy-tls
+ # hosts:
+ # - ical-filter-proxy.local
+
+resources: {}
+ # limits:
+ # cpu: 100m
+ # memory: 128Mi
+ # requests:
+ # cpu: 100m
+ # memory: 128Mi
+
+livenessProbe:
+ httpGet:
+ path: /liveness
+ port: http
+readinessProbe:
+ httpGet:
+ path: /readiness
+ port: http
+
+autoscaling:
+ enabled: false
+ minReplicas: 1
+ maxReplicas: 100
+ targetCPUUtilizationPercentage: 80
+ # targetMemoryUtilizationPercentage: 80
+
+volumes: []
+# - name: foo
+# secret:
+# secretName: mysecret
+# optional: false
+
+volumeMounts: []
+# - name: foo
+# mountPath: "/etc/foo"
+# readOnly: true
+
+nodeSelector: {}
+
+tolerations: []
+
+affinity: {}
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 0000000..94401d9
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,26 @@
+---
+services:
+ ical-filter-proxy:
+ image: yungwood/ical-filter-proxy:latest
+ container_name: ical-filter-proxy
+ # # specify additional command arguments
+ # command: --debug
+ ports:
+ - "8080:8080"
+ volumes:
+ - ./config.yaml:/app/config.yaml
+ restart: always
+
+## the below example might be used with traefik docker provider
+## https://doc.traefik.io/traefik/providers/docker/
+# labels:
+# traefik.enable: true
+# traefik.http.routers.ical.rule: "Host(`ical.mydomain.com`)"
+# traefik.http.routers.ical.entrypoints: web
+# traefik.http.services.ical.loadbalancer.server.port: 8080
+# networks:
+# - traefik
+# networks:
+# traefik:
+# name: traefik
+# external: true
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..6cb4cd0
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,18 @@
+module github.com/yungwood/ical-filter-proxy
+
+go 1.22.5
+
+require (
+ github.com/arran4/golang-ical v0.3.1
+ github.com/gookit/color v1.5.4 // indirect
+ github.com/gookit/goutil v0.6.15 // indirect
+ github.com/gookit/gsr v0.1.0 // indirect
+ github.com/gookit/slog v0.5.6 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sync v0.5.0 // indirect
+ golang.org/x/sys v0.22.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..f7f1f36
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,29 @@
+github.com/arran4/golang-ical v0.3.1 h1:v13B3eQZ9VDHTAvT6M11vVzxYgcYmjyPBE2eAZl3VZk=
+github.com/arran4/golang-ical v0.3.1/go.mod h1:LZWxF8ZIu/sjBVUCV0udiVPrQAgq3V0aa0RfbO99Qkk=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
+github.com/gookit/goutil v0.6.15/go.mod h1:qdKdYEHQdEtyH+4fNdQNZfJHhI0jUZzHxQVAV3DaMDY=
+github.com/gookit/gsr v0.1.0/go.mod h1:7wv4Y4WCnil8+DlDYHBjidzrEzfHhXEoFjEA0pPPWpI=
+github.com/gookit/slog v0.5.6/go.mod h1:RfIwzoaQ8wZbKdcqG7+3EzbkMqcp2TUn3mcaSZAw2EQ=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/logo.png b/logo.png
new file mode 100644
index 0000000..c292f0c
Binary files /dev/null and b/logo.png differ
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..94be1c4
--- /dev/null
+++ b/main.go
@@ -0,0 +1,182 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+var Version = "development"
+
+// this struct used to parse config.yaml
+type Config struct {
+ Calendars []CalendarConfig `yaml:"calendars"`
+ AllowUnsafe bool `yaml:"unsafe"`
+}
+
+// This function loads the configuration file and does some basic validation
+// Returns false if the config is not valid or an error occurs
+func (config *Config) LoadConfig(file string) bool {
+ data, err := os.ReadFile(file)
+ if err != nil {
+ slog.Error("Unable to open config file! You can use -config to specify a different file", "file", file)
+ return false
+ }
+ err = yaml.Unmarshal(data, &config)
+ if err != nil {
+ slog.Error("Error while unmarshalling yaml! Check config file is valid", "file", file)
+ return false
+ }
+
+ // ensure calendars exist
+ if len(config.Calendars) == 0 {
+ slog.Error("No calendars found! Configuration should define at least one calendar")
+ return false
+ }
+
+ // validate calendar configs
+ for _, calendarConfig := range config.Calendars {
+
+ // check if url seems valid
+ if !strings.HasPrefix(calendarConfig.FeedURL, "http://") && !strings.HasPrefix(calendarConfig.FeedURL, "https://") {
+ slog.Debug("Calendar URL must begin with http:// or https://", "calendar", calendarConfig.Name, "feed_url", len(calendarConfig.Filters))
+ return false
+ }
+
+ // Check to see if auth is disabled (token not set)
+ // If so print a warning message and make sure unsafe is enabled in config
+ if len(calendarConfig.Token) == 0 {
+ slog.Warn("Calendar has no token set. Authentication will be disabled", "calendar", calendarConfig.Name)
+ if !config.AllowUnsafe {
+ slog.Error("Calendar cannot have authentication disabled without unsafe optionenabled in the configuration", "calendar", calendarConfig.Name)
+ return false
+ }
+ }
+
+ // Print a warning if the calendar has no filters
+ if len(calendarConfig.Filters) == 0 {
+ slog.Warn("Calendar has no filters and will be proxy-only", "calendar", calendarConfig.Name)
+ break
+ }
+
+ }
+
+ return true // config is parsed successfully
+
+}
+
+func main() {
+
+ // command-line args
+ var (
+ configFile string
+ debugLogging bool
+ jsonLogging bool
+ listenPort int
+ validateConfig bool
+ printVersion bool
+ )
+ flag.StringVar(&configFile, "config", "config.yaml", "config file")
+ flag.BoolVar(&debugLogging, "debug", false, "enable debug logging")
+ flag.BoolVar(&printVersion, "version", false, "print version and exit")
+ flag.BoolVar(&jsonLogging, "json", false, "output logging in JSON format")
+ flag.IntVar(&listenPort, "port", 8080, "listening port for api")
+ flag.BoolVar(&validateConfig, "validate", false, "validate config and exit")
+ flag.Parse()
+
+ // print version and exit
+ if printVersion {
+ fmt.Println("version:", Version)
+ os.Exit(0)
+ }
+
+ // setup logging options
+ loggingLevel := slog.LevelInfo // default loglevel
+ if debugLogging {
+ loggingLevel = slog.LevelDebug // debug logging enabled
+ }
+ opts := &slog.HandlerOptions{
+ Level: loggingLevel,
+ }
+
+ // create json or text logger based on args
+ var logger *slog.Logger
+ if jsonLogging {
+ logger = slog.New(slog.NewJSONHandler(os.Stdout, opts))
+ } else {
+ logger = slog.New(slog.NewTextHandler(os.Stdout, opts))
+ }
+ slog.SetDefault(logger)
+
+ // load configuration
+ slog.Debug("reading config", "configFile", configFile)
+ var config Config
+ if !config.LoadConfig(configFile) {
+ os.Exit(1) // fail if config is not valid
+ }
+ slog.Debug("loaded config")
+
+ // print a message and exit if validate arg was specified
+ if validateConfig {
+ slog.Info("configuration was validated successfully")
+ os.Exit(0)
+ }
+
+ // iterate through calendars in the config and setup a handler for each
+ // todo: consider refactor to route requests dynamically?
+ for _, calendarConfig := range config.Calendars {
+
+ // configure HTTP endpoint
+ httpPath := "/calendars/" + calendarConfig.Name + "/feed"
+ slog.Debug("Configuring endpoint", "calendar", calendarConfig.Name, "http_path", httpPath)
+ http.HandleFunc(httpPath, func(w http.ResponseWriter, r *http.Request) {
+
+ slog.Debug("Received request for calendar", "http_path", httpPath, "calendar", calendarConfig.Name, "client_ip", r.RemoteAddr)
+
+ // validate token
+ token := r.URL.Query().Get("token")
+ if token != calendarConfig.Token {
+ slog.Warn("Unauthorized access attempt", "client_ip", r.RemoteAddr)
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ // fetch and filter upstream calendar
+ feed, err := calendarConfig.fetch()
+ if err != nil {
+ slog.Error("Error fetching and filtering feed", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ // return calendar
+ w.Header().Set("Content-Type", "text/calendar")
+ _, err = w.Write(feed)
+ if err != nil {
+ slog.Error("Error writing response", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ slog.Info("Calendar request processed", "http_path", httpPath, "calendar", calendarConfig.Name, "client_ip", r.RemoteAddr)
+ })
+
+ }
+
+ // add a readiness and liveness check endpoint (return blank 200 OK response)
+ http.HandleFunc("/liveness", func(w http.ResponseWriter, r *http.Request) {})
+ http.HandleFunc("/readiness", func(w http.ResponseWriter, r *http.Request) {})
+
+ // start the webserver
+ slog.Info("Starting web server", "port", listenPort)
+ if err := http.ListenAndServe(":"+strconv.Itoa(listenPort), nil); err != nil {
+ slog.Error("Error starting web server", "error", err)
+ }
+
+}
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 0000000..4107bab
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "baseBranches": ["develop"]
+}
\ No newline at end of file