7 Commits
0.0.4 ... 0.0.8

Author SHA1 Message Date
Lennart J. Kurzweg (Nx2)
47f12834c1 hardcode sh 2026-04-23 18:04:34 +02:00
Lennart J. Kurzweg (Nx2)
b4a65a1af4 gitignore 2026-04-23 17:33:35 +02:00
Lennart J. Kurzweg (Nx2)
65aeeda263 Merge branch 'master' of ssh://ssh.nx2.site:50022/nx2/nxcaldav 2026-04-23 17:32:33 +02:00
Lennart J. Kurzweg (Nx2)
f61e014d2a smtp pw 2026-04-23 17:32:30 +02:00
ce6a5c7477 Delete shell.nix 2026-04-23 17:21:10 +02:00
Lennart J. Kurzweg (Nx2)
f66f58f67f no shebang 2026-04-23 17:17:52 +02:00
Lennart J. Kurzweg (Nx2)
5f036dbc89 fixed frfr 2026-04-22 20:32:48 +02:00
9 changed files with 105 additions and 134 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.direnv .direnv
server.log server.log
shell.nix
mem.go mem.go
nxcaldav nxcaldav
in/ in/

View File

@@ -3,74 +3,45 @@ database:
server: server:
bind_address: 0.0.0.0:14243 bind_address: 0.0.0.0:14243
default_class: CONFIDENTIAL default_class: CONFIDENTIAL
public_url: http://nxc.nx2.site/ public_url: http://example.com/
email_domain: nx2.site email_domain: example.com
redaction_text: '[-]' redaction_text: '[-]'
smtp: smtp:
host: localhost host: localhost
port: 587 port: 587
user: nxcaldav@nx2.site user: nxcaldav@nx2.site
password: Vastly-Wrinkle9-Corsage password_cmd: echo "Vastly-Wrinkle9-Corsage"
users: users:
- name: daniel - name: alice
password: ll password: 123
groups: groups:
- family - family
- name: lennart - name: bob
password: ll password: abc
groups: groups:
- family - family
- name: shared
password: Oxidant-Ageless3-Dispersed
calendars: calendars:
- id: preservation - id: test
owner: lennart owner: alice
color: '#F6F5F4' color: '#F6F5F4'
- id: effort - id: family
owner: lennart
color: '#FF0000'
- id: experience
owner: lennart
color: '#2C33FF'
- id: leisure
owner: lennart
color: '#10B400'
- id: daniel
owner: daniel
color: '#ff2222'
- access:
- group: family
mode: read-write
id: family
owner: shared owner: shared
color: '#999999' color: '#999999'
access:
- group: family
mode: read-write
address_books: address_books:
- id: contacts - id: contacts
owner: lennart owner: alice
- id: contacts
owner: daniel
- id: family - id: family
owner: shared owner: bob
access: access:
- group: family - group: family
mode: read-write mode: read-write
aggregates: aggregates:
- access:
- group: family
mode: read-only
- ics: future-only
id: lennart-aggregat
owner: lennart
color: '#dd9999'
sources:
- preservation
- effort
- experience
- leisure
- family

View File

@@ -1,4 +1,3 @@
#!/usr/bin/env python3
import os import os
import argparse import argparse
import psycopg2 import psycopg2

View File

@@ -1,4 +1,3 @@
#!/usr/bin/env python3
import os import os
import argparse import argparse
import psycopg2 import psycopg2

View File

@@ -8,7 +8,6 @@ import (
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"os/exec"
"path" "path"
"slices" "slices"
"strings" "strings"
@@ -140,16 +139,9 @@ func (b *DBBackend) initSchema(ctx context.Context) error {
} }
func (b *DBBackend) resolvePassword(u config.User) (string, error) { func (b *DBBackend) resolvePassword(u config.User) (string, error) {
var raw string raw, err := config.ResolvePassword(u.Password, u.PasswordCmd)
if u.PasswordCmd != "" { if err != nil {
cmd := exec.Command("sh", "-c", u.PasswordCmd) return "", fmt.Errorf("failed to resolve password for %s: %v", u.Name, err)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to run password command for %s: %v", u.Name, err)
}
raw = strings.TrimSpace(string(out))
} else {
raw = u.Password
} }
// If it already looks like a bcrypt hash, return as is. // If it already looks like a bcrypt hash, return as is.

View File

@@ -10,6 +10,7 @@ import (
"strings" "strings"
"github.com/emersion/go-ical" "github.com/emersion/go-ical"
"nxcaldav/internal/config"
) )
// sendInvitation sends an iMIP (RFC 6047) invitation email. // sendInvitation sends an iMIP (RFC 6047) invitation email.
@@ -99,8 +100,14 @@ func (b *DBBackend) sendInvitation(senderName, recipientEmail, summary, descript
if err = c.StartTLS(tlsConfig); err != nil { return err } if err = c.StartTLS(tlsConfig); err != nil { return err }
} }
} }
if b.smtp.User != "" && b.smtp.Password != "" {
auth := smtp.PlainAuth("", b.smtp.User, b.smtp.Password, b.smtp.Host) smtpPassword, err := config.ResolvePassword(b.smtp.Password, b.smtp.PasswordCmd)
if err != nil {
return fmt.Errorf("failed to resolve SMTP password: %v", err)
}
if b.smtp.User != "" && smtpPassword != "" {
auth := smtp.PlainAuth("", b.smtp.User, smtpPassword, b.smtp.Host)
if err = c.Auth(auth); err != nil { return err } if err = c.Auth(auth); err != nil { return err }
} }

