aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitattributes1
-rw-r--r--.gitignore5
-rw-r--r--config.go74
-rw-r--r--flake.lock58
-rw-r--r--flake.nix37
-rw-r--r--go.mod13
-rw-r--r--go.sum8
-rw-r--r--icalproxy.go117
-rw-r--r--module/default.nix73
9 files changed, 386 insertions, 0 deletions
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..b72521c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
/vendor/* linguist-vendored
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9cb80c8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
1.idea/
2.vscode/
3
4# go build
5icalproxy
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..d9a2e30
--- /dev/null
+++ b/config.go
@@ -0,0 +1,74 @@
1package main
2
3import (
4 "encoding/base64"
5 "encoding/json"
6 "log"
7 "net/url"
8 "os"
9 "regexp"
10)
11
12type userToken struct {
13 Hash []byte `json:"hash"`
14 Salt []byte `json:"salt"`
15}
16
17type config struct {
18 CalendarURL url.URL `json:"calendar_url"`
19 Ignore ignoreRules `json:"ignore"`
20 Port string `json:"port"`
21 UserTokens map[string]userToken `json:"user_tokens"`
22}
23
24type ignoreRules struct {
25 LocationRegexes []regexp.Regexp `json:"location_regexes"`
26 SummaryRegexes []regexp.Regexp `json:"summary_regexes"`
27}
28
29func printConfig(cfg *config) {
30 b64 := base64.StdEncoding
31
32 log.Print("Loaded configuration: ")
33 log.Print(" HTTP Port: ", cfg.Port)
34 log.Print(" User Tokens:")
35 for user, token := range cfg.UserTokens {
36 log.Print(" User ", user, ":")
37 log.Print(" Hash: ", b64.EncodeToString(token.Hash))
38 log.Print(" Salt: ", b64.EncodeToString(token.Salt))
39 }
40 if len(cfg.UserTokens) == 0 {
41 log.Print(" <no users configured>")
42 }
43 log.Print(" Ignoring:")
44 for _, entry := range cfg.Ignore.LocationRegexes {
45 log.Printf(" Events with locations matching %s", entry.String())
46 }
47 for _, entry := range cfg.Ignore.SummaryRegexes {
48 log.Printf(" Events with summaries matching %s", entry.String())
49 }
50 if len(cfg.Ignore.LocationRegexes)+len(cfg.Ignore.SummaryRegexes) == 0 {
51 log.Printf(" <no ignore rules configured>")
52 }
53}
54
55func loadConfigFrom(filename string) config {
56 bytes, err := os.ReadFile(filename)
57 if err != nil {
58 log.Fatal("Could not read config file (at ", filename, ")")
59 }
60 var cfg config
61 if err = json.Unmarshal(bytes, &cfg); err != nil {
62 log.Fatalln("Could not parse config file:", err)
63 }
64 return cfg
65}
66
67func loadConfig() config {
68 configFile := os.Getenv("CONFIG_FILE")
69 if configFile == "" {
70 log.Fatal("Environment variable CONFIG_FILE not specified")
71 }
72 log.Print("Loading configuration from ", configFile)
73 return loadConfigFrom(configFile)
74}
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..1bcf97a
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,58 @@
1{
2 "nodes": {
3 "flake-utils": {
4 "inputs": {
5 "systems": "systems"
6 },
7 "locked": {
8 "lastModified": 1701680307,
9 "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
10 "rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
11 "revCount": 88,
12 "type": "tarball",
13 "url": "https://api.flakehub.com/f/pinned/numtide/flake-utils/0.1.88%2Brev-4022d587cbbfd70fe950c1e2083a02621806a725/018c340d-3287-7c66-818b-f2f646a808e3/source.tar.gz"
14 },
15 "original": {
16 "type": "tarball",
17 "url": "https://flakehub.com/f/numtide/flake-utils/0.1.88.tar.gz"
18 }
19 },
20 "nixpkgs": {
21 "locked": {
22 "lastModified": 1701952659,
23 "narHash": "sha256-TJv2srXt6fYPUjxgLAL0cy4nuf1OZD4KuA1TrCiQqg0=",
24 "rev": "b4372c4924d9182034066c823df76d6eaf1f4ec4",
25 "revCount": 552856,
26 "type": "tarball",
27 "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2311.552856%2Brev-b4372c4924d9182034066c823df76d6eaf1f4ec4/018c47c2-c604-7ad3-b455-4b2ad1c90554/source.tar.gz"
28 },
29 "original": {
30 "type": "tarball",
31 "url": "https://flakehub.com/f/NixOS/nixpkgs/0.2311.%2A.tar.gz"
32 }
33 },
34 "root": {
35 "inputs": {
36 "flake-utils": "flake-utils",
37 "nixpkgs": "nixpkgs"
38 }
39 },
40 "systems": {
41 "locked": {
42 "lastModified": 1681028828,
43 "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
44 "owner": "nix-systems",
45 "repo": "default",
46 "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
47 "type": "github"
48 },
49 "original": {
50 "owner": "nix-systems",
51 "repo": "default",
52 "type": "github"
53 }
54 }
55 },
56 "root": "root",
57 "version": 7
58}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..f8ca910
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,37 @@
1{
2 inputs = {
3 nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.2311.*.tar.gz";
4 flake-utils.url = "https://flakehub.com/f/numtide/flake-utils/0.1.88.tar.gz";
5 };
6
7 outputs = { self, nixpkgs, flake-utils, ... }@inputs:
8 {
9 nixosModules = rec {
10 icalproxy = import ./module self;
11 default = icalproxy;
12 };
13 } // flake-utils.lib.eachDefaultSystem
14 (system:
15 let
16 pkgs = import nixpkgs {
17 inherit system;
18 overlays = [ ];
19 };
20
21 icalproxy = pkgs.buildGoModule {
22 name = "icalproxy";
23 src = ./.;
24 vendorHash = "sha256-l/PQPEZC99umlSr6PBn/dXn2mZN4qf8YMBE8ENOm97I=";
25 };
26 in
27 {
28 packages.icalproxy = icalproxy;
29 packages.default = self.packages.${system}.icalproxy;
30
31 devShells.default = pkgs.mkShell {
32 inputsFrom = [ icalproxy ];
33 };
34
35 formatter = pkgs.nixpkgs-fmt;
36 });
37}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..d89d396
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,13 @@
1module github.com/rutgerbrf/icalproxy
2
3go 1.21
4
5require (
6 github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f
7 golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90
8)
9
10require (
11 github.com/teambition/rrule-go v1.7.2 // indirect
12 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
13)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..aede5ad
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,8 @@
1github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f h1:feGUUxxvOtWVOhTko8Cbmp33a+tU0IMZxMEmnkoAISQ=
2github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ=
3github.com/teambition/rrule-go v1.7.2 h1:goEajFWYydfCgavn2m/3w5U+1b3PGqPUHx/fFSVfTy0=
4github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU=
5golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM=
6golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
7golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
8golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/icalproxy.go b/icalproxy.go
new file mode 100644
index 0000000..cf09e5d
--- /dev/null
+++ b/icalproxy.go
@@ -0,0 +1,117 @@
1package main
2
3import (
4 "bytes"
5 "log"
6 "net/http"
7 "net/url"
8 "time"
9
10 "github.com/emersion/go-ical"
11 "golang.org/x/crypto/argon2"
12)
13
14func main() {
15 cfg := loadConfig()
16 printConfig(&cfg)
17
18 handler := handler{ignore: cfg.Ignore, tokens: cfg.UserTokens}
19
20 mux := http.ServeMux{}
21 mux.HandleFunc("/", handler.handle)
22 server := http.Server{
23 Addr: ":" + cfg.Port,
24 Handler: &mux,
25 ReadTimeout: 5 * time.Second,
26 WriteTimeout: 30 * time.Second,
27 IdleTimeout: 30 * time.Second,
28 ReadHeaderTimeout: 2 * time.Second,
29 }
30
31 log.Println("Listening on", cfg.Port)
32 log.Fatal(server.ListenAndServe())
33}
34
35type handler struct {
36 ignore ignoreRules
37 tokens map[string]userToken
38 calURL url.URL
39}
40
41func (h handler) makeTokenURL(token string) string {
42 newURL := h.calURL
43 query := newURL.Query()
44 query.Set("token", token)
45 newURL.RawQuery = query.Encode()
46 return newURL.String()
47}
48
49func (h handler) handle(w http.ResponseWriter, r *http.Request) {
50 brightspaceToken := r.URL.Query().Get("brightspace_token")
51 proxyUserID := r.URL.Query().Get("proxy_user_id")
52 proxyToken := r.URL.Query().Get("proxy_token")
53 if !userOK(h.tokens, proxyUserID, []byte(proxyToken)) {
54 http.Error(w, "Bad proxy_user_id or proxy_token", http.StatusUnauthorized)
55 return
56 }
57
58 resp, err := http.Get(h.makeTokenURL(brightspaceToken))
59 if err != nil {
60 http.Error(w, "Error sending request to Brightspace", http.StatusInternalServerError)
61 return
62 }
63
64 cal, err := ical.NewDecoder(resp.Body).Decode()
65 if err != nil {
66 http.Error(w, "Could not decode iCal", http.StatusInternalServerError)
67 return
68 }
69
70 filter(h.ignore, cal.Component)
71
72 if err = ical.NewEncoder(w).Encode(cal); err != nil {
73 log.Println("Error writing calendar:", err)
74 }
75}
76
77func filter(ignore ignoreRules, c *ical.Component) {
78 j := 0
79 for _, child := range c.Children {
80 keep := true
81
82 if child.Name == ical.CompEvent {
83 if location := child.Props.Get(ical.PropLocation); location != nil {
84 for _, locationRule := range ignore.LocationRegexes {
85 if locationRule.MatchString(location.Value) {
86 keep = false
87 }
88 }
89 }
90 if summary := child.Props.Get(ical.PropSummary); summary != nil {
91 for _, summaryRule := range ignore.SummaryRegexes {
92 if summaryRule.MatchString(summary.Value) {
93 keep = false
94 }
95 }
96 }
97 }
98
99 if keep {
100 c.Children[j] = child
101 j++
102 }
103 }
104 c.Children = c.Children[:j]
105}
106
107func userOK(tokens map[string]userToken, id string, token []byte) bool {
108 info, ok := tokens[id]
109 if !ok {
110 return false
111 }
112 return bytes.Compare(hash(token, info.Salt), info.Hash) == 0
113}
114
115func hash(token []byte, salt []byte) []byte {
116 return argon2.IDKey(token, salt, 1, 64*1024, 4, 32)
117}
diff --git a/module/default.nix b/module/default.nix
new file mode 100644
index 0000000..774a361
--- /dev/null
+++ b/module/default.nix
@@ -0,0 +1,73 @@
1flake: { lib, config, pkgs, ... }:
2with lib;
3let
4 inherit (flake.packages.${pkgs.stdenv.hostPlatform.system}) icalproxy;
5
6 cfg = config.services.icalproxy;
7in {
8 options.services.icalproxy = with types; {
9 enable = mkEnableOption "icalproxy";
10 calendarUrl = mkOption {
11 type = str;
12 };
13 ignore = mkOption {
14 type = submodule {
15 options = {
16 locationRegexes = mkOption {
17 type = listOf str;
18 };
19 summaryRegexes = mkOption {
20 type = listOf str;
21 };
22 };
23 };
24 };
25 port = mkOption {
26 type = str;
27 };
28 userTokens = mkOption {
29 type = attrsOf (submodule {
30 options = {
31 hash = mkOption {
32 type = str;
33 };
34 salt = mkOption {
35 type = str;
36 };
37 };
38 });
39 };
40 };
41
42 config = mkIf cfg.enable {
43 users.users.icalproxy = {
44 description = "icalproxy service user";
45 isSystemUser = true;
46 group = "icalproxy";
47 };
48
49 users.groups.icalproxy = {};
50
51 systemd.services.icalproxy = {
52 wants = [ "network-online.target" ];
53 wantedBy = [ "multi-user.target" ];
54 environment = {
55 CONFIG_FILE = pkgs.writeText "icalproxy-config.json" (builtins.toJSON {
56 calendar_url = cfg.calendarUrl;
57 ignore = {
58 location_regexes = cfg.ignore.locationRegexes;
59 summary_regexes = cfg.ignore.summaryRegexes;
60 };
61 port = cfg.port;
62 user_tokens = cfg.userTokens;
63 });
64 };
65 serviceConfig = {
66 User = config.users.users.icalproxy.name;
67 Group = config.users.users.icalproxy.group;
68 Restart = "always";
69 ExecStart = "${lib.getBin icalproxy}/bin/icalproxy";
70 };
71 };
72 };
73}