├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── assets └── css │ └── semantic.min.css ├── main.go ├── main_test.go ├── reporting ├── README.md └── lambda.py ├── templates ├── error.html ├── form.html ├── landing.html ├── layout.html └── thankyou.html ├── testdata └── config.yml └── unifi-portal.yml.example /.gitignore: -------------------------------------------------------------------------------- 1 | # Config file 2 | unifi-portal.yml 3 | 4 | # Binaries for programs and plugins 5 | unifi-captive-portal* 6 | *.exe 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 18 | .glide/ 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Jarod Watkins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY = unifi-captive-portal 2 | GOARCH = amd64 3 | 4 | VERSION?=? 5 | COMMIT=$(shell git rev-parse HEAD) 6 | BRANCH=$(shell git rev-parse --abbrev-ref HEAD) 7 | 8 | # Setup the -ldflags option for go build here, interpolate the variable values 9 | LDFLAGS = -ldflags "-X main.Version=${VERSION} -X main.Commit=${COMMIT} -X main.Branch=${BRANCH}" 10 | 11 | # Build the project 12 | all: test fmt linux darwin windows 13 | 14 | linux: 15 | GOOS=linux GOARCH=${GOARCH} go build ${LDFLAGS} -o ${BINARY}-linux-${GOARCH} 16 | 17 | darwin: 18 | GOOS=darwin GOARCH=${GOARCH} go build ${LDFLAGS} -o ${BINARY}-darwin-${GOARCH} 19 | 20 | windows: 21 | GOOS=windows GOARCH=${GOARCH} go build ${LDFLAGS} -o ${BINARY}-windows-${GOARCH}.exe 22 | 23 | test: 24 | go test -v 25 | 26 | fmt: 27 | go fmt $$(go list ./... | grep -v /vendor/) 28 | 29 | .PHONY: linux darwin windows test fmt 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UniFi Captive Portal 2 | 3 | A [UniFi](https://www.ubnt.com) external captive portal which captures email 4 | addresses and saves them to a [DynamoDB](https://aws.amazon.com/dynamodb/) table. 5 | 6 | ## Running 7 | 8 | There are two important directories that need to be on disk in order for this 9 | program to run: `assets` and `templates`. Assets holds CSS/JS/IMG assets. 10 | Templates holds the various HTML templates. 11 | 12 | If you would like to add custom elements (such as a header image) feel free. 13 | The CSS library used is [Semantic UI](https://semantic-ui.com) so refer to their 14 | documentation if you would like to modify the look. 15 | 16 | You must have the DynamoDB table already created and have setup a shared 17 | credentials file per the [SDK docs](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html#specifying-credentials). 18 | Please use an IAM role with the least privileges required (read/write access to 19 | the DynamoDB table you created). **DO NOT USE YOUR ROOT ACCOUNT**. 20 | 21 | See the configuration section below for more information regarding the config 22 | file. You will need to specify its location along with the assets and 23 | templates directories. 24 | 25 | Also, due to a limitation in the UniFi controller software, external portals 26 | must run on port 80. I recommend running a reverse proxy (such as NGINX) in front 27 | of this application instead of running it with elevated privileges. 28 | 29 | ``` 30 | unifi-captive-portal: 31 | -asset.dir string 32 | Directory which contains css/js/img assets. (default "assets") 33 | -config.file string 34 | Unifi captive portal configuration file. (default "unifi-portal.yml") 35 | -template.dir string 36 | Directory which contains HTML templates. (default "templates") 37 | -verbose 38 | Enable verbose/debug logging. 39 | -version 40 | Print version/build information. 41 | -web.listen-address string 42 | Address to listen on for requests. (default ":4646") 43 | ``` 44 | 45 | ## Configuration 46 | 47 | Config Key | Value 48 | ---------- | ----- 49 | unifi_url | Full URL of your UniFi Controller. Be sure to include the port it is running on (8443 is the default) 50 | unifi_username | Username of the user to make API calls with. It is recommended to use a dedicated user 51 | unifi_password | Password for user defined above 52 | unifi_site | The name of the site the APs/Users reside in. Usually this is default 53 | title | Title used in HTML pages as well as headings. Usually you will put your company name here 54 | intro | Paragraph of text below the page title and above the form requesting a user for their email. You may wish to offer a brief explanation of why you are collecting their email address. 55 | tos | Terms of Service. I am not a lawyer, the sample TOS provided is in no way legally binding nor implied valid. Please consult legal advice for what to put here. 56 | minutes | Amount of time to register user for 57 | redirect_url | URL to redirect users to if they do not provide one to the controller 58 | dynamo_table_name | Name of your DynamoDB table to store the collected email addresses 59 | 60 | 61 | ## Building 62 | 63 | Pre built binaries are provided on Github, but if you prefer to manually compile, 64 | there is a Make file provided. The version variable is not required but highly 65 | recommended. 66 | 67 | $ VERSION="version" make 68 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "encoding/json" 7 | "errors" 8 | "flag" 9 | "fmt" 10 | "gopkg.in/yaml.v2" 11 | "html/template" 12 | "io/ioutil" 13 | "net/http" 14 | "net/http/cookiejar" 15 | "os" 16 | "time" 17 | 18 | "github.com/aws/aws-sdk-go/aws" 19 | "github.com/aws/aws-sdk-go/aws/session" 20 | "github.com/aws/aws-sdk-go/service/dynamodb" 21 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" 22 | 23 | log "github.com/sirupsen/logrus" 24 | ) 25 | 26 | var ( 27 | configFile = flag.String( 28 | "config.file", "unifi-portal.yml", 29 | "UniFi captive portal configuration file.", 30 | ) 31 | listenAddress = flag.String( 32 | "web.listen-address", ":4646", 33 | "Address to listen on for requests.", 34 | ) 35 | templateDir = flag.String( 36 | "template.dir", "templates", 37 | "Directory which contains HTML templates.", 38 | ) 39 | assetDir = flag.String( 40 | "asset.dir", "assets", 41 | "Directory which contains css/js/img assets.", 42 | ) 43 | verbose = flag.Bool( 44 | "verbose", false, 45 | "Enable verbose/debug logging.", 46 | ) 47 | version = flag.Bool( 48 | "version", false, 49 | "Print version/build information.", 50 | ) 51 | 52 | templates = template.Must(template.ParseGlob(fmt.Sprintf("%s/*", *templateDir))) 53 | config *Config 54 | 55 | Version string 56 | Commit string 57 | Branch string 58 | ) 59 | 60 | // Config represents the YAML structure of the configuration file. 61 | type Config struct { 62 | // UnifiURL URL of the UniFi instance you want to register users against 63 | UnifiURL string `yaml:"unifi_url"` 64 | // UnifiUsername username for UniFi API 65 | UnifiUsername string `yaml:"unifi_username"` 66 | // UnifiPassword password for UniFi API 67 | UnifiPassword string `yaml:"unifi_password"` 68 | // UnifiSite site for UniFi Controller 69 | UnifiSite string `yaml:"unifi_site"` 70 | // Title of HTML pages 71 | Title string `yaml:"title"` 72 | // TOS Terms of Service 73 | TOS string `yaml:"tos"` 74 | // Intro text on form page 75 | Intro string `yaml:"intro"` 76 | // Minutes user is authenticate for in one session 77 | Minutes string `yaml:"minutes"` 78 | // RedirectURL url to redirect user to if they did not have one supplied 79 | RedirectURL string `yaml:"redirect_url"` 80 | // DynamoTableName 81 | DynamoTableName string `yaml:"dynamo_table_name"` 82 | } 83 | 84 | type Db struct { 85 | Email string `json:"email"` 86 | ID string `json:"id"` 87 | AP string `json:"ap"` 88 | SSID string `json:"ssid"` 89 | Date time.Time `json:"date"` 90 | } 91 | 92 | type Page struct { 93 | Title string 94 | ID string 95 | AP string 96 | SSID string 97 | URL string 98 | TOS string 99 | Intro string 100 | Errors map[string]string 101 | FormEmail string 102 | FormTOS string 103 | } 104 | 105 | func authUser(id string, ap string) error { 106 | jar, err := cookiejar.New(nil) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | tr := &http.Transport{ 112 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 113 | } 114 | 115 | client := &http.Client{Jar: jar, Transport: tr} 116 | 117 | data := map[string]string{ 118 | "username": config.UnifiUsername, 119 | "password": config.UnifiPassword, 120 | } 121 | 122 | b := new(bytes.Buffer) 123 | json.NewEncoder(b).Encode(data) 124 | 125 | req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/login", config.UnifiURL), b) 126 | req.Header.Set("Content-Type", "application/json") 127 | 128 | resp, err := client.Do(req) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | log.WithFields(log.Fields{ 134 | "code": resp.StatusCode, 135 | }).Debug("login response") 136 | 137 | if resp.StatusCode != 200 { 138 | return errors.New("Controller returned non 200 status code") 139 | } 140 | 141 | data = map[string]string{ 142 | "cmd": "authorize-guest", 143 | "mac": id, 144 | "minutes": config.Minutes, 145 | } 146 | 147 | b = new(bytes.Buffer) 148 | json.NewEncoder(b).Encode(data) 149 | 150 | req, err = http.NewRequest("POST", fmt.Sprintf("%s/api/s/%s/cmd/stamgr", config.UnifiURL, config.UnifiSite), b) 151 | req.Header.Set("Content-Type", "application/json") 152 | 153 | resp, err = client.Do(req) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | log.WithFields(log.Fields{ 159 | "code": resp.StatusCode, 160 | }).Debug("authorize command response") 161 | 162 | if resp.StatusCode != 200 { 163 | return errors.New("Controller returned non 200 status code") 164 | } 165 | 166 | data = map[string]string{ 167 | "cmd": "authorize-guest", 168 | "mac": id, 169 | "minutes": config.Minutes, 170 | } 171 | 172 | req, err = http.NewRequest("GET", fmt.Sprintf("%s/logout", config.UnifiURL), nil) 173 | _, err = client.Do(req) 174 | if err != nil { 175 | return err 176 | } 177 | 178 | return nil 179 | } 180 | 181 | func dbSave(email string, id string, ap string, ssid string) error { 182 | sess := session.Must(session.NewSessionWithOptions(session.Options{ 183 | SharedConfigState: session.SharedConfigEnable, 184 | })) 185 | 186 | svc := dynamodb.New(sess) 187 | 188 | item := Db{ 189 | Email: email, 190 | ID: id, 191 | AP: ap, 192 | SSID: ssid, 193 | Date: time.Now(), 194 | } 195 | 196 | av, err := dynamodbattribute.MarshalMap(item) 197 | if err != nil { 198 | return err 199 | } 200 | 201 | input := &dynamodb.PutItemInput{ 202 | Item: av, 203 | TableName: aws.String(config.DynamoTableName), 204 | } 205 | 206 | _, err = svc.PutItem(input) 207 | if err != nil { 208 | return err 209 | } 210 | 211 | return nil 212 | } 213 | 214 | func landingHandler(w http.ResponseWriter, r *http.Request) { 215 | id := r.URL.Query().Get("id") 216 | if id == "" { 217 | log.Error("Request missing id query parameter") 218 | errorHandler(w, r, http.StatusBadRequest) 219 | return 220 | } 221 | 222 | ap := r.URL.Query().Get("ap") 223 | if ap == "" { 224 | log.Error("Request missing ap query parameter") 225 | errorHandler(w, r, http.StatusBadRequest) 226 | return 227 | } 228 | 229 | ssid := r.URL.Query().Get("ssid") 230 | if ssid == "" { 231 | log.Error("Request missing ssid query parameter") 232 | errorHandler(w, r, http.StatusBadRequest) 233 | return 234 | } 235 | 236 | url := r.URL.Query().Get("url") 237 | 238 | vars := Page{ 239 | Title: config.Title, 240 | ID: id, 241 | AP: ap, 242 | SSID: ssid, 243 | URL: url, 244 | TOS: config.TOS, 245 | Intro: config.Intro, 246 | } 247 | 248 | err := templates.ExecuteTemplate(w, "landingPage", vars) 249 | if err != nil { 250 | log.Error(err.Error()) 251 | errorHandler(w, r, http.StatusInternalServerError) 252 | return 253 | } 254 | } 255 | 256 | func formHandler(w http.ResponseWriter, r *http.Request) { 257 | r.ParseForm() 258 | errors := make(map[string]string) 259 | 260 | if len(r.Form["email"][0]) == 0 { 261 | errors["email"] = "Please enter your email address" 262 | } 263 | 264 | var tos string 265 | _, ok := r.Form["tos"] 266 | if ok { 267 | tos = "on" 268 | } else { 269 | errors["tos"] = "You must agree to the Terms of Service" 270 | } 271 | 272 | if len(errors) != 0 { 273 | log.WithFields(log.Fields{ 274 | "errors": errors, 275 | }).Debug("Form Errors") 276 | 277 | vars := Page{ 278 | Title: config.Title, 279 | ID: r.Form["id"][0], 280 | AP: r.Form["ap"][0], 281 | SSID: r.Form["ssid"][0], 282 | URL: r.Form["url"][0], 283 | TOS: config.TOS, 284 | Intro: config.Intro, 285 | Errors: errors, 286 | FormEmail: r.Form["email"][0], 287 | FormTOS: tos, 288 | } 289 | 290 | err := templates.ExecuteTemplate(w, "landingPage", vars) 291 | if err != nil { 292 | log.Error(err.Error()) 293 | errorHandler(w, r, http.StatusInternalServerError) 294 | return 295 | } 296 | return 297 | } 298 | 299 | err := authUser(r.Form["id"][0], r.Form["ap"][0]) 300 | if err != nil { 301 | log.Error(err.Error()) 302 | errorHandler(w, r, http.StatusInternalServerError) 303 | return 304 | } 305 | 306 | redirect_url := config.RedirectURL 307 | 308 | if len(r.Form["url"][0]) != 0 { 309 | redirect_url = r.Form["url"][0] 310 | } 311 | 312 | log.WithFields(log.Fields{ 313 | "url": redirect_url, 314 | }).Debug("Redirecting user") 315 | 316 | vars := Page{Title: config.Title, URL: redirect_url} 317 | 318 | err = templates.ExecuteTemplate(w, "thankYouPage", vars) 319 | if err != nil { 320 | log.Error(err.Error()) 321 | errorHandler(w, r, http.StatusInternalServerError) 322 | return 323 | } 324 | 325 | err = dbSave(r.Form["email"][0], r.Form["id"][0], r.Form["ap"][0], r.Form["ssid"][0]) 326 | if err != nil { 327 | log.Error(err.Error()) 328 | } 329 | } 330 | 331 | func errorHandler(w http.ResponseWriter, r *http.Request, code int) { 332 | w.WriteHeader(code) 333 | vars := Page{Title: config.Title} 334 | err := templates.ExecuteTemplate(w, "errorPage", vars) 335 | if err != nil { 336 | log.Error(err.Error()) 337 | } 338 | } 339 | 340 | func loadConfiguration(file string) (*Config, error) { 341 | yamlFile, err := ioutil.ReadFile(*configFile) 342 | if err != nil { 343 | return nil, err 344 | } 345 | 346 | config := new(Config) 347 | err = yaml.Unmarshal(yamlFile, &config) 348 | if err != nil { 349 | return nil, err 350 | } 351 | 352 | return config, nil 353 | } 354 | 355 | func logRequest(handler http.Handler) http.Handler { 356 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 357 | log.WithFields(log.Fields{ 358 | "remote_addr": r.RemoteAddr, 359 | "method": r.Method, 360 | "url": r.URL, 361 | }).Debug("Request") 362 | handler.ServeHTTP(w, r) 363 | }) 364 | } 365 | 366 | func info() string { 367 | return fmt.Sprintf("version=%s, commit=%s, branch=%s", Version, Commit, Branch) 368 | } 369 | 370 | func main() { 371 | flag.Parse() 372 | 373 | if *version { 374 | fmt.Println(info()) 375 | os.Exit(0) 376 | } 377 | 378 | if *verbose { 379 | log.SetLevel(log.DebugLevel) 380 | } 381 | 382 | log.Info("Starting up...") 383 | log.Debug(info()) 384 | 385 | var err error 386 | config, err = loadConfiguration(*configFile) 387 | if err != nil { 388 | log.Fatalf("Configuration error, aborting: %s", err) 389 | } 390 | 391 | fs := http.FileServer(http.Dir(*assetDir)) 392 | http.Handle("/assets/", http.StripPrefix("/assets/", fs)) 393 | http.HandleFunc("/guest/s/default/", landingHandler) 394 | http.HandleFunc("/form", formHandler) 395 | log.Fatal(http.ListenAndServe(*listenAddress, logRequest(http.DefaultServeMux))) 396 | 397 | } 398 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func init() { 13 | c := "testdata/config.yml" 14 | configFile = &c 15 | 16 | var err error 17 | config, err = loadConfiguration("testdata/config.yml") 18 | if err != nil { 19 | panic("Could not load test configuration: " + err.Error()) 20 | } 21 | } 22 | 23 | func TestLandingHandler(t *testing.T) { 24 | req, err := http.NewRequest("GET", "/?id=1234&ap=ap01&ssid=ssid", nil) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | rr := httptest.NewRecorder() 30 | handler := http.HandlerFunc(landingHandler) 31 | 32 | handler.ServeHTTP(rr, req) 33 | 34 | if status := rr.Code; status != http.StatusOK { 35 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) 36 | } 37 | 38 | expected := "Test Captive Portal" 39 | if !strings.Contains(rr.Body.String(), expected) { 40 | t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) 41 | } 42 | 43 | } 44 | 45 | func TestLandingHandlerValidation(t *testing.T) { 46 | req, err := http.NewRequest("GET", "/", nil) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | 51 | rr := httptest.NewRecorder() 52 | handler := http.HandlerFunc(landingHandler) 53 | 54 | handler.ServeHTTP(rr, req) 55 | 56 | if status := rr.Code; status != http.StatusBadRequest { 57 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusBadRequest) 58 | } 59 | 60 | expected := "An error has occured" 61 | if !strings.Contains(rr.Body.String(), expected) { 62 | t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) 63 | } 64 | 65 | } 66 | 67 | func TestFormHandlerValidation(t *testing.T) { 68 | data := make(url.Values) 69 | data.Set("email", "user@example.com") 70 | data.Set("id", "1234") 71 | data.Set("ap", "ap01") 72 | data.Set("ssid", "ssid") 73 | data.Set("url", "") 74 | 75 | req, err := http.NewRequest("POST", "/form", strings.NewReader(data.Encode())) 76 | if err != nil { 77 | t.Fatal(err) 78 | } 79 | 80 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 81 | 82 | rr := httptest.NewRecorder() 83 | handler := http.HandlerFunc(formHandler) 84 | 85 | handler.ServeHTTP(rr, req) 86 | 87 | if status := rr.Code; status != http.StatusOK { 88 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) 89 | } 90 | 91 | expected := "You must agree to the Terms of Service" 92 | if !strings.Contains(rr.Body.String(), expected) { 93 | t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) 94 | } 95 | 96 | } 97 | 98 | func TestAuthUser(t *testing.T) { 99 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 100 | w.Header().Set("Content-Type", "application/json") 101 | fmt.Fprintln(w, `{"response":"OK"}`) 102 | })) 103 | defer ts.Close() 104 | 105 | config.UnifiURL = ts.URL 106 | err := authUser("02:00:00:00:00:01", "ap01") 107 | if err != nil { 108 | t.Errorf("Mock request to UniFi controller returned: %v", err) 109 | } 110 | 111 | ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 112 | w.Header().Set("Content-Type", "application/json") 113 | w.WriteHeader(500) 114 | fmt.Fprintln(w, `{"response":"OK"}`) 115 | })) 116 | defer ts.Close() 117 | 118 | config.UnifiURL = ts.URL 119 | err = authUser("02:00:00:00:00:01", "ap01") 120 | expected := "Controller returned non 200 status code" 121 | 122 | if !strings.Contains(err.Error(), expected) { 123 | t.Errorf("Mock request to UniFi controller returned: %v, wanted Controller returned non 200 status code", err) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /reporting/README.md: -------------------------------------------------------------------------------- 1 | # UniFi Captive Portal Reporting Function 2 | 3 | This lambda function allows you to send an email with the collected data as a CSV 4 | attachment. 5 | -------------------------------------------------------------------------------- /reporting/lambda.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import csv 3 | import datetime 4 | import io 5 | import json 6 | import os 7 | 8 | from boto3.dynamodb.conditions import Key 9 | from email.mime.text import MIMEText 10 | from email.mime.application import MIMEApplication 11 | from email.mime.multipart import MIMEMultipart 12 | 13 | dynamodb = boto3.resource('dynamodb') 14 | ses = boto3.client('ses') 15 | 16 | print('Loading function') 17 | 18 | def lambda_handler(event, context): 19 | end = datetime.datetime.now() 20 | start = end - datetime.timedelta(days=int(event['days'])) 21 | 22 | table = dynamodb.Table(os.environ['DYNAMODB_TABLE']) 23 | 24 | fe = Key('date').between(start.isoformat(), end.isoformat()) 25 | 26 | response = table.scan( 27 | FilterExpression=fe, 28 | ) 29 | 30 | with open('/tmp/emails.csv', 'w', newline='') as file: 31 | writer = csv.writer(file, 32 | quoting=csv.QUOTE_NONNUMERIC, 33 | ) 34 | 35 | for i in response['Items']: 36 | writer.writerow([i['email']]) 37 | 38 | msg = MIMEMultipart() 39 | msg['Subject'] = event['email']['subject'] 40 | msg['From'] = event['email']['from'] 41 | msg['To'] = ', '.join(event['email']['to']) 42 | 43 | part = MIMEText('Attached is your captive portal email report from {} to {}.'.format(start.isoformat(), end.isoformat())) 44 | msg.attach(part) 45 | 46 | part = MIMEApplication(open('/tmp/emails.csv', 'rb').read()) 47 | part.add_header('Content-Disposition', 'attachment', filename='emails.csv') 48 | msg.attach(part) 49 | 50 | ses.send_raw_email( 51 | RawMessage={'Data': msg.as_string()}, 52 | Source=msg['From'], 53 | Destinations=event['email']['to'] 54 | ) 55 | 56 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | {{ define "errorPage" }} 2 | 3 | {{ template "head" . }} 4 |
An error has occured. Please contact the site administrator for further assistance.
6 | {{ template "tail" . }} 7 | 8 | {{ end }} 9 | 10 | -------------------------------------------------------------------------------- /templates/form.html: -------------------------------------------------------------------------------- 1 | {{ define "form" }} 2 |{{ .Intro }}
3 | 35 | {{ end }} 36 | -------------------------------------------------------------------------------- /templates/landing.html: -------------------------------------------------------------------------------- 1 | {{ define "landingPage" }} 2 | 3 | {{ template "head" . }} 4 |