View File

@@ -1,8 +1,10 @@
package config package config
import ( import (
"fmt"
"net/url" "net/url"
"os" "os"
"os/exec"
"slices" "slices"
"strings" "strings"
@@ -64,10 +66,11 @@ func (s ServerConfig) BasePath() string {
type SMTPConfig struct { type SMTPConfig struct {
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
User string `yaml:"user"` User string `yaml:"user"`
Password string `yaml:"password"` Password string `yaml:"password"`
PasswordCmd string `yaml:"password_cmd"`
} }
type Config struct { type Config struct {
@@ -79,6 +82,18 @@ type Config struct {
AddressBooks []AddressBook `yaml:"address_books"` AddressBooks []AddressBook `yaml:"address_books"`
Aggregates []Aggregate `yaml:"aggregates"` Aggregates []Aggregate `yaml:"aggregates"`
} }
func ResolvePassword(password, passwordCmd string) (string, error) {
if passwordCmd != "" {
cmd := exec.Command("/bin/sh", "-c", passwordCmd)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to run password command: %v", err)
}
return strings.TrimSpace(string(out)), nil
}
return password, nil
}
func (c *Config) setDefaults() { func (c *Config) setDefaults() {
if c.Server.BindAddress == "" { c.Server.BindAddress = ":8080" } if c.Server.BindAddress == "" { c.Server.BindAddress = ":8080" }
if c.Server.Redaction == "" { c.Server.Redaction = "Busy" } if c.Server.Redaction == "" { c.Server.Redaction = "Busy" }

115
main.go
View File

@@ -19,11 +19,9 @@ import (
"nxcaldav/internal/extra" "nxcaldav/internal/extra"
) )
func main() { func main() {
// -- GET CONFIG // -- GET CONFIG
path := "config.yaml"; path := "config.yaml"
if len(os.Args) == 3 { if len(os.Args) == 3 {
if os.Args[1] == "-c" { if os.Args[1] == "-c" {
path = os.Args[2] path = os.Args[2]
@@ -41,18 +39,18 @@ func main() {
log.Fatalf("failed to initialize database backend: %v", err) log.Fatalf("failed to initialize database backend: %v", err)
} }
// -- GET CONTEXT AND DB // -- GET CONTEXT AND DB
caldavHandler := &caldav.Handler{Backend: be} caldavHandler := &caldav.Handler{Backend: be}
carddavHandler := &carddav.Handler{Backend: be} carddavHandler := &carddav.Handler{Backend: be}
publicURL, _ := url.Parse(cfg.Server.PublicURL) publicURL, _ := url.Parse(cfg.Server.PublicURL)
// -- DISCOVERIES // -- DISCOVERIES
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
scheme := r.URL.Scheme scheme := r.URL.Scheme
if scheme == "" { scheme = "http" } if scheme == "" {
scheme = "http"
}
// set host (because reverse proxies exist) // set host (because reverse proxies exist)
if publicURL != nil && publicURL.Host != "" { if publicURL != nil && publicURL.Host != "" {
@@ -60,7 +58,7 @@ func main() {
r.URL.Host = publicURL.Host r.URL.Host = publicURL.Host
// prioritize X-Forwarded-Proto, then PublicURL (e.g. Cloudfalre proxy) // prioritize X-Forwarded-Proto, then PublicURL (e.g. Cloudfalre proxy)
scheme := publicURL.Scheme scheme = publicURL.Scheme
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto scheme = proto
} }
@@ -103,67 +101,68 @@ func main() {
return return
} }
log.Printf("[user: %s] %s %s", user, r.Method, r.URL.Path) log.Printf("[user: %s] %s %s", user, r.Method, r.URL.Path)
principalPath := prefix + fmt.Sprintf("/%s/", user) principalPath := prefix + fmt.Sprintf("/%s/", user)
ctx := context.WithValue(r.Context(), "principal", principalPath) ctx := context.WithValue(r.Context(), "principal", principalPath)
// set header for carddav // set header for carddav
if slices.Contains([]string{ if slices.Contains([]string{
"/.well-known/carddav", "/.well-known/carddav",
prefix + "/.well-known/carddav", prefix + "/.well-known/carddav",
principalPath, principalPath,
strings.TrimSuffix(principalPath, "/"), strings.TrimSuffix(principalPath, "/"),
}, r.URL.Path) || strings.Contains(r.URL.Path, "/addressbooks/") { }, r.URL.Path) || strings.Contains(r.URL.Path, "/addressbooks/") {
w.Header().Add("DAV", "addressbook") w.Header().Add("DAV", "addressbook")
} }
// for caldav discovery // for caldav discovery
if slices.Contains([]string{ if slices.Contains([]string{
"/.well-known/caldav", "/.well-known/caldav",
prefix+"/.well-known/caldav", prefix + "/.well-known/caldav",
}, r.URL.Path) { }, r.URL.Path) {
http.Redirect(w, r, fmt.Sprintf( "%s://%s%s", scheme, r.Host, principalPath), http.StatusMovedPermanently) http.Redirect(w, r, fmt.Sprintf("%s://%s%s", scheme, r.Host, principalPath), http.StatusMovedPermanently)
return return
} }
// serve caldav // serve caldav
if strings.Contains(r.URL.Path, "/calendars/") { if strings.Contains(r.URL.Path, "/calendars/") {
if r.Method == "PROPFIND" { if r.Method == "PROPFIND" {
// Calendar colors // Calendar colors
// needed because color info is not RFC, so I hacked it in with regex, to look like Radicales response // needed because color info is not RFC, so I hacked it in with regex, to look like Radicales response
extra.InjectColor(r, ctx, caldavHandler, w, be) extra.InjectColor(r, ctx, caldavHandler, w, be)
} else { } else {
caldavHandler.ServeHTTP(w, r.WithContext(ctx)) caldavHandler.ServeHTTP(w, r.WithContext(ctx))
} }
// serve carddav // serve carddav
} else if strings.Contains(r.URL.Path, "/addressbooks/") { } else if strings.Contains(r.URL.Path, "/addressbooks/") {
carddavHandler.ServeHTTP(w, r.WithContext(ctx)) carddavHandler.ServeHTTP(w, r.WithContext(ctx))
// catch weird requests // catch weird requests
} else { } else {
if strings.HasSuffix(r.URL.Path, user+"/") || strings.HasSuffix(r.URL.Path, user) { if strings.HasSuffix(r.URL.Path, user+"/") || strings.HasSuffix(r.URL.Path, user) {
// For principal path, use merged discovery handler // For principal path, use merged discovery handler
if r.Method == "PROPFIND" { if r.Method == "PROPFIND" {
extra.HandleDiscoveryPropfind(r, ctx, caldavHandler, w, be) extra.HandleDiscoveryPropfind(r, ctx, caldavHandler, w, be)
} else { } else {
caldavHandler.ServeHTTP(w, r.WithContext(ctx)) caldavHandler.ServeHTTP(w, r.WithContext(ctx))
// Ensure DAV header includes addressbook for OPTIONS // Ensure DAV header includes addressbook for OPTIONS
if r.Method == "OPTIONS" { if r.Method == "OPTIONS" {
dav := w.Header().Get("DAV") dav := w.Header().Get("DAV")
if dav != "" && !strings.Contains(dav, "addressbook") { if dav != "" && !strings.Contains(dav, "addressbook") {
w.Header().Set("DAV", dav+", addressbook") w.Header().Set("DAV", dav+", addressbook")
} }
} }
} }
} else { } else {
http.NotFound(w, r) http.NotFound(w, r)
} }
} }
})
fmt.Printf("Starting CalDAV/CardDAV server on %s...\n", cfg.Server.BindAddress) fmt.Printf("Starting CalDAV/CardDAV server on %s...\n", cfg.Server.BindAddress)
server := &http.Server{ server := &http.Server{
Addr: cfg.Server.BindAddress, Addr: cfg.Server.BindAddress,
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,

View File

@@ -1,12 +0,0 @@
{ pkgs ? import <nixpkgs> { } }: let
my-python = pkgs.python312;
python-with-my-packages = my-python.withPackages (p: with p; [
ical
ics
caldav
pyyaml
psycopg2
]);
in pkgs.mkShell {
buildInputs = [ python-with-my-packages ];
}