Compare commits

..
14 changed files with 234 additions and 442 deletions

View file

@ -1,9 +0,0 @@
root = true
[*]
trim_trailing_whitespace = true
insert_final_newline = true
[*.json]
indent_style = tab
tab_width = 4

View file

@ -6,8 +6,7 @@
$ curl http://localhost:8080 | jq
{
"api_compatibility": [
"14",
"15"
"14"
],
"space": "CCCHH",
...
@ -16,16 +15,16 @@ $ curl http://localhost:8080 | jq
## Configuring
`spaceapid` has to be configured via one or multiple json files.
spaceapid has to be configured via one or multiple json files.
A sample configuration is provided as [config-template.json](./config-template.json).
The config consists of three parts:
- `"credentials"`
- List of Username/Password credentials for HTTP BasicAuth
- `"dynamic"`
- The dynamic parts of the message
- `"static"`
- The static (pre-filled) parts of the message
- The configuration for the dynamic parts of the message
- `"response"`
- The static (pre-filled) parts of the response
See [Running](#Running) for details.

View file

@ -66,10 +66,9 @@
}
}
},
"static": {
"response": {
"api_compatibility": [
"14",
"15"
"14"
],
"space": "CCCHH",
"logo": "https://hamburg.ccc.de/images/logo.svg",

View file

@ -2,7 +2,7 @@ package config
import (
"bytes"
"encoding/json/v2"
"encoding/json"
"flag"
"fmt"
"log"
@ -69,7 +69,7 @@ func ParseConfiguration() (conf SpaceapidConfig) {
log.Println("Parsing configuration files")
paths := getConfigPaths()
log.Println("Config files:", paths)
log.Println(paths)
for _, path := range paths {
// Read file
file, err := os.ReadFile(path)
@ -78,15 +78,17 @@ func ParseConfiguration() (conf SpaceapidConfig) {
}
// Parse JSON
err = json.UnmarshalRead(bytes.NewReader(file), &conf, json.RejectUnknownMembers(true))
dec := json.NewDecoder(bytes.NewReader(file))
dec.DisallowUnknownFields()
err = dec.Decode(&conf)
if err != nil {
log.Fatalln("Could not parse spaceapid config file:", err)
}
}
// Sanity check: Config should declare compatibility with current SpaceAPI message version
if !slices.Contains(conf.Response.APICompatibility, types.CurrentMessageVersion) {
log.Fatalln("Provided config doesn't specify compatibility with current API version", types.CurrentMessageVersion)
// Check if compatible with v14
if !slices.Contains(conf.Response.APICompatibility, "14") {
log.Fatalln("Provided file doesn't specify compatibility with API version 14")
}
// Initialize fields for environment sensors

View file

@ -34,5 +34,5 @@ type SpaceapidConfig struct {
// The dynamic part of the spaceapi JSON
Dynamic DynamicStateConfig `json:"dynamic"`
// The static part of the spaceapi JSON
Response types.SpaceAPIResponse `json:"static"`
Response types.SpaceAPIResponseV14 `json:"response"`
}

2
go.mod
View file

@ -1,3 +1,3 @@
module git.hamburg.ccc.de/ccchh/spaceapid
go 1.27
go 1.22

View file

@ -1,14 +1,14 @@
package handlers
import (
"encoding/json/v2"
"encoding/json"
"log"
"net/http"
"git.hamburg.ccc.de/ccchh/spaceapid/types"
)
func Root(resp *types.SpaceAPIResponse) func(http.ResponseWriter, *http.Request) {
func Root(resp *types.SpaceAPIResponseV14) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// Check if GET method
if r.Method != http.MethodGet {
@ -19,7 +19,7 @@ func Root(resp *types.SpaceAPIResponse) func(http.ResponseWriter, *http.Request)
}
// Serialize response
response, err := json.Marshal(resp, json.Deterministic(true))
response, err := json.Marshal(resp)
if err != nil {
log.Println("Failed to serialize JSON response:", err)
w.WriteHeader(http.StatusInternalServerError)

View file

@ -11,7 +11,7 @@ import (
"git.hamburg.ccc.de/ccchh/spaceapid/types"
)
func EnvironmentSensorPUT(
func EnvironmentSensor(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.EnvironmentSensor,
) func(http.ResponseWriter, *http.Request) {
@ -35,20 +35,3 @@ func EnvironmentSensorPUT(
resp.LastChange = time.Now().Unix()
}
}
func EnvironmentSensorDELETE(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.EnvironmentSensor,
) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
_, err := updateEndpointValidator(authDB, validCredentials, w, r)
if err != nil {
log.Println(err)
return
}
// Set SpaceAPI response values
resp.Value = 0
resp.LastChange = time.Now().Unix()
}
}

View file

@ -10,9 +10,9 @@ import (
"git.hamburg.ccc.de/ccchh/spaceapid/types"
)
func StateOpenPUT(
func StateOpen(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceAPIState,
resp *types.SpaceState,
) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
body, err := updateEndpointValidator(authDB, validCredentials, w, r)
@ -22,7 +22,7 @@ func StateOpenPUT(
}
// Parse request body
doorNowOpen, err := strconv.ParseBool(string(body))
newState, err := strconv.ParseBool(string(body))
if err != nil {
log.Println("Failed to parse request body from", r.RemoteAddr, "with error:", err)
http.Error(w, "HTTP request body should either be true or false", http.StatusBadRequest)
@ -30,35 +30,14 @@ func StateOpenPUT(
}
// Set SpaceAPI response values
if doorNowOpen {
resp.Open = types.SpaceAPIStateOpenIsOpen
} else {
resp.Open = types.SpaceAPIStateOpenIsClosed
}
resp.LastChange = time.Now().Unix()
}
}
func StateOpenDELETE(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceAPIState,
) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
_, err := updateEndpointValidator(authDB, validCredentials, w, r)
if err != nil {
log.Println(err)
return
}
// Set SpaceAPI response values
resp.Open = types.SpaceAPIStateOpenIsUnknown
resp.Open = newState
resp.LastChange = time.Now().Unix()
}
}
func StateMessagePUT(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceAPIState,
resp *types.SpaceState,
) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
body, err := updateEndpointValidator(authDB, validCredentials, w, r)
@ -75,7 +54,7 @@ func StateMessagePUT(
func StateMessageDELETE(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceAPIState,
resp *types.SpaceState,
) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
_, err := updateEndpointValidator(authDB, validCredentials, w, r)

View file

@ -1,6 +1,7 @@
package handlers
import (
"errors"
"fmt"
"io"
"net/http"
@ -17,16 +18,16 @@ func updateEndpointValidator(
// Check BasicAuth credentials
username, password, ok := r.BasicAuth()
if !ok || !util.CheckCredentials(authDB, validCredentials, username, password) {
w.Header().Set("WWW-Authenticate", "Basic realm=\"spaceapid\"")
w.Header()["WWW-Authenticate"] = []string{"Basic realm=\"spaceapid\""}
http.Error(w, "", http.StatusUnauthorized)
return []byte{}, fmt.Errorf("Unauthorized request from %s Username: %s Password: %s", r.RemoteAddr, username, password)
return []byte{}, errors.New(fmt.Sprintf("Unauthorized request from %s Username: %s Password: %s", r.RemoteAddr, username, password))
}
// Read request body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "", http.StatusInternalServerError)
return []byte{}, fmt.Errorf("Failed to read request body from %s with error: %s", r.RemoteAddr, err)
return []byte{}, errors.New(fmt.Sprintf("Failed to read request body from %s with error: %s", r.RemoteAddr, err))
}
return body, nil

29
main.go
View file

@ -2,7 +2,6 @@ package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
@ -18,21 +17,14 @@ import (
var (
flagHelp bool
listenAddress string
listenPort string
version string
)
func init() {
flag.StringVar(&listenAddress, "i", "", "IP address the HTTP server should bind to (all available addresses by default)")
flag.StringVar(&listenPort, "p", "8080", "Port the HTTP server should listen on")
flag.BoolVar(&flagHelp, "h", false, "Print help output")
}
func main() {
if version == "" {
version = "unknown"
}
log.Println("SpaceAPId version", version)
flag.Parse()
@ -51,7 +43,7 @@ func main() {
// Register signal handler
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM)
go func(resp *types.SpaceAPIResponse) {
go func(resp *types.SpaceAPIResponseV14) {
<-sc
log.Println("Saving state and shutting down...")
persistence.SaveCurrentState(*resp)
@ -64,10 +56,7 @@ func main() {
)
// state->open
http.HandleFunc("PUT /state/open",
handlers.StateOpenPUT(conf.Credentials, conf.Dynamic.State.Open.AllowedCredentials, &conf.Response.State),
)
http.HandleFunc("DELETE /state/open",
handlers.StateOpenDELETE(conf.Credentials, conf.Dynamic.State.Open.AllowedCredentials, &conf.Response.State),
handlers.StateOpen(conf.Credentials, conf.Dynamic.State.Open.AllowedCredentials, &conf.Response.State),
)
// state->message
http.HandleFunc("PUT /state/message",
@ -79,18 +68,12 @@ func main() {
// Register handlers for environmental sensors
for sensorType, envSensorConfigs := range conf.Dynamic.Sensors {
for i, envSensorConfig := range envSensorConfigs {
urlPattern := util.GetSensorURLPath(
urlPattern := "PUT " + util.GetSensorURLPath(
sensorType, envSensorConfig.SensorData.Location, envSensorConfig.SensorData.Name,
)
http.HandleFunc(
"PUT "+urlPattern,
handlers.EnvironmentSensorPUT(
conf.Credentials, envSensorConfig.AllowedCredentials, &conf.Response.Sensors[sensorType][i],
),
)
http.HandleFunc(
"DELETE "+urlPattern,
handlers.EnvironmentSensorDELETE(
urlPattern,
handlers.EnvironmentSensor(
conf.Credentials, envSensorConfig.AllowedCredentials, &conf.Response.Sensors[sensorType][i],
),
)
@ -99,5 +82,5 @@ func main() {
// Start webserver
log.Println("Starting HTTP server...")
log.Fatalln(http.ListenAndServe(fmt.Sprintf("%s:%s", listenAddress, listenPort), nil))
log.Fatalln(http.ListenAndServe(":8080", nil))
}

View file

@ -1,8 +1,7 @@
package persistence
import (
"encoding/json/jsontext"
"encoding/json/v2"
"encoding/json"
"errors"
"log"
"os"
@ -17,11 +16,11 @@ const (
// MergeOldState merges a given SpaceAPIResponse with the state saved at the time of last program exit and then deletes
// the file containing the old state.
func MergeOldState(response *types.SpaceAPIResponse) {
func MergeOldState(response *types.SpaceAPIResponseV14) {
var (
err error
oldState []byte
persistedState types.PersistentState
persistedState types.PersistentStateV14
)
log.Println("Merging old state from", persistentStatePath, "...")
@ -49,7 +48,7 @@ func MergeOldState(response *types.SpaceAPIResponse) {
}
// Merge state
response.State.FromPersistentSpaceState(persistedState)
response.State = persistedState.State
// Merge sensors
for key, environmentSensors := range persistedState.Sensors {
@ -73,7 +72,7 @@ removeOld:
}
}
func SaveCurrentState(response types.SpaceAPIResponse) {
func SaveCurrentState(response types.SpaceAPIResponseV14) {
// Create state directory if not present
err := os.MkdirAll(persistentStateDir, 0750)
if err != nil {
@ -88,38 +87,27 @@ func SaveCurrentState(response types.SpaceAPIResponse) {
defer file.Close()
// Create persistent state
var (
isOpen bool
isUnknown bool
)
switch response.State.Open {
case types.SpaceAPIStateOpenIsUnknown:
isUnknown = true
case types.SpaceAPIStateOpenIsClosed:
break
case types.SpaceAPIStateOpenIsOpen:
isOpen = true
}
persistentState := types.PersistentState{
State: types.PersistentSpaceState{
Open: isOpen, IsUnknown: isUnknown,
LastChange: response.State.LastChange, Message: response.State.Message,
},
Sensors: make(map[string][]types.PersistentEnvironmentSensor),
persistentStateV14 := types.PersistentStateV14{
State: struct {
Open bool `json:"open"`
LastChange int64 `json:"lastchange"`
Message string `json:"message,omitempty"`
}{Open: response.State.Open, LastChange: response.State.LastChange, Message: response.State.Message},
}
// Save sensor state
persistentStateV14.Sensors = make(map[string][]types.PersistentEnvironmentSensor)
for key, environmentSensors := range response.Sensors {
persistentState.Sensors[key] = make([]types.PersistentEnvironmentSensor, len(environmentSensors))
persistentStateV14.Sensors[key] = make([]types.PersistentEnvironmentSensor, len(environmentSensors))
for i, sensor := range environmentSensors {
persistentState.Sensors[key][i].Value = sensor.Value
persistentState.Sensors[key][i].Location = sensor.Location
persistentState.Sensors[key][i].Name = sensor.Name
persistentState.Sensors[key][i].LastChange = sensor.LastChange
persistentStateV14.Sensors[key][i].Value = sensor.Value
persistentStateV14.Sensors[key][i].Location = sensor.Location
persistentStateV14.Sensors[key][i].Name = sensor.Name
persistentStateV14.Sensors[key][i].LastChange = sensor.LastChange
}
}
// Serialize persistent state
marshal, err := json.Marshal(persistentState, json.JoinOptions(jsontext.Multiline(true), jsontext.SpaceAfterComma(true)))
marshal, err := json.MarshalIndent(persistentStateV14, "", "\t")
if err != nil {
log.Fatalln("Failed serializing persistent state... error:", err)
}

69
types/v14.go Normal file
View file

@ -0,0 +1,69 @@
package types
type SpaceAPIResponseV14 struct {
APICompatibility []string `json:"api_compatibility"`
Space string `json:"space"`
Logo string `json:"logo"`
ExtCCC string `json:"ext_ccc"`
URL string `json:"url"`
Location struct {
Address string `json:"address"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
} `json:"location"`
State SpaceState `json:"state"`
Contact struct {
Phone string `json:"phone"`
IRC string `json:"irc"`
Mastodon string `json:"mastodon"`
Email string `json:"email"`
ML string `json:"ml"`
Matrix string `json:"matrix"`
} `json:"contact"`
Sensors map[string][]EnvironmentSensor `json:"sensors"`
Feeds struct {
Blog struct {
Type string `json:"type"`
URL string `json:"url"`
} `json:"blog"`
Calendar struct {
Type string `json:"type"`
URL string `json:"url"`
} `json:"calendar"`
} `json:"feeds"`
Links []struct {
Name string `json:"name"`
URL string `json:"url"`
} `json:"links"`
}
type SpaceState struct {
Open bool `json:"open"`
LastChange int64 `json:"lastchange"`
Message string `json:"message,omitempty"`
}
type EnvironmentSensor struct {
Value float64 `json:"value"`
Unit string `json:"unit"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
LastChange int64 `json:"lastchange,omitempty"`
}
type PersistentStateV14 struct {
State struct {
Open bool `json:"open"`
LastChange int64 `json:"lastchange"`
Message string `json:"message,omitempty"`
} `json:"state"`
Sensors map[string][]PersistentEnvironmentSensor `json:"sensors,omitempty"`
}
type PersistentEnvironmentSensor struct {
Value float64 `json:"value"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
LastChange int64 `json:"lastchange"`
}

View file

@ -1,202 +0,0 @@
package types
import (
"encoding/json/v2"
)
const CurrentMessageVersion = "15"
// SpaceAPIResponse represents the contents of the SpaceAPI message
//
// Currently implemented version: 15
type SpaceAPIResponse struct {
APICompatibility []string `json:"api_compatibility"` // REQUIRED
Space string `json:"space"` // REQUIRED
Logo string `json:"logo"` // REQUIRED
URL string `json:"url"` // REQUIRED
// Minimum number of properties: 1
Location struct {
Address string `json:"address,omitzero"`
Lat float64 `json:"lat,omitzero"`
Lon float64 `json:"lon,omitzero"`
Timezone string `json:"timezone,omitzero"`
CountryCode string `json:"country_code,omitzero"`
Hint string `json:"hint,omitzero"`
// Minimum number of items: 1
Areas []SpaceAPILocationAreas `json:"areas,omitempty"`
} `json:"location,omitzero"`
SpaceFed struct {
SpaceNet bool `json:"spacenet,omitzero"`
SpaceSAML bool `json:"spacesaml,omitzero"`
} `json:"spacefed,omitzero"`
Cam []string `json:"cam,omitempty"`
State SpaceAPIState `json:"state,omitzero"`
Events []SpaceAPIEvents `json:"events,omitempty"`
Contact struct {
Phone string `json:"phone,omitzero"`
Sip string `json:"sip,omitzero"`
// keymasters
Keymasters []struct {
Name string `json:"name,omitzero"`
IRCNick string `json:"irc_nick,omitzero"`
Phone string `json:"phone,omitzero"`
Email string `json:"email,omitzero"`
Twitter string `json:"twitter,omitzero"`
Xmpp string `json:"xmpp,omitzero"`
Mastodon string `json:"mastodon,omitzero"`
Matrix string `json:"matrix,omitzero"`
} `json:"keymasters,omitempty"`
IRC string `json:"irc,omitzero"`
Twitter string `json:"twitter,omitzero"`
Mastodon string `json:"mastodon,omitzero"`
Facebook string `json:"facebook,omitzero"`
Identica string `json:"identica,omitzero"`
Foursquare string `json:"foursquare,omitzero"`
Email string `json:"email,omitzero"`
ML string `json:"ml,omitzero"`
XMPP string `json:"xmpp,omitzero"`
IssueMail string `json:"issue_mail,omitzero"`
Gopher string `json:"gopher,omitzero"`
Matrix string `json:"matrix,omitzero"`
Mumble string `json:"mumble,omitzero"`
} `json:"contact"`
Sensors map[string][]EnvironmentSensor `json:"sensors,omitempty"`
Feeds struct {
Blog struct {
Type string `json:"type,omitzero"`
URL string `json:"url"` // REQUIRED
} `json:"blog,omitzero"`
Wiki struct {
Type string `json:"type,omitzero"`
URL string `json:"url"` // REQUIRED
} `json:"wiki,omitzero"`
Calendar struct {
Type string `json:"type,omitzero"`
URL string `json:"url"` // REQUIRED
} `json:"calendar,omitzero"`
Flickr struct {
Type string `json:"type,omitzero"`
URL string `json:"url"` // REQUIRED
} `json:"flickr,omitzero"`
} `json:"feeds,omitzero"`
Projects []string `json:"projects,omitempty"`
Links []struct {
Name string `json:"name"` // REQUIRED
URL string `json:"url"` // REQUIRED
Description string `json:"description,omitzero"`
} `json:"links,omitempty"`
MembershipPlans []struct {
Name string `json:"name"` // REQUIRED
Value int64 `json:"value"` // REQUIRED
Currency string `json:"currency"` // REQUIRED
BillingInterval string `json:"billing_interval"` // REQUIRED
Description string `json:"description,omitzero"`
} `json:"membership_plans,omitempty"`
LinkedSpaces []struct {
Endpoint string `json:"endpoint,omitzero"`
Website string `json:"website,omitzero"`
} `json:"linked_spaces,omitempty"`
ExtCCC string `json:"ext_ccc,omitzero"`
}
type SpaceAPILocationAreas struct {
Name string `json:"name,omitzero"`
Description string `json:"description,omitzero"`
SquareMeters float64 `json:"square_meters"` // REQUIRED
}
type SpaceAPIStateOpen int
const (
SpaceAPIStateOpenIsUnknown = iota
SpaceAPIStateOpenIsClosed
SpaceAPIStateOpenIsOpen
)
func (state SpaceAPIStateOpen) MarshalJSON() ([]byte, error) {
switch state {
case SpaceAPIStateOpenIsClosed:
return json.Marshal(false)
case SpaceAPIStateOpenIsOpen:
return json.Marshal(true)
default:
return []byte{}, nil
}
}
func (state SpaceAPIStateOpen) IsZero() bool {
return state == SpaceAPIStateOpenIsUnknown
}
type SpaceAPIState struct {
Open SpaceAPIStateOpen `json:"open,omitzero"`
LastChange int64 `json:"lastchange,omitempty"`
TriggerPerson string `json:"trigger_person,omitempty"`
Message string `json:"message,omitempty"`
Icon struct {
Open string `json:"open,omitempty"` // REQUIRED
Closed string `json:"closed,omitempty"` // REQUIRED
} `json:"icon,omitzero"`
}
func (s *SpaceAPIState) FromPersistentSpaceState(ps PersistentState) {
if ps.State.IsUnknown {
s.Open = SpaceAPIStateOpenIsUnknown
} else {
if ps.State.Open {
s.Open = SpaceAPIStateOpenIsOpen
} else {
s.Open = SpaceAPIStateOpenIsClosed
}
}
s.LastChange = ps.State.LastChange
s.Message = ps.State.Message
}
type SpaceAPIEvents struct {
Name string `json:"name"` // REQUIRED
Type string `json:"type"` // REQUIRED
Timestamp int64 `json:"timestamp"` // REQUIRED
Extra string `json:"extra,omitzero"`
}
type EnvironmentSensor struct {
Value float64 `json:"value"`
Unit string `json:"unit"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
LastChange int64 `json:"lastchange,omitempty"`
}
type PersistentState struct {
State PersistentSpaceState `json:"state"`
Sensors map[string][]PersistentEnvironmentSensor `json:"sensors,omitempty"`
}
type PersistentSpaceState struct {
Open bool `json:"open"`
IsUnknown bool `json:"is_unknown"`
LastChange int64 `json:"lastchange"`
Message string `json:"message,omitempty"`
}
type PersistentEnvironmentSensor struct {
Value float64 `json:"value"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
LastChange int64 `json:"lastchange"`
}