Initial Codebase (untested)

This commit is contained in:
James Mills
2021-01-30 14:05:04 +10:00
parent c1dc91b7e0
commit 4529ea3196
60 changed files with 9807 additions and 0 deletions

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
Dockerfile
*~
*.db
*.bak
.DS_Store
*.idea
/dist
/twt
/twtd
/cmd/twt/twt
/cmd/twtd/twtd
/data/cache
/data/feeds/*
/data/media/*
/data/avatars/*
/data/feedsources
/internal/rice-box.go

34
.drone.yml Normal file
View File

@@ -0,0 +1,34 @@
---
kind: pipeline
name: default
steps:
- name: build
image: r.mills.io/prologic/golang-alpine-ffmpeg:latest
volumes:
- name: gomodcache
path: /go/pkg/mod/cache
privileged: true
environment:
GOPROXY: https://goproxy.mills.io
commands:
- make deps
- make build
- name: notify
image: plugins/webhook
settings:
urls:
- https://msgbus.mills.io/ci.mills.io
when:
status:
- success
- failure
image_pull_secrets:
- dockerconfigjson
volumes:
- name: gomodcache
host:
path: /var/lib/cache/go

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
*~
*.bak
**/.DS_Store
/data
/spyda
/cmd/spyda/spyda

68
Dockerfile Normal file
View File

@@ -0,0 +1,68 @@
# Build
FROM golang:alpine AS build
RUN apk add --no-cache -U build-base git make ffmpeg-dev
RUN mkdir -p /src
WORKDIR /src
# Copy Makefile
COPY Makefile ./
# Copy go.mod and go.sum and install and cache dependencies
COPY go.mod .
COPY go.sum .
# Install deps
RUN make deps
RUN go mod download
# Copy static assets
COPY ./internal/static/css/* ./internal/static/css/
COPY ./internal/static/img/* ./internal/static/img/
COPY ./internal/static/js/* ./internal/static/js/
# Copy pages
COPY ./internal/pages/* ./internal/pages/
# Copy templates
COPY ./internal/templates/* ./internal/templates/
# Copy sources
COPY *.go ./
COPY ./internal/*.go ./internal/
COPY ./internal/auth/*.go ./internal/auth/
COPY ./internal/session/*.go ./internal/session/
COPY ./internal/passwords/*.go ./internal/passwords/
COPY ./internal/webmention/*.go ./internal/webmention/
COPY ./types/*.go ./types/
COPY ./types/retwt/*.go ./types/retwt/
COPY ./cmd/twtd/*.go ./cmd/twtd/
# Version/Commit (there there is no .git in Docker build context)
# NOTE: This is fairly low down in the Dockerfile instructions so
# we don't break the Docker build cache just be changing
# unrelated files that actually haven't changed but caused the
# COMMIT value to change.
ARG VERSION="0.0.0"
ARG COMMIT="HEAD"
# Build server binary
RUN make server VERSION=$VERSION COMMIT=$COMMIT
# Runtime
FROM alpine:latest
RUN apk --no-cache -U add ca-certificates tzdata ffmpeg
WORKDIR /
VOLUME /data
# force cgo resolver
ENV GODEBUG=netdns=cgo
COPY --from=build /src/twtd /twtd
ENTRYPOINT ["/twtd"]
CMD [""]

62
Makefile Normal file
View File

@@ -0,0 +1,62 @@
.PHONY: deps dev build install image test clean
CGO_ENABLED=0
VERSION=$(shell git describe --abbrev=0 --tags 2>/dev/null || echo "$VERSION")
COMMIT=$(shell git rev-parse --short HEAD || echo "$COMMIT")
all: build
deps:
@go get -u github.com/GeertJohan/go.rice/rice
@go get -u github.com/tdewolff/minify/v2/cmd/...
dev : DEBUG=1
dev : build
@./twt -v
@./twtd -D -O -R
cli:
@go build -tags "netgo static_build" -installsuffix netgo \
-ldflags "-w \
-X $(shell go list).Version=$(VERSION) \
-X $(shell go list).Commit=$(COMMIT)" \
./cmd/twt/...
server: generate
@go build -tags "netgo static_build" -installsuffix netgo \
-ldflags "-w \
-X $(shell go list).Version=$(VERSION) \
-X $(shell go list).Commit=$(COMMIT)" \
./cmd/twtd/...
build: cli server
generate:
@if [ x"$(DEBUG)" = x"1" ]; then \
echo 'Running in debug mode...'; \
rm -f -v ./internal/rice-box.go; \
else \
minify -b -o ./internal/static/css/spyda.min.css ./internal/static/css/[0-9]*-*.css; \
minify -b -o ./internal/static/js/spyda.min.js ./internal/static/js/[0-9]*-*.js; \
rm -f ./internal/rice-box.go; \
rice -i ./internal embed-go; \
fi
install: build
@go install ./cmd/twt/...
@go install ./cmd/twtd/...
ifeq ($(PUBLISH), 1)
image:
@docker build --build-arg VERSION="$(VERSION)" --build-arg COMMIT="$(COMMIT)" -t r.mills.io/prologic/spyda .
@docker push r.mills.io/prologic/spyda
else
image:
@docker build --build-arg VERSION="$(VERSION)" --build-arg COMMIT="$(COMMIT)" -t r.mills.io/prologic/spyda .
endif
test:
@go test -v -cover -race ./...
clean:
@git clean -f -d -X

324
cmd/spyda/main.go Normal file
View File

@@ -0,0 +1,324 @@
package main
import (
"expvar"
"fmt"
"net/http"
"net/http/pprof"
"os"
"path"
"strings"
"time"
log "github.com/sirupsen/logrus"
flag "github.com/spf13/pflag"
profiler "github.com/wblakecaldwell/profiler"
"git.mills.io/prologic/spyda"
"git.mills.io/prologic/spyda/internal"
"git.mills.io/prologic/spyda/types/retwt"
)
var (
bind string
debug bool
version bool
// Basic options
name string
description string
data string
store string
theme string
baseURL string
// Pod Oeprator
adminUser string
adminName string
adminEmail string
// Pod Settings
openProfiles bool
openRegistrations bool
// Pod Limits
twtsPerPage int
maxTwtLength int
maxUploadSize int64
maxFetchLimit int64
maxCacheTTL time.Duration
maxCacheItems int
// Pod Secrets
apiSigningKey string
cookieSecret string
magiclinkSecret string
// Email Setitngs
smtpHost string
smtpPort int
smtpUser string
smtpPass string
smtpFrom string
// Messaging Settings
smtpBind string
pop3Bind string
// Timeouts
sessionExpiry time.Duration
sessionCacheTTL time.Duration
apiSessionTime time.Duration
transcoderTimeout time.Duration
// Whitelists, Sources
feedSources []string
whitelistedDomains []string
)
func init() {
flag.BoolVarP(&debug, "debug", "D", false, "enable debug logging")
flag.StringVarP(&bind, "bind", "b", "0.0.0.0:8000", "[int]:<port> to bind to")
flag.BoolVarP(&version, "version", "v", false, "display version information")
// Basic options
flag.StringVarP(&name, "name", "n", internal.DefaultName, "set the pod's name")
flag.StringVarP(&description, "description", "m", internal.DefaultMetaDescription, "set the pod's description")
flag.StringVarP(&data, "data", "d", internal.DefaultData, "data directory")
flag.StringVarP(&store, "store", "s", internal.DefaultStore, "store to use")
flag.StringVarP(&theme, "theme", "t", internal.DefaultTheme, "set the default theme")
flag.StringVarP(&baseURL, "base-url", "u", internal.DefaultBaseURL, "base url to use")
// Pod Oeprator
flag.StringVarP(&adminName, "admin-name", "N", internal.DefaultAdminName, "default admin user name")
flag.StringVarP(&adminEmail, "admin-email", "E", internal.DefaultAdminEmail, "default admin user email")
flag.StringVarP(&adminUser, "admin-user", "A", internal.DefaultAdminUser, "default admin user to use")
// Pod Settings
flag.BoolVarP(
&openRegistrations, "open-registrations", "R", internal.DefaultOpenRegistrations,
"whether or not to have open user registgration",
)
flag.BoolVarP(
&openProfiles, "open-profiles", "O", internal.DefaultOpenProfiles,
"whether or not to have open user profiles",
)
// Pod Limits
flag.IntVarP(
&twtsPerPage, "twts-per-page", "T", internal.DefaultTwtsPerPage,
"maximum twts per page to display",
)
flag.IntVarP(
&maxTwtLength, "max-twt-length", "L", internal.DefaultMaxTwtLength,
"maximum length of posts",
)
flag.Int64VarP(
&maxUploadSize, "max-upload-size", "U", internal.DefaultMaxUploadSize,
"maximum upload size of media",
)
flag.Int64VarP(
&maxFetchLimit, "max-fetch-limit", "F", internal.DefaultMaxFetchLimit,
"maximum feed fetch limit in bytes",
)
flag.DurationVarP(
&maxCacheTTL, "max-cache-ttl", "C", internal.DefaultMaxCacheTTL,
"maximum cache ttl (time-to-live) of cached twts in memory",
)
flag.IntVarP(
&maxCacheItems, "max-cache-items", "I", internal.DefaultMaxCacheItems,
"maximum cache items (per feed source) of cached twts in memory",
)
// Pod Secrets
flag.StringVar(
&apiSigningKey, "api-signing-key", internal.DefaultAPISigningKey,
"secret to use for signing api tokens",
)
flag.StringVar(
&cookieSecret, "cookie-secret", internal.DefaultCookieSecret,
"cookie secret to use secure sessions",
)
flag.StringVar(
&magiclinkSecret, "magiclink-secret", internal.DefaultMagicLinkSecret,
"magiclink secret to use for password reset tokens",
)
// Email Setitngs
flag.StringVar(&smtpHost, "smtp-host", internal.DefaultSMTPHost, "SMTP Host to use for email sending")
flag.IntVar(&smtpPort, "smtp-port", internal.DefaultSMTPPort, "SMTP Port to use for email sending")
flag.StringVar(&smtpUser, "smtp-user", internal.DefaultSMTPUser, "SMTP User to use for email sending")
flag.StringVar(&smtpPass, "smtp-pass", internal.DefaultSMTPPass, "SMTP Pass to use for email sending")
flag.StringVar(&smtpFrom, "smtp-from", internal.DefaultSMTPFrom, "SMTP From to use for email sending")
// Messaging Settings
flag.StringVar(&smtpBind, "smtp-bind", internal.DefaultSMTPBind, "SMTP interface and port to bind to")
flag.StringVar(&pop3Bind, "pop3-bind", internal.DefaultPOP3Bind, "POP3 interface and port to bind to")
// Timeouts
flag.DurationVar(
&sessionExpiry, "session-expiry", internal.DefaultSessionExpiry,
"timeout for sessions to expire",
)
flag.DurationVar(
&sessionCacheTTL, "session-cache-ttl", internal.DefaultSessionCacheTTL,
"time-to-live for cached sessions",
)
flag.DurationVar(
&apiSessionTime, "api-session-time", internal.DefaultAPISessionTime,
"timeout for api tokens to expire",
)
flag.DurationVar(
&transcoderTimeout, "transcoder-timeout", internal.DefaultTranscoderTimeout,
"timeout for the video transcoder",
)
// Whitelists, Sources
flag.StringSliceVar(
&feedSources, "feed-sources", internal.DefaultFeedSources,
"external feed sources for discovery of other feeds",
)
flag.StringSliceVar(
&whitelistedDomains, "whitelist-domain", internal.DefaultWhitelistedDomains,
"whitelist of external domains to permit for display of inline images",
)
}
func flagNameFromEnvironmentName(s string) string {
s = strings.ToLower(s)
s = strings.Replace(s, "_", "-", -1)
return s
}
func parseArgs() error {
for _, v := range os.Environ() {
vals := strings.SplitN(v, "=", 2)
flagName := flagNameFromEnvironmentName(vals[0])
fn := flag.CommandLine.Lookup(flagName)
if fn == nil || fn.Changed {
continue
}
if err := fn.Value.Set(vals[1]); err != nil {
return err
}
}
flag.Parse()
return nil
}
func extraServiceInfoFactory(svr *internal.Server) profiler.ExtraServiceInfoRetriever {
return func() map[string]interface{} {
extraInfo := make(map[string]interface{})
expvar.Get("stats").(*expvar.Map).Do(func(kv expvar.KeyValue) {
extraInfo[kv.Key] = kv.Value.String()
})
return extraInfo
}
}
func main() {
parseArgs()
if version {
fmt.Printf("spyda v%s", spyda.FullVersion())
os.Exit(0)
}
if debug {
log.SetLevel(log.DebugLevel)
} else {
log.SetLevel(log.InfoLevel)
}
retwt.DefaultTwtManager()
svr, err := internal.NewServer(bind,
// Debug mode
internal.WithDebug(debug),
// Basic options
internal.WithName(name),
internal.WithDescription(description),
internal.WithData(data),
internal.WithStore(store),
internal.WithTheme(theme),
internal.WithBaseURL(baseURL),
// Pod Oeprator
internal.WithAdminUser(adminUser),
internal.WithAdminName(adminName),
internal.WithAdminEmail(adminEmail),
// Pod Settings
internal.WithOpenProfiles(openProfiles),
internal.WithOpenRegistrations(openRegistrations),
// Pod Limits
internal.WithTwtsPerPage(twtsPerPage),
internal.WithMaxTwtLength(maxTwtLength),
internal.WithMaxUploadSize(maxUploadSize),
internal.WithMaxFetchLimit(maxFetchLimit),
internal.WithMaxCacheTTL(maxCacheTTL),
internal.WithMaxCacheItems(maxCacheItems),
// Pod Secrets
internal.WithAPISigningKey(apiSigningKey),
internal.WithCookieSecret(cookieSecret),
internal.WithMagicLinkSecret(magiclinkSecret),
// Email Setitngs
internal.WithSMTPHost(smtpHost),
internal.WithSMTPPort(smtpPort),
internal.WithSMTPUser(smtpUser),
internal.WithSMTPPass(smtpPass),
internal.WithSMTPFrom(smtpFrom),
// Messaging Settings
internal.WithSMTPBind(smtpBind),
internal.WithPOP3Bind(pop3Bind),
// Timeouts
internal.WithSessionExpiry(sessionExpiry),
internal.WithSessionCacheTTL(sessionCacheTTL),
internal.WithAPISessionTime(apiSessionTime),
internal.WithTranscoderTimeout(transcoderTimeout),
// Whitelists, Sources
internal.WithFeedSources(feedSources),
internal.WithWhitelistedDomains(whitelistedDomains),
)
if err != nil {
log.WithError(err).Fatal("error creating server")
}
if debug {
log.Info("starting memory profiler (debug mode) ...")
go func() {
// add the profiler handler endpoints
profiler.AddMemoryProfilingHandlers()
// add realtime extra key/value diagnostic info (optional)
profiler.RegisterExtraServiceInfoRetriever(extraServiceInfoFactory(svr))
// start the profiler on service start (optional)
profiler.StartProfiling()
// Add pprof handlers
http.Handle("/debug/pprof/block", pprof.Handler("block"))
http.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine"))
http.Handle("/debug/pprof/heap", pprof.Handler("heap"))
http.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate"))
// listen on port 6060 (pick a port)
http.ListenAndServe(":6060", nil)
}()
}
log.Infof("%s v%s listening on http://%s", path.Base(os.Args[0]), spyda.FullVersion(), bind)
if err := svr.Run(); err != nil {
log.WithError(err).Fatal("error running or shutting down server")
}
}

12
docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
---
version: "3.8"
services:
spyda:
build: .
image: r.mills.io/prologic/spyda:latest
command: -d /data -s bitcask:///data/spyda.db -u http://127.0.0.1:8000
ports:
- "8000:8000/tcp"
volumes:
- ./data:/data

58
go.mod Normal file
View File

@@ -0,0 +1,58 @@
module veri-index
go 1.13
require (
github.com/GeertJohan/go.rice v1.0.2
github.com/Masterminds/goutils v1.1.0 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
github.com/Masterminds/sprig v2.22.0+incompatible
github.com/NYTimes/gziphandler v1.1.1
github.com/PuerkitoBio/goquery v1.6.1
github.com/andyleap/microformats v0.0.0-20150523144534-25ae286f528b
github.com/audiolion/ipip v1.0.0
github.com/bakape/thumbnailer/v2 v2.6.6
github.com/bbalet/stopwords v1.0.0
github.com/chai2010/webp v1.1.0
github.com/creasty/defaults v1.5.1
github.com/cyphar/filepath-securejoin v0.2.2
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/disintegration/gift v1.2.1
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/dustin/go-humanize v1.0.0
github.com/gabstv/merger v1.0.1
github.com/goccy/go-yaml v1.8.6
github.com/gomarkdown/markdown v0.0.0-20201113031856-722100d81a8e
github.com/gorilla/feeds v1.1.1
github.com/gorilla/mux v1.8.0
github.com/goware/urlx v0.3.1
github.com/h2non/filetype v1.1.1
github.com/huandu/xstrings v1.3.2 // indirect
github.com/imdario/mergo v0.3.11 // indirect
github.com/james4k/fmatter v0.0.0-20150827042251-377c8ea6259d
github.com/julienschmidt/httprouter v1.3.0
github.com/justinas/nosurf v1.1.1
github.com/kljensen/snowball v0.6.0
github.com/lithammer/shortuuid/v3 v3.0.5
github.com/microcosm-cc/bluemonday v1.0.4
github.com/mitchellh/copystructure v1.0.0 // indirect
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/prologic/bitcask v0.3.10
github.com/prologic/observe v0.0.0-20181231082615-747b185a0928
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
github.com/rickb777/accept v0.0.0-20170318132422-d5183c44530d
github.com/robfig/cron v1.2.0
github.com/securisec/go-keywords v0.0.0-20200619134240-769e7273f2ed
github.com/sirupsen/logrus v1.7.0
github.com/spf13/pflag v1.0.5
github.com/steambap/captcha v1.3.1
github.com/theplant-retired/timezones v0.0.0-20150304063004-f9bd3c0ef9db
github.com/unrolled/logger v0.0.0-20201216141554-31a3694fe979
github.com/vcraescu/go-paginator v1.0.0
github.com/wblakecaldwell/profiler v0.0.0-20150908040756-6111ef1313a1
github.com/writeas/slug v1.2.0
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
google.golang.org/appengine v1.6.7 // indirect
gopkg.in/yaml.v2 v2.4.0
)

427
go.sum Normal file
View File

@@ -0,0 +1,427 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/GeertJohan/go.rice v1.0.2/go.mod h1:af5vUNlDNkCjOZeSGFgIJxDje9qdjsO6hshx0gTmZt4=
github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/goquery v1.6.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/andyleap/microformats v0.0.0-20150523144534-25ae286f528b/go.mod h1:I3yyaN+QdpdChOtQg3ApgY01JRmFsXJASweq6Ye5A3s=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/audiolion/ipip v1.0.0/go.mod h1:dQtLacQAC8IPK3CTQAwhm2B+I0403irUHgit3r2IylM=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bakape/thumbnailer/v2 v2.6.6/go.mod h1:+yOYrfZmQ3VO7uqVHxTr3p5J74WRjP5MeQQXaU6GBjY=
github.com/bbalet/stopwords v1.0.0/go.mod h1:sAWrQoDMfqARGIn4s6dp7OW7ISrshUD8IP2q3KoqPjc=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/chai2010/webp v1.1.0/go.mod h1:LP12PG5IFmLGHUU26tBiCBKnghxx3toZFwDjOYvd3Ow=
github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creasty/defaults v1.5.1/go.mod h1:FPZ+Y0WNrbqOVw+c6av63eyHUAl6pMHZwqLPvXUZGfY=
github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4=
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
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/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabstv/merger v1.0.1/go.mod h1:oQKCbAX4P6q0jk4s9Is144NojOE/HggFPb5qjPNZjq8=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/goccy/go-yaml v1.8.6/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA=
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gomarkdown/markdown v0.0.0-20201113031856-722100d81a8e/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/goware/urlx v0.3.1/go.mod h1:h8uwbJy68o+tQXCGZNa9D73WN8n0r9OBae5bUnLcgjw=
github.com/grokify/html-strip-tags-go v0.0.0-20200322061010-ea0c1cf2f119/go.mod h1:2Su6romC5/1VXOQMaWL2yb618ARB8iVo6/DR99A6d78=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/h2non/filetype v1.1.1/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/james4k/fmatter v0.0.0-20150827042251-377c8ea6259d/go.mod h1:lxdGBh4Mr76rBen37GEal03CF0eF1qF5DSk2qfrrdo0=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
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/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lithammer/shortuuid/v3 v3.0.5/go.mod h1:2QdoCtD4SBzugx2qs3gdR3LXY6McxZYCNEHwDmYvOAE=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc=
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022/go.mod h1:x4NsS+uc7ecH/Cbm9xKQ6XzmJM57rWTkjywjfB2yQ18=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/plar/go-adaptive-radix-tree v1.0.4/go.mod h1:Ot8d28EII3i7Lv4PSvBlF8ejiD/CtRYDuPsySJbSaK8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/prologic/bitcask v0.3.10/go.mod h1:8RKJdbHLE7HFGLYSGu9slnYXSV7DMIucwVkaIYOk9GY=
github.com/prologic/observe v0.0.0-20181231082615-747b185a0928/go.mod h1:tEdBKdkpsOZCgueJIZwZREodFg5oRhLkTWWNiQ5y84E=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM=
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/rickb777/accept v0.0.0-20170318132422-d5183c44530d/go.mod h1:sv64uV+hMk2K4qwURvESkYmF8QyMYF/9nJpxF8UPQb8=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/securisec/go-keywords v0.0.0-20200619134240-769e7273f2ed/go.mod h1:ewJJMApUajQGvQOaQb/QyzTLoL619B5D02XOZlGnlNo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/steambap/captcha v1.3.1/go.mod h1:OPtfgZrvs/bolQ9JIZXsejxNzc98ETqaiMCVg8kqQGM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/theplant-retired/timezones v0.0.0-20150304063004-f9bd3c0ef9db/go.mod h1:vXWFQa6TAgTMbDqs8os5Wy6sTryvHNC39l0M3WHl8EQ=
github.com/tidwall/btree v0.2.2/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
github.com/tidwall/redcon v1.4.0/go.mod h1:IGzxyoKE3Ea5AWIXo/ZHP+hzY8sWXaMKr7KlFgcWSZU=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/unrolled/logger v0.0.0-20201216141554-31a3694fe979/go.mod h1:X5DBNY1yIVkuLwJP3BXlCoQCa5mGg7hPJPIA0Blwc44=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/vcraescu/go-paginator v1.0.0/go.mod h1:caZCjjt2qcA1O2aDzW7lwAcK4Rxw3LNvdEVF/ONxZWw=
github.com/wblakecaldwell/profiler v0.0.0-20150908040756-6111ef1313a1/go.mod h1:3+0F8oLB1rQlbIcRAuqDgGdzNi9X69un/aPz4cUAFV4=
github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191117063200-497ca9f6d64f/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20200228211341-fcea875c7e85/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191119073136-fc4aabc6c914/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191119060738-e882bf8e40c2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191118222007-07fc4c7f2b98/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.53.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.6/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=

270
internal/api.go Normal file
View File

@@ -0,0 +1,270 @@
package internal
import (
"context"
"errors"
"fmt"
"net/http"
"time"
jwt "github.com/dgrijalva/jwt-go"
"github.com/julienschmidt/httprouter"
log "github.com/sirupsen/logrus"
"git.mills.io/prologic/spyda/internal/passwords"
"git.mills.io/prologic/spyda/types"
)
// ContextKey ...
type ContextKey int
const (
TokenContextKey ContextKey = iota
UserContextKey
)
var (
// ErrInvalidCredentials is returned for invalid credentials against /auth
ErrInvalidCredentials = errors.New("error: invalid credentials")
// ErrInvalidToken is returned for expired or invalid tokens used in Authorizeation headers
ErrInvalidToken = errors.New("error: invalid token")
)
// API ...
type API struct {
router *Router
config *Config
cache *Cache
archive Archiver
db Store
pm passwords.Passwords
tasks *Dispatcher
}
// NewAPI ...
func NewAPI(router *Router, config *Config, cache *Cache, archive Archiver, db Store, pm passwords.Passwords, tasks *Dispatcher) *API {
api := &API{router, config, cache, archive, db, pm, tasks}
api.initRoutes()
return api
}
func (a *API) initRoutes() {
router := a.router.Group("/api/v1")
router.GET("/ping", a.PingEndpoint())
router.POST("/auth", a.AuthEndpoint())
// Support / Report endpoints
router.POST("/support", a.isAuthorized(a.SupportEndpoint()))
router.POST("/report", a.isAuthorized(a.ReportEndpoint()))
}
// CreateToken ...
func (a *API) CreateToken(user *User, r *http.Request) (*Token, error) {
claims := jwt.MapClaims{}
claims["username"] = user.Username
createdAt := time.Now()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(a.config.APISigningKey))
if err != nil {
log.WithError(err).Error("error creating signed token")
return nil, err
}
signedToken, err := jwt.Parse(tokenString, a.jwtKeyFunc)
if err != nil {
log.WithError(err).Error("error creating signed token")
return nil, err
}
tkn := &Token{
Signature: signedToken.Signature,
Value: tokenString,
UserAgent: r.UserAgent(),
CreatedAt: createdAt,
}
return tkn, nil
}
func (a *API) jwtKeyFunc(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("There was an error")
}
return []byte(a.config.APISigningKey), nil
}
func (a *API) getLoggedInUser(r *http.Request) *User {
token, err := jwt.Parse(r.Header.Get("Token"), a.jwtKeyFunc)
if err != nil {
return nil
}
if !token.Valid {
return nil
}
claims := token.Claims.(jwt.MapClaims)
username := claims["username"].(string)
user, err := a.db.GetUser(username)
if err != nil {
log.WithError(err).Error("error loading user object")
return nil
}
// Every registered new user follows themselves
// TODO: Make this configurable server behaviour?
if user.Following == nil {
user.Following = make(map[string]string)
}
user.Following[user.Username] = user.URL
return user
}
func (a *API) isAuthorized(endpoint httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
if r.Header.Get("Token") == "" {
http.Error(w, "No Token Provided", http.StatusUnauthorized)
return
}
token, err := jwt.Parse(r.Header.Get("Token"), a.jwtKeyFunc)
if err != nil {
log.WithError(err).Error("error parsing token")
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if token.Valid {
claims := token.Claims.(jwt.MapClaims)
username := claims["username"].(string)
user, err := a.db.GetUser(username)
if err != nil {
log.WithError(err).Error("error loading user object")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Every registered new user follows themselves
// TODO: Make this configurable server behaviour?
if user.Following == nil {
user.Following = make(map[string]string)
}
user.Following[user.Username] = user.URL
ctx := context.WithValue(r.Context(), TokenContextKey, token)
ctx = context.WithValue(ctx, UserContextKey, user)
endpoint(w, r.WithContext(ctx), p)
} else {
http.Error(w, "Invalid Token", http.StatusUnauthorized)
return
}
}
}
// PingEndpoint ...
func (a *API) PingEndpoint() httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
}
}
// AuthEndpoint ...
func (a *API) AuthEndpoint() httprouter.Handle {
// #239: Throttle failed login attempts and lock user account.
failures := NewTTLCache(5 * time.Minute)
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
req, err := types.NewAuthRequest(r.Body)
if err != nil {
log.WithError(err).Error("error parsing auth request")
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
username := NormalizeUsername(req.Username)
password := req.Password
// Error: no username or password provided
if username == "" || password == "" {
log.Warn("no username or password provided")
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// Lookup user
user, err := a.db.GetUser(username)
if err != nil {
log.WithField("username", username).Warn("login attempt from non-existent user")
http.Error(w, "Invalid Credentials", http.StatusUnauthorized)
return
}
// #239: Throttle failed login attempts and lock user account.
if failures.Get(user.Username) > MaxFailedLogins {
http.Error(w, "Account Locked", http.StatusTooManyRequests)
return
}
// Validate cleartext password against KDF hash
err = a.pm.CheckPassword(user.Password, password)
if err != nil {
// #239: Throttle failed login attempts and lock user account.
failed := failures.Inc(user.Username)
time.Sleep(time.Duration(IntPow(2, failed)) * time.Second)
log.WithField("username", username).Warn("login attempt with invalid credentials")
http.Error(w, "Invalid Credentials", http.StatusUnauthorized)
return
}
// #239: Throttle failed login attempts and lock user account.
failures.Reset(user.Username)
// Login successful
log.WithField("username", username).Info("login successful")
token, err := a.CreateToken(user, r)
if err != nil {
log.WithError(err).Error("error creating token")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
user.AddToken(token)
if err := a.db.SetToken(token.Signature, token); err != nil {
log.WithError(err).Error("error saving token object")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if err := a.db.SetUser(user.Username, user); err != nil {
log.WithError(err).Error("error saving user object")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
res := types.AuthResponse{Token: token.Value}
body, err := res.Bytes()
if err != nil {
log.WithError(err).Error("error serializing response")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}
}

70
internal/auth/manager.go Normal file
View File

@@ -0,0 +1,70 @@
package auth
import (
"net/http"
"github.com/julienschmidt/httprouter"
"git.mills.io/prologic/spyda/internal/session"
)
// Options ...
type Options struct {
login string
register string
}
// NewOptions ...
func NewOptions(login, register string) *Options {
return &Options{login, register}
}
// Manager ...
type Manager struct {
options *Options
}
// NewManager ...
func NewManager(options *Options) *Manager {
return &Manager{options}
}
// MustAuth ...
func (m *Manager) MustAuth(next httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
if sess := r.Context().Value(session.SessionKey); sess != nil {
if _, ok := sess.(*session.Session).Get("username"); ok {
next(w, r, p)
return
}
}
http.Redirect(w, r, m.options.login, http.StatusFound)
}
}
// ShouldAuth ...
func (m *Manager) ShouldAuth(next httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
if sess := r.Context().Value(session.SessionKey); sess != nil {
if _, ok := sess.(*session.Session).Get("username"); ok {
next(w, r, p)
return
}
}
http.Redirect(w, r, m.options.login, http.StatusFound)
}
}
func (m *Manager) HasAuth(next httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
if sess := r.Context().Value(session.SessionKey); sess != nil {
if _, ok := sess.(*session.Session).Get("username"); ok {
http.Redirect(w, r, "/", http.StatusFound)
return
}
}
next(w, r, p)
}
}

376
internal/bitcask_store.go Normal file
View File

@@ -0,0 +1,376 @@
package internal
import (
"fmt"
"strings"
"github.com/prologic/bitcask"
log "github.com/sirupsen/logrus"
"git.mills.io/prologic/spyda/internal/session"
)
const (
feedsKeyPrefix = "/feeds"
sessionsKeyPrefix = "/sessions"
usersKeyPrefix = "/users"
tokensKeyPrefix = "/tokens"
)
// BitcaskStore ...
type BitcaskStore struct {
db *bitcask.Bitcask
}
func newBitcaskStore(path string) (*BitcaskStore, error) {
db, err := bitcask.Open(
path,
bitcask.WithMaxKeySize(256),
)
if err != nil {
return nil, err
}
return &BitcaskStore{db: db}, nil
}
// Sync ...
func (bs *BitcaskStore) Sync() error {
return bs.db.Sync()
}
// Close ...
func (bs *BitcaskStore) Close() error {
log.Info("syncing store ...")
if err := bs.db.Sync(); err != nil {
log.WithError(err).Error("error syncing store")
return err
}
log.Info("closing store ...")
if err := bs.db.Close(); err != nil {
log.WithError(err).Error("error closing store")
return err
}
return nil
}
// Merge ...
func (bs *BitcaskStore) Merge() error {
log.Info("merging store ...")
if err := bs.db.Merge(); err != nil {
log.WithError(err).Error("error merging store")
return err
}
return nil
}
func (bs *BitcaskStore) HasFeed(name string) bool {
key := []byte(fmt.Sprintf("%s/%s", feedsKeyPrefix, name))
return bs.db.Has(key)
}
func (bs *BitcaskStore) DelFeed(name string) error {
key := []byte(fmt.Sprintf("%s/%s", feedsKeyPrefix, name))
return bs.db.Delete(key)
}
func (bs *BitcaskStore) GetFeed(name string) (*Feed, error) {
key := []byte(fmt.Sprintf("%s/%s", feedsKeyPrefix, name))
data, err := bs.db.Get(key)
if err == bitcask.ErrKeyNotFound {
return nil, ErrFeedNotFound
}
return LoadFeed(data)
}
func (bs *BitcaskStore) SetFeed(name string, feed *Feed) error {
data, err := feed.Bytes()
if err != nil {
return err
}
key := []byte(fmt.Sprintf("%s/%s", feedsKeyPrefix, name))
if err := bs.db.Put(key, data); err != nil {
return err
}
return nil
}
func (bs *BitcaskStore) LenFeeds() int64 {
var count int64
if err := bs.db.Scan([]byte(feedsKeyPrefix), func(_ []byte) error {
count++
return nil
}); err != nil {
log.WithError(err).Error("error scanning")
}
return count
}
func (bs *BitcaskStore) SearchFeeds(prefix string) []string {
var keys []string
if err := bs.db.Scan([]byte(feedsKeyPrefix), func(key []byte) error {
if strings.HasPrefix(strings.ToLower(string(key)), prefix) {
keys = append(keys, strings.TrimPrefix(string(key), "/feeds/"))
}
return nil
}); err != nil {
log.WithError(err).Error("error scanning")
}
return keys
}
func (bs *BitcaskStore) GetAllFeeds() ([]*Feed, error) {
var feeds []*Feed
err := bs.db.Scan([]byte(feedsKeyPrefix), func(key []byte) error {
data, err := bs.db.Get(key)
if err != nil {
return err
}
feed, err := LoadFeed(data)
if err != nil {
return err
}
feeds = append(feeds, feed)
return nil
})
if err != nil {
return nil, err
}
return feeds, nil
}
func (bs *BitcaskStore) HasUser(username string) bool {
key := []byte(fmt.Sprintf("%s/%s", usersKeyPrefix, username))
return bs.db.Has(key)
}
func (bs *BitcaskStore) DelUser(username string) error {
key := []byte(fmt.Sprintf("%s/%s", usersKeyPrefix, username))
return bs.db.Delete(key)
}
func (bs *BitcaskStore) GetUser(username string) (*User, error) {
key := []byte(fmt.Sprintf("%s/%s", usersKeyPrefix, username))
data, err := bs.db.Get(key)
if err == bitcask.ErrKeyNotFound {
return nil, ErrUserNotFound
}
return LoadUser(data)
}
func (bs *BitcaskStore) SetUser(username string, user *User) error {
data, err := user.Bytes()
if err != nil {
return err
}
key := []byte(fmt.Sprintf("%s/%s", usersKeyPrefix, username))
if err := bs.db.Put(key, data); err != nil {
return err
}
return nil
}
func (bs *BitcaskStore) LenUsers() int64 {
var count int64
if err := bs.db.Scan([]byte(usersKeyPrefix), func(_ []byte) error {
count++
return nil
}); err != nil {
log.WithError(err).Error("error scanning")
}
return count
}
func (bs *BitcaskStore) SearchUsers(prefix string) []string {
var keys []string
if err := bs.db.Scan([]byte(usersKeyPrefix), func(key []byte) error {
if strings.HasPrefix(strings.ToLower(string(key)), prefix) {
keys = append(keys, strings.TrimPrefix(string(key), "/users/"))
}
return nil
}); err != nil {
log.WithError(err).Error("error scanning")
}
return keys
}
func (bs *BitcaskStore) GetAllUsers() ([]*User, error) {
var users []*User
err := bs.db.Scan([]byte(usersKeyPrefix), func(key []byte) error {
data, err := bs.db.Get(key)
if err != nil {
return err
}
user, err := LoadUser(data)
if err != nil {
return err
}
users = append(users, user)
return nil
})
if err != nil {
return nil, err
}
return users, nil
}
func (bs *BitcaskStore) GetSession(sid string) (*session.Session, error) {
key := []byte(fmt.Sprintf("%s/%s", sessionsKeyPrefix, sid))
data, err := bs.db.Get(key)
if err != nil {
if err == bitcask.ErrKeyNotFound {
return nil, session.ErrSessionNotFound
}
return nil, err
}
sess := session.NewSession(bs)
if err := session.LoadSession(data, sess); err != nil {
return nil, err
}
return sess, nil
}
func (bs *BitcaskStore) SetSession(sid string, sess *session.Session) error {
key := []byte(fmt.Sprintf("%s/%s", sessionsKeyPrefix, sid))
data, err := sess.Bytes()
if err != nil {
return err
}
return bs.db.Put(key, data)
}
func (bs *BitcaskStore) HasSession(sid string) bool {
key := []byte(fmt.Sprintf("%s/%s", sessionsKeyPrefix, sid))
return bs.db.Has(key)
}
func (bs *BitcaskStore) DelSession(sid string) error {
key := []byte(fmt.Sprintf("%s/%s", sessionsKeyPrefix, sid))
return bs.db.Delete(key)
}
func (bs *BitcaskStore) SyncSession(sess *session.Session) error {
// Only persist sessions with a logged in user associated with an account
// This saves resources as we don't need to keep session keys around for
// sessions we may never load from the store again.
if sess.Has("username") {
return bs.SetSession(sess.ID, sess)
}
return nil
}
func (bs *BitcaskStore) LenSessions() int64 {
var count int64
if err := bs.db.Scan([]byte(sessionsKeyPrefix), func(_ []byte) error {
count++
return nil
}); err != nil {
log.WithError(err).Error("error scanning")
}
return count
}
func (bs *BitcaskStore) GetAllSessions() ([]*session.Session, error) {
var sessions []*session.Session
err := bs.db.Scan([]byte(sessionsKeyPrefix), func(key []byte) error {
data, err := bs.db.Get(key)
if err != nil {
return err
}
sess := session.NewSession(bs)
if err := session.LoadSession(data, sess); err != nil {
return err
}
sessions = append(sessions, sess)
return nil
})
if err != nil {
return nil, err
}
return sessions, nil
}
func (bs *BitcaskStore) GetUserTokens(user *User) ([]*Token, error) {
tokens := []*Token{}
for _, signature := range user.Tokens {
tkn, err := bs.GetToken(signature)
if err != nil {
return tokens, err
}
tokens = append(tokens, tkn)
}
return tokens, nil
}
func (bs *BitcaskStore) GetToken(signature string) (*Token, error) {
key := []byte(fmt.Sprintf("%s/%s", tokensKeyPrefix, signature))
data, err := bs.db.Get(key)
if err == bitcask.ErrKeyNotFound {
return nil, ErrTokenNotFound
}
tkn, err := LoadToken(data)
if err != nil {
return nil, err
}
return tkn, nil
}
func (bs *BitcaskStore) SetToken(signature string, tkn *Token) error {
data, err := tkn.Bytes()
if err != nil {
return err
}
key := []byte(fmt.Sprintf("%s/%s", tokensKeyPrefix, signature))
if err := bs.db.Put(key, data); err != nil {
return err
}
return nil
}
func (bs *BitcaskStore) DelToken(signature string) error {
key := []byte(fmt.Sprintf("%s/%s", tokensKeyPrefix, signature))
return bs.db.Delete(key)
}
func (bs *BitcaskStore) LenTokens() int64 {
var count int64
if err := bs.db.Scan([]byte(tokensKeyPrefix), func(_ []byte) error {
count++
return nil
}); err != nil {
log.WithError(err).Error("error scanning")
}
return count
}

195
internal/config.go Normal file
View File

@@ -0,0 +1,195 @@
package internal
import (
"errors"
"fmt"
"io/ioutil"
"math/rand"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/gabstv/merger"
"github.com/goccy/go-yaml"
"git.mills.io/prologic/spyda/types"
log "github.com/sirupsen/logrus"
)
var (
ErrConfigPathMissing = errors.New("error: config file missing")
)
// Settings contains Pod Settings that can be customised via the Web UI
type Settings struct {
Name string `yaml:"pod_name"`
Description string `yaml:"pod_description"`
MaxTwtLength int `yaml:"max_twt_length"`
OpenProfiles bool `yaml:"open_profiles"`
OpenRegistrations bool `yaml:"open_registrations"`
}
// Config contains the server configuration parameters
type Config struct {
Debug bool
Data string
Name string
Description string
Store string
Theme string
BaseURL string
AdminUser string
AdminName string
AdminEmail string
FeedSources []string
RegisterMessage string
CookieSecret string
TwtPrompts []string
TwtsPerPage int
MaxUploadSize int64
MaxTwtLength int
MaxCacheTTL time.Duration
MaxCacheItems int
MsgsPerPage int
OpenProfiles bool
OpenRegistrations bool
SessionExpiry time.Duration
SessionCacheTTL time.Duration
TranscoderTimeout time.Duration
MagicLinkSecret string
SMTPBind string
POP3Bind string
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPass string
SMTPFrom string
MaxFetchLimit int64
APISessionTime time.Duration
APISigningKey string
baseURL *url.URL
whitelistedDomains []*regexp.Regexp
WhitelistedDomains []string
// path string
}
var _ types.FmtOpts = (*Config)(nil)
func (c *Config) IsLocalURL(url string) bool {
if NormalizeURL(url) == "" {
return false
}
return strings.HasPrefix(NormalizeURL(url), NormalizeURL(c.BaseURL))
}
func (c *Config) LocalURL() *url.URL { return c.baseURL }
func (c *Config) ExternalURL(nick, uri string) string { return URLForExternalProfile(c, nick, uri) }
func (c *Config) UserURL(url string) string { return UserURL(url) }
// Settings returns a `Settings` struct containing pod settings that can
// then be persisted to disk to override some configuration options.
func (c *Config) Settings() *Settings {
settings := &Settings{}
if err := merger.MergeOverwrite(settings, c); err != nil {
log.WithError(err).Warn("error creating pod settings")
}
return settings
}
// WhitelistedDomain returns true if the domain provided is a whiltelisted
// domain as per the configuration
func (c *Config) WhitelistedDomain(domain string) (bool, bool) {
// Always per mit our own domain
ourDomain := strings.TrimPrefix(strings.ToLower(c.baseURL.Hostname()), "www.")
if domain == ourDomain {
return true, true
}
// Check against list of whitelistedDomains (regexes)
for _, re := range c.whitelistedDomains {
if re.MatchString(domain) {
return true, false
}
}
return false, false
}
// RandomTwtPrompt returns a random Twt Prompt for display by the UI
func (c *Config) RandomTwtPrompt() string {
n := rand.Int() % len(c.TwtPrompts)
return c.TwtPrompts[n]
}
// Validate validates the configuration is valid which for the most part
// just ensures that default secrets are actually configured correctly
func (c *Config) Validate() error {
if c.Debug {
return nil
}
if c.CookieSecret == InvalidConfigValue {
return fmt.Errorf("error: COOKIE_SECRET is not configured!")
}
if c.MagicLinkSecret == InvalidConfigValue {
return fmt.Errorf("error: MAGICLINK_SECRET is not configured!")
}
if c.APISigningKey == InvalidConfigValue {
return fmt.Errorf("error: API_SIGNING_KEY is not configured!")
}
return nil
}
// LoadSettings loads pod settings from the given path
func LoadSettings(path string) (*Settings, error) {
var settings Settings
data, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(data, &settings); err != nil {
return nil, err
}
return &settings, nil
}
// Save saves the pod settings to the given path
func (s *Settings) Save(path string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
data, err := yaml.MarshalWithOptions(s, yaml.Indent(4))
if err != nil {
return err
}
if _, err = f.Write(data); err != nil {
return err
}
if err = f.Sync(); err != nil {
return err
}
return f.Close()
}

175
internal/context.go Normal file
View File

@@ -0,0 +1,175 @@
package internal
import (
"fmt"
"html/template"
"net/http"
"strings"
log "github.com/sirupsen/logrus"
"github.com/vcraescu/go-paginator"
"git.mills.io/prologic/spyda"
"git.mills.io/prologic/spyda/internal/session"
"git.mills.io/prologic/spyda/types"
"github.com/justinas/nosurf"
"github.com/theplant-retired/timezones"
)
type Meta struct {
Title string
Description string
UpdatedAt string
Image string
Author string
URL string
Keywords string
}
type Context struct {
Config string
Debug bool
BaseURL string
InstanceName string
SoftwareVersion string
TwtsPerPage int
TwtPrompt string
MaxTwtLength int
RegisterDisabled bool
OpenProfiles bool
RegisterDisabledMessage string
Timezones []*timezones.Zoneinfo
Reply string
Username string
User *User
Tokens []*Token
LastTwt types.Twt
Profile types.Profile
Authenticated bool
IsAdmin bool
Error bool
Message string
Theme string
Commit string
Page string
Content template.HTML
Title string
Meta Meta
Links types.Links
Alternatives types.Alternatives
Messages Messages
NewMessages int
Twter types.Twter
Twts types.Twts
BlogPost *BlogPost
BlogPosts BlogPosts
Feeds []*Feed
FeedSources FeedSourceMap
Pager *paginator.Paginator
// Report abuse
ReportNick string
ReportURL string
// Reset Password Token
PasswordResetToken string
// CSRF Token
CSRFToken string
}
func NewContext(conf *Config, db Store, req *http.Request) *Context {
ctx := &Context{
Debug: conf.Debug,
BaseURL: conf.BaseURL,
InstanceName: conf.Name,
SoftwareVersion: spyda.FullVersion(),
TwtsPerPage: conf.TwtsPerPage,
TwtPrompt: conf.RandomTwtPrompt(),
MaxTwtLength: conf.MaxTwtLength,
RegisterDisabled: !conf.OpenRegistrations,
OpenProfiles: conf.OpenProfiles,
LastTwt: types.NilTwt,
Commit: spyda.Commit,
Theme: conf.Theme,
Timezones: timezones.AllZones,
Title: "",
Meta: Meta{
Title: DefaultMetaTitle,
Author: DefaultMetaAuthor,
Keywords: DefaultMetaKeywords,
Description: conf.Description,
},
Alternatives: types.Alternatives{
types.Alternative{
Type: "application/atom+xml",
Title: fmt.Sprintf("%s local feed", conf.Name),
URL: fmt.Sprintf("%s/atom.xml", conf.BaseURL),
},
},
}
ctx.CSRFToken = nosurf.Token(req)
if sess := req.Context().Value(session.SessionKey); sess != nil {
if username, ok := sess.(*session.Session).Get("username"); ok {
ctx.Authenticated = true
ctx.Username = username
}
}
if ctx.Authenticated && ctx.Username != "" {
user, err := db.GetUser(ctx.Username)
if err != nil {
log.WithError(err).Warnf("error loading user object for %s", ctx.Username)
}
ctx.Twter = types.Twter{
Nick: user.Username,
URL: URLForUser(conf, user.Username),
}
ctx.User = user
tokens, err := db.GetUserTokens(user)
if err != nil {
log.WithError(err).Warnf("error loading tokens for %s", ctx.Username)
}
ctx.Tokens = tokens
} else {
ctx.User = &User{}
ctx.Twter = types.Twter{}
}
if ctx.Username == conf.AdminUser {
ctx.IsAdmin = true
}
// Set the theme based on user preferences
theme := strings.ToLower(ctx.User.Theme)
switch theme {
case "", "auto":
ctx.Theme = ""
case "light", "dark":
ctx.Theme = theme
default:
log.WithField("name", theme).Warn("invalid theme found")
}
return ctx
}

94
internal/errors.go Normal file
View File

@@ -0,0 +1,94 @@
package internal
import (
"fmt"
"syscall"
)
type ErrCommandKilled struct {
Err error
Signal syscall.Signal
}
func (e *ErrCommandKilled) Is(target error) bool {
if _, ok := target.(*ErrCommandKilled); ok {
return true
}
return false
}
func (e *ErrCommandKilled) Error() string {
return fmt.Sprintf("error: command killed: %s", e.Err)
}
func (e *ErrCommandKilled) Unwrap() error {
return e.Err
}
type ErrCommandFailed struct {
Err error
Status int
}
func (e *ErrCommandFailed) Is(target error) bool {
if _, ok := target.(*ErrCommandFailed); ok {
return true
}
return false
}
func (e *ErrCommandFailed) Error() string {
return fmt.Sprintf("error: command failed: %s", e.Err)
}
func (e *ErrCommandFailed) Unwrap() error {
return e.Err
}
type ErrTranscodeTimeout struct {
Err error
}
func (e *ErrTranscodeTimeout) Error() string {
return fmt.Sprintf("error: transcode timed out: %s", e.Err)
}
func (e *ErrTranscodeTimeout) Unwrap() error {
return e.Err
}
type ErrTranscodeFailed struct {
Err error
}
func (e *ErrTranscodeFailed) Error() string {
return fmt.Sprintf("error: transcode failed: %s", e.Err)
}
func (e *ErrTranscodeFailed) Unwrap() error {
return e.Err
}
type ErrAudioUploadFailed struct {
Err error
}
func (e *ErrAudioUploadFailed) Error() string {
return fmt.Sprintf("error: audio upload failed: %s", e.Err)
}
func (e *ErrAudioUploadFailed) Unwrap() error {
return e.Err
}
type ErrVideoUploadFailed struct {
Err error
}
func (e *ErrVideoUploadFailed) Error() string {
return fmt.Sprintf("error: video upload failed: %s", e.Err)
}
func (e *ErrVideoUploadFailed) Unwrap() error {
return e.Err
}

100
internal/handlers.go Normal file
View File

@@ -0,0 +1,100 @@
package internal
import (
"errors"
"fmt"
"html/template"
"net/http"
"strings"
rice "github.com/GeertJohan/go.rice"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"github.com/james4k/fmatter"
"github.com/julienschmidt/httprouter"
log "github.com/sirupsen/logrus"
)
const (
MediaResolution = 720 // 720x576
AvatarResolution = 360 // 360x360
AsyncTaskLimit = 5
MaxFailedLogins = 3 // By default 3 failed login attempts per 5 minutes
)
var (
ErrFeedImposter = errors.New("error: imposter detected, you do not own this feed")
)
func (s *Server) NotFoundHandler(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") == "application/json" {
w.Header().Set("Content-Type", "application/json")
http.Error(w, "Endpoint Not Found", http.StatusNotFound)
return
}
ctx := NewContext(s.config, s.db, r)
ctx.Title = "Page Not Found"
w.WriteHeader(http.StatusNotFound)
s.render("404", w, ctx)
}
type FrontMatter struct {
Title string
}
// PageHandler ...
func (s *Server) PageHandler(name string) httprouter.Handle {
box := rice.MustFindBox("pages")
mdTpl := box.MustString(fmt.Sprintf("%s.md", name))
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
ctx := NewContext(s.config, s.db, r)
md, err := RenderString(mdTpl, ctx)
if err != nil {
log.WithError(err).Errorf("error rendering page %s", name)
ctx.Error = true
ctx.Message = "Error loading help page! Please contact support."
s.render("error", w, ctx)
return
}
var frontmatter FrontMatter
content, err := fmatter.Read([]byte(md), &frontmatter)
if err != nil {
log.WithError(err).Error("error parsing front matter")
ctx.Error = true
ctx.Message = "Error loading page! Please contact support."
s.render("error", w, ctx)
return
}
extensions := parser.CommonExtensions | parser.AutoHeadingIDs
p := parser.NewWithExtensions(extensions)
htmlFlags := html.CommonFlags
opts := html.RendererOptions{
Flags: htmlFlags,
Generator: "",
}
renderer := html.NewRenderer(opts)
html := markdown.ToHTML(content, p, renderer)
var title string
if frontmatter.Title != "" {
title = frontmatter.Title
} else {
title = strings.Title(name)
}
ctx.Title = title
ctx.Page = name
ctx.Content = template.HTML(html)
s.render("page", w, ctx)
}
}

15
internal/init.go Normal file
View File

@@ -0,0 +1,15 @@
package internal
import (
"net/http"
"time"
)
func init() {
/*
Safety net for 'too many open files' issue on legacy code.
Set a sane timeout duration for the http.DefaultClient, to ensure idle connections are terminated.
Reference: https://stackoverflow.com/questions/37454236/net-http-server-too-many-open-files-error
*/
http.DefaultClient.Timeout = time.Minute * 5 // Default TCP timeout
}

50
internal/jobs.go Normal file
View File

@@ -0,0 +1,50 @@
package internal
import (
"github.com/robfig/cron"
log "github.com/sirupsen/logrus"
)
// JobSpec ...
type JobSpec struct {
Schedule string
Factory JobFactory
}
func NewJobSpec(schedule string, factory JobFactory) JobSpec {
return JobSpec{schedule, factory}
}
var (
Jobs map[string]JobSpec
StartupJobs map[string]JobSpec
)
func init() {
Jobs = map[string]JobSpec{
"SyncStore": NewJobSpec("@every 1m", NewSyncStoreJob),
}
StartupJobs = map[string]JobSpec{}
}
type JobFactory func(conf *Config, blogs *BlogsCache, cache *Cache, archive Archiver, store Store) cron.Job
type SyncStoreJob struct {
conf *Config
blogs *BlogsCache
cache *Cache
archive Archiver
db Store
}
func NewSyncStoreJob(conf *Config, blogs *BlogsCache, cache *Cache, archive Archiver, db Store) cron.Job {
return &SyncStoreJob{conf: conf, blogs: blogs, cache: cache, archive: archive, db: db}
}
func (job *SyncStoreJob) Run() {
if err := job.db.Sync(); err != nil {
log.WithError(err).Warn("error sycning store")
}
log.Info("synced store")
}

554
internal/models.go Normal file
View File

@@ -0,0 +1,554 @@
package internal
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/creasty/defaults"
"git.mills.io/prologic/spyda/types"
log "github.com/sirupsen/logrus"
)
const (
maxUserFeeds = 5 // 5 is < 7 and humans can only really handle ~7 things
)
var (
ErrFeedAlreadyExists = errors.New("error: feed already exists by that name")
ErrAlreadyFollows = errors.New("error: you already follow this feed")
ErrTooManyFeeds = errors.New("error: you have too many feeds")
)
// Feed ...
type Feed struct {
Name string
Description string
URL string
CreatedAt time.Time
Followers map[string]string `default:"{}"`
remotes map[string]string
}
// User ...
type User struct {
Username string
Password string
Tagline string
Email string // DEPRECATED: In favor of storing a Hashed Email
URL string
CreatedAt time.Time
Theme string `default:"auto"`
Recovery string `default:"auto"`
DisplayDatesInTimezone string `default:"UTC"`
IsFollowersPubliclyVisible bool `default:"true"`
IsFollowingPubliclyVisible bool `default:"true"`
IsBookmarksPubliclyVisible bool `default:"true"`
Feeds []string `default:"[]"`
Tokens []string `default:"[]"`
SMTPToken string `default:""`
POP3Token string `default:""`
Bookmarks map[string]string `default:"{}"`
Followers map[string]string `default:"{}"`
Following map[string]string `default:"{}"`
Muted map[string]string `default:"{}"`
muted map[string]string
remotes map[string]string
sources map[string]string
}
// Token ...
type Token struct {
Signature string
Value string
UserAgent string
CreatedAt time.Time
ExpiresAt time.Time
}
func LoadToken(data []byte) (token *Token, err error) {
token = &Token{}
if err := defaults.Set(token); err != nil {
return nil, err
}
if err = json.Unmarshal(data, &token); err != nil {
return nil, err
}
return
}
func (t *Token) Bytes() ([]byte, error) {
data, err := json.Marshal(t)
if err != nil {
return nil, err
}
return data, nil
}
func CreateFeed(conf *Config, db Store, user *User, name string, force bool) error {
if user != nil {
if !force && len(user.Feeds) > maxUserFeeds {
return ErrTooManyFeeds
}
}
fn := filepath.Join(conf.Data, feedsDir, name)
stat, err := os.Stat(fn)
if err == nil && !force {
return ErrFeedAlreadyExists
}
if stat == nil {
if err := ioutil.WriteFile(fn, []byte{}, 0644); err != nil {
return err
}
}
if user != nil {
if !user.OwnsFeed(name) {
user.Feeds = append(user.Feeds, name)
}
}
followers := make(map[string]string)
if user != nil {
followers[user.Username] = user.URL
}
feed := NewFeed()
feed.Name = name
feed.URL = URLForUser(conf, name)
feed.Followers = followers
feed.CreatedAt = time.Now()
if err := db.SetFeed(name, feed); err != nil {
return err
}
if user != nil {
user.Follow(name, feed.URL)
}
return nil
}
func DetachFeedFromOwner(db Store, user *User, feed *Feed) (err error) {
delete(user.Following, feed.Name)
delete(user.sources, feed.URL)
user.Feeds = RemoveString(user.Feeds, feed.Name)
if err = db.SetUser(user.Username, user); err != nil {
return
}
delete(feed.Followers, user.Username)
if err = db.SetFeed(feed.Name, feed); err != nil {
return
}
return nil
}
func RemoveFeedOwnership(db Store, user *User, feed *Feed) (err error) {
user.Feeds = RemoveString(user.Feeds, feed.Name)
if err = db.SetUser(user.Username, user); err != nil {
return
}
return nil
}
func AddFeedOwnership(db Store, user *User, feed *Feed) (err error) {
user.Feeds = append(user.Feeds, feed.Name)
if err = db.SetUser(user.Username, user); err != nil {
return
}
return nil
}
// NewFeed ...
func NewFeed() *Feed {
feed := &Feed{}
if err := defaults.Set(feed); err != nil {
log.WithError(err).Error("error creating new feed object")
}
return feed
}
// LoadFeed ...
func LoadFeed(data []byte) (feed *Feed, err error) {
feed = &Feed{}
if err := defaults.Set(feed); err != nil {
return nil, err
}
if err = json.Unmarshal(data, &feed); err != nil {
return nil, err
}
if feed.Followers == nil {
feed.Followers = make(map[string]string)
}
feed.remotes = make(map[string]string)
for n, u := range feed.Followers {
if u = NormalizeURL(u); u == "" {
continue
}
feed.remotes[u] = n
}
return
}
// NewUser ...
func NewUser() *User {
user := &User{}
if err := defaults.Set(user); err != nil {
log.WithError(err).Error("error creating new user object")
}
return user
}
func LoadUser(data []byte) (user *User, err error) {
user = &User{}
if err := defaults.Set(user); err != nil {
return nil, err
}
if err = json.Unmarshal(data, &user); err != nil {
return nil, err
}
if user.SMTPToken == "" {
user.SMTPToken = GenerateRandomToken()
}
if user.POP3Token == "" {
user.POP3Token = GenerateRandomToken()
}
if user.Bookmarks == nil {
user.Bookmarks = make(map[string]string)
}
if user.Followers == nil {
user.Followers = make(map[string]string)
}
if user.Following == nil {
user.Following = make(map[string]string)
}
user.muted = make(map[string]string)
for n, u := range user.Muted {
if u = NormalizeURL(u); u == "" {
continue
}
user.muted[u] = n
}
user.remotes = make(map[string]string)
for n, u := range user.Followers {
if u = NormalizeURL(u); u == "" {
continue
}
user.remotes[u] = n
}
user.sources = make(map[string]string)
for n, u := range user.Following {
if u = NormalizeURL(u); u == "" {
continue
}
user.sources[u] = n
}
return
}
func (f *Feed) AddFollower(nick, url string) {
url = NormalizeURL(url)
f.Followers[nick] = url
f.remotes[url] = nick
}
func (f *Feed) FollowedBy(url string) bool {
_, ok := f.remotes[NormalizeURL(url)]
return ok
}
func (f *Feed) Source() types.Feeds {
feeds := make(types.Feeds)
feeds[types.Feed{Nick: f.Name, URL: f.URL}] = true
return feeds
}
func (f *Feed) Profile(baseURL string, viewer *User) types.Profile {
var (
follows bool
followedBy bool
muted bool
)
if viewer != nil {
follows = viewer.Follows(f.URL)
followedBy = viewer.FollowedBy(f.URL)
muted = viewer.HasMuted(f.URL)
}
return types.Profile{
Type: "Feed",
Username: f.Name,
Tagline: f.Description,
URL: f.URL,
BlogsURL: URLForBlogs(baseURL, f.Name),
Follows: follows,
FollowedBy: followedBy,
Muted: muted,
Followers: f.Followers,
}
}
func (f *Feed) Bytes() ([]byte, error) {
data, err := json.Marshal(f)
if err != nil {
return nil, err
}
return data, nil
}
func (u *User) String() string {
url, err := url.Parse(u.URL)
if err != nil {
log.WithError(err).Warn("error parsing user url")
return u.Username
}
return fmt.Sprintf("%s@%s", u.Username, url.Hostname())
}
// HasToken will add a token to a user if it doesn't exist already
func (u *User) AddToken(token *Token) {
if !u.HasToken(token.Signature) {
u.Tokens = append(u.Tokens, token.Signature)
}
}
// HasToken will compare a token value with stored tokens
func (u *User) HasToken(token string) bool {
for _, t := range u.Tokens {
if t == token {
return true
}
}
return false
}
func (u *User) OwnsFeed(name string) bool {
name = NormalizeFeedName(name)
for _, feed := range u.Feeds {
if NormalizeFeedName(feed) == name {
return true
}
}
return false
}
func (u *User) Is(url string) bool {
if NormalizeURL(url) == "" {
return false
}
return u.URL == NormalizeURL(url)
}
func (u *User) Bookmark(hash string) {
if _, ok := u.Bookmarks[hash]; !ok {
u.Bookmarks[hash] = ""
} else {
delete(u.Bookmarks, hash)
}
}
func (u *User) Bookmarked(hash string) bool {
_, ok := u.Bookmarks[hash]
return ok
}
func (u *User) AddFollower(nick, url string) {
url = NormalizeURL(url)
u.Followers[nick] = url
u.remotes[url] = nick
}
func (u *User) FollowedBy(url string) bool {
_, ok := u.remotes[NormalizeURL(url)]
return ok
}
func (u *User) Mute(nick, url string) {
if !u.HasMuted(url) {
u.Muted[nick] = url
u.muted[url] = nick
}
}
func (u *User) Unmute(nick string) {
url, ok := u.Muted[nick]
if ok {
delete(u.Muted, nick)
delete(u.muted, url)
}
}
func (u *User) Follow(nick, url string) {
if !u.Follows(url) {
u.Following[nick] = url
u.sources[url] = nick
}
}
func (u *User) FollowAndValidate(conf *Config, nick, url string) error {
if err := ValidateFeed(conf, nick, url); err != nil {
return err
}
if u.Follows(url) {
return ErrAlreadyFollows
}
u.Following[nick] = url
u.sources[url] = nick
return nil
}
func (u *User) Follows(url string) bool {
_, ok := u.sources[NormalizeURL(url)]
return ok
}
func (u *User) HasMuted(url string) bool {
_, ok := u.muted[NormalizeURL(url)]
return ok
}
func (u *User) Source() types.Feeds {
feeds := make(types.Feeds)
feeds[types.Feed{Nick: u.Username, URL: u.URL}] = true
return feeds
}
func (u *User) Sources() types.Feeds {
// Ensure we fetch the user's own posts in the cache
feeds := u.Source()
for url, nick := range u.sources {
feeds[types.Feed{Nick: nick, URL: url}] = true
}
return feeds
}
func (u *User) Profile(baseURL string, viewer *User) types.Profile {
var (
follows bool
followedBy bool
muted bool
)
if viewer != nil {
if viewer.Is(u.URL) {
follows = true
followedBy = true
} else {
follows = viewer.Follows(u.URL)
followedBy = viewer.FollowedBy(u.URL)
}
muted = viewer.HasMuted(u.URL)
}
return types.Profile{
Type: "User",
Username: u.Username,
Tagline: u.Tagline,
URL: u.URL,
BlogsURL: URLForBlogs(baseURL, u.Username),
Follows: follows,
FollowedBy: followedBy,
Muted: muted,
Followers: u.Followers,
Following: u.Following,
Bookmarks: u.Bookmarks,
}
}
func (u *User) Twter() types.Twter {
return types.Twter{Nick: u.Username, URL: u.URL}
}
func (u *User) Filter(twts []types.Twt) (filtered []types.Twt) {
// fast-path
if len(u.muted) == 0 {
return twts
}
for _, twt := range twts {
if u.HasMuted(twt.Twter().URL) {
continue
}
filtered = append(filtered, twt)
}
return
}
func (u *User) Reply(twt types.Twt) string {
mentionsSet := make(map[string]bool)
for _, m := range twt.Mentions() {
twter := m.Twter()
if _, ok := mentionsSet[twter.Nick]; !ok && twter.Nick != u.Username {
mentionsSet[twter.Nick] = true
}
}
mentions := []string{fmt.Sprintf("@%s", twt.Twter().Nick)}
for nick := range mentionsSet {
mentions = append(mentions, fmt.Sprintf("@%s", nick))
}
mentions = UniqStrings(mentions)
subject := twt.Subject()
if subject != "" {
subject = FormatMentionsAndTagsForSubject(subject)
return fmt.Sprintf("%s %s ", strings.Join(mentions, " "), subject)
}
return fmt.Sprintf("%s ", strings.Join(mentions, " "))
}
func (u *User) Bytes() ([]byte, error) {
data, err := json.Marshal(u)
if err != nil {
return nil, err
}
return data, nil
}

432
internal/options.go Normal file
View File

@@ -0,0 +1,432 @@
package internal
import (
"net/url"
"regexp"
"time"
)
const (
// InvalidConfigValue is the constant value for invalid config values
// which must be changed for production configurations before successful
// startup
InvalidConfigValue = "INVALID CONFIG VALUE - PLEASE CHANGE THIS VALUE"
// DebugMode is the default debug mode
DefaultDebug = false
// DefaultData is the default data directory for storage
DefaultData = "./data"
// DefaultStore is the default data store used for accounts, sessions, etc
DefaultStore = "bitcask://spyda.db"
// DefaultBaseURL is the default Base URL for the app used to construct feed URLs
DefaultBaseURL = "http://0.0.0.0:8000"
// DefaultAdminXXX is the default admin user / pod operator
DefaultAdminUser = "admin"
DefaultAdminName = "Administrator"
DefaultAdminEmail = "support@twt.social"
// DefaultName is the default instance name
DefaultName = "spyda.dev"
// DefaultMetaxxx are the default set of <meta> tags used on non-specific views
DefaultMetaTitle = ""
DefaultMetaAuthor = "spyda.dev"
DefaultMetaKeywords = "spider, crawler, search, engine, web, index, spyda, find, lookup"
DefaultMetaDescription = " 🕸 spyda is a privacy first search engine and web crawler."
// DefaultTheme is the default theme to use ('light' or 'dark')
DefaultTheme = "dark"
// DefaultOpenRegistrations is the default for open user registrations
DefaultOpenRegistrations = false
// DefaultRegisterMessage is the default message displayed when registrations are disabled
DefaultRegisterMessage = ""
// DefaultCookieSecret is the server's default cookie secret
DefaultCookieSecret = InvalidConfigValue
// DefaultTwtsPerPage is the server's default twts per page to display
DefaultTwtsPerPage = 50
// DefaultMaxTwtLength is the default maximum length of posts permitted
DefaultMaxTwtLength = 288
// DefaultMaxCacheTTL is the default maximum cache ttl of twts in memory
DefaultMaxCacheTTL = time.Hour * 24 * 10 // 10 days 28 days 28 days 28 days
// DefaultMaxCacheItems is the default maximum cache items (per feed source)
// of twts in memory
DefaultMaxCacheItems = DefaultTwtsPerPage * 3 // We get bored after paging thorughh > 3 pages :D
// DefaultMsgPerPage is the server's default msgs per page to display
DefaultMsgsPerPage = 20
// DefaultOpenProfiles is the default for whether or not to have open user profiles
DefaultOpenProfiles = false
// DefaultMaxUploadSize is the default maximum upload size permitted
DefaultMaxUploadSize = 1 << 24 // ~16MB (enough for high-res photos)
// DefaultSessionCacheTTL is the server's default session cache ttl
DefaultSessionCacheTTL = 1 * time.Hour
// DefaultSessionExpiry is the server's default session expiry time
DefaultSessionExpiry = 240 * time.Hour // 10 days
// DefaultTranscoderTimeout is the default vodeo transcoding timeout
DefaultTranscoderTimeout = 10 * time.Minute // 10mins
// DefaultMagicLinkSecret is the jwt magic link secret
DefaultMagicLinkSecret = InvalidConfigValue
// Default Messaging settings
DefaultSMTPBind = "0.0.0.0:8025"
DefaultPOP3Bind = "0.0.0.0:8110"
// Default SMTP configuration
DefaultSMTPHost = "smtp.gmail.com"
DefaultSMTPPort = 587
DefaultSMTPUser = InvalidConfigValue
DefaultSMTPPass = InvalidConfigValue
DefaultSMTPFrom = InvalidConfigValue
// DefaultMaxFetchLimit is the maximum fetch fetch limit in bytes
DefaultMaxFetchLimit = 1 << 21 // ~2MB (or more than enough for a year)
// DefaultAPISessionTime is the server's default session time for API tokens
DefaultAPISessionTime = 240 * time.Hour // 10 days
// DefaultAPISigningKey is the default API JWT signing key for tokens
DefaultAPISigningKey = InvalidConfigValue
)
var (
// DefaultSearchPrompts are the set of default prompts for search(s)
DefaultSearchPrompts = []string{
`What can we find for you today?`,
`Whatcha look'n for?`,
`Looking for something? Type some keywords and hit Go!`,
`Looking for a local buinsess? Find it here!`,
`Tired of search engines tracking you? Search freely and privately here!`,
`Search the web...`,
}
)
func NewConfig() *Config {
return &Config{
Debug: DefaultDebug,
Name: DefaultName,
Description: DefaultMetaDescription,
Store: DefaultStore,
Theme: DefaultTheme,
BaseURL: DefaultBaseURL,
AdminUser: DefaultAdminUser,
FeedSources: DefaultFeedSources,
RegisterMessage: DefaultRegisterMessage,
CookieSecret: DefaultCookieSecret,
TwtPrompts: DefaultTwtPrompts,
TwtsPerPage: DefaultTwtsPerPage,
MaxTwtLength: DefaultMaxTwtLength,
MsgsPerPage: DefaultMsgsPerPage,
OpenProfiles: DefaultOpenProfiles,
OpenRegistrations: DefaultOpenRegistrations,
SessionExpiry: DefaultSessionExpiry,
MagicLinkSecret: DefaultMagicLinkSecret,
SMTPHost: DefaultSMTPHost,
SMTPPort: DefaultSMTPPort,
SMTPUser: DefaultSMTPUser,
SMTPPass: DefaultSMTPPass,
}
}
// Option is a function that takes a config struct and modifies it
type Option func(*Config) error
// WithDebug sets the debug mode lfag
func WithDebug(debug bool) Option {
return func(cfg *Config) error {
cfg.Debug = debug
return nil
}
}
// WithData sets the data directory to use for storage
func WithData(data string) Option {
return func(cfg *Config) error {
cfg.Data = data
return nil
}
}
// WithStore sets the store to use for accounts, sessions, etc.
func WithStore(store string) Option {
return func(cfg *Config) error {
cfg.Store = store
return nil
}
}
// WithBaseURL sets the Base URL used for constructing feed URLs
func WithBaseURL(baseURL string) Option {
return func(cfg *Config) error {
u, err := url.Parse(baseURL)
if err != nil {
return err
}
cfg.BaseURL = baseURL
cfg.baseURL = u
return nil
}
}
// WithAdminUser sets the Admin user used for granting special features to
func WithAdminUser(adminUser string) Option {
return func(cfg *Config) error {
cfg.AdminUser = adminUser
return nil
}
}
// WithAdminName sets the Admin name used to identify the pod operator
func WithAdminName(adminName string) Option {
return func(cfg *Config) error {
cfg.AdminName = adminName
return nil
}
}
// WithAdminEmail sets the Admin email used to contact the pod operator
func WithAdminEmail(adminEmail string) Option {
return func(cfg *Config) error {
cfg.AdminEmail = adminEmail
return nil
}
}
// WithFeedSources sets the feed sources to use for external feeds
func WithFeedSources(feedSources []string) Option {
return func(cfg *Config) error {
cfg.FeedSources = feedSources
return nil
}
}
// WithName sets the instance's name
func WithName(name string) Option {
return func(cfg *Config) error {
cfg.Name = name
return nil
}
}
// WithDescription sets the instance's description
func WithDescription(description string) Option {
return func(cfg *Config) error {
cfg.Description = description
return nil
}
}
// WithTheme sets the default theme to use
func WithTheme(theme string) Option {
return func(cfg *Config) error {
cfg.Theme = theme
return nil
}
}
// WithOpenRegistrations sets the open registrations flag
func WithOpenRegistrations(openRegistrations bool) Option {
return func(cfg *Config) error {
cfg.OpenRegistrations = openRegistrations
return nil
}
}
// WithCookieSecret sets the server's cookie secret
func WithCookieSecret(secret string) Option {
return func(cfg *Config) error {
cfg.CookieSecret = secret
return nil
}
}
// WithTwtsPerPage sets the server's twts per page
func WithTwtsPerPage(twtsPerPage int) Option {
return func(cfg *Config) error {
cfg.TwtsPerPage = twtsPerPage
return nil
}
}
// WithMaxTwtLength sets the maximum length of posts permitted on the server
func WithMaxTwtLength(maxTwtLength int) Option {
return func(cfg *Config) error {
cfg.MaxTwtLength = maxTwtLength
return nil
}
}
// WithMaxCacheTTL sets the maximum cache ttl of twts in memory
func WithMaxCacheTTL(maxCacheTTL time.Duration) Option {
return func(cfg *Config) error {
cfg.MaxCacheTTL = maxCacheTTL
return nil
}
}
// WithMaxCacheItems sets the maximum cache items (per feed source) of twts in memory
func WithMaxCacheItems(maxCacheItems int) Option {
return func(cfg *Config) error {
cfg.MaxCacheItems = maxCacheItems
return nil
}
}
// WithOpenProfiles sets whether or not to have open user profiles
func WithOpenProfiles(openProfiles bool) Option {
return func(cfg *Config) error {
cfg.OpenProfiles = openProfiles
return nil
}
}
// WithMaxUploadSize sets the maximum upload size permitted by the server
func WithMaxUploadSize(maxUploadSize int64) Option {
return func(cfg *Config) error {
cfg.MaxUploadSize = maxUploadSize
return nil
}
}
// WithSessionCacheTTL sets the server's session cache ttl
func WithSessionCacheTTL(cacheTTL time.Duration) Option {
return func(cfg *Config) error {
cfg.SessionCacheTTL = cacheTTL
return nil
}
}
// WithSessionExpiry sets the server's session expiry time
func WithSessionExpiry(expiry time.Duration) Option {
return func(cfg *Config) error {
cfg.SessionExpiry = expiry
return nil
}
}
// WithTranscoderTimeout sets the video transcoding timeout
func WithTranscoderTimeout(timeout time.Duration) Option {
return func(cfg *Config) error {
cfg.TranscoderTimeout = timeout
return nil
}
}
// WithMagicLinkSecret sets the MagicLinkSecert used to create password reset tokens
func WithMagicLinkSecret(secret string) Option {
return func(cfg *Config) error {
cfg.MagicLinkSecret = secret
return nil
}
}
// WithSMTPBind sets the interface and port to bind to for SMTP
func WithSMTPBind(smtpBind string) Option {
return func(cfg *Config) error {
cfg.SMTPBind = smtpBind
return nil
}
}
// WithPOP3Bind sets the interface and port to use for POP3
func WithPOP3Bind(pop3Bind string) Option {
return func(cfg *Config) error {
cfg.POP3Bind = pop3Bind
return nil
}
}
// WithSMTPHost sets the SMTPHost to use for sending email
func WithSMTPHost(host string) Option {
return func(cfg *Config) error {
cfg.SMTPHost = host
return nil
}
}
// WithSMTPPort sets the SMTPPort to use for sending email
func WithSMTPPort(port int) Option {
return func(cfg *Config) error {
cfg.SMTPPort = port
return nil
}
}
// WithSMTPUser sets the SMTPUser to use for sending email
func WithSMTPUser(user string) Option {
return func(cfg *Config) error {
cfg.SMTPUser = user
return nil
}
}
// WithSMTPPass sets the SMTPPass to use for sending email
func WithSMTPPass(pass string) Option {
return func(cfg *Config) error {
cfg.SMTPPass = pass
return nil
}
}
// WithSMTPFrom sets the SMTPFrom address to use for sending email
func WithSMTPFrom(from string) Option {
return func(cfg *Config) error {
cfg.SMTPFrom = from
return nil
}
}
// WithMaxFetchLimit sets the maximum feed fetch limit in bytes
func WithMaxFetchLimit(limit int64) Option {
return func(cfg *Config) error {
cfg.MaxFetchLimit = limit
return nil
}
}
// WithAPISessionTime sets the API session time for tokens
func WithAPISessionTime(duration time.Duration) Option {
return func(cfg *Config) error {
cfg.APISessionTime = duration
return nil
}
}
// WithAPISigningKey sets the API JWT signing key for tokens
func WithAPISigningKey(key string) Option {
return func(cfg *Config) error {
cfg.APISigningKey = key
return nil
}
}
// WithWhitelistedDomains sets the list of domains whitelisted and permitted for external iamges
func WithWhitelistedDomains(whitelistedDomains []string) Option {
return func(cfg *Config) error {
for _, whitelistedDomain := range whitelistedDomains {
re, err := regexp.Compile(whitelistedDomain)
if err != nil {
return err
}
cfg.whitelistedDomains = append(cfg.whitelistedDomains, re)
}
return nil
}
}

14
internal/pages/about.md Normal file
View File

@@ -0,0 +1,14 @@
---
title: About Spyda, a privacy first search engine and web crawler.
---
# About {{ .InstanceName }}
🕸 {{ .InstanceName }} is a privacy first search engine and web crawler.
> ... TBD ...
For additional help on how to use this service please see the [/help](/help)
page or contact [/support](/support) for help.
Please also see the [/privacy](/privacy) policy.

7
internal/pages/help.md Normal file
View File

@@ -0,0 +1,7 @@
---
title: Help on using Spyda Search
---
# Help
> ... TBD ...

35
internal/pages/privacy.md Normal file
View File

@@ -0,0 +1,35 @@
---
title: Privacy Policy
---
# Privacy Policy
This is aimed to be the world's simplest privacy policy.
This document should explain to you why the app collects some
information, what happens when your account is deleted and some other
frequently asked questions answered regarding your privacy.
If you have any more questions, please raise an issue regarding
the same on GitHub and I'll answer as honest and quickly as possible :)
<details>
<summary>What identifiable information is stored about me?</summary>
<p>
Your username/nickname and your email address is stored along with
your user information, and of course. You won't even get any
marketing emails, feature updates, newsletters, notification emails,
nothing.
</p>
</details>
<details>
<summary>How is this all free? There must be a catch!</summary>
<p>
Absolutely no catch to this freebie. This project is just my way of
giving back to the community that I've learned so much from. If you'd
like to show your appreciation however, you can follow me on my social
media and let me know how much it helped you, or donate to help pay
the cloud bills, or if you are a fellow developer, you can head to
GitHub and contribute to the code by raising a PR.
</p>
</details>

View File

@@ -0,0 +1,9 @@
package passwords
// Passwords is an interface for creating and verifying secure passwords
// An implementation must implement all methods and it is up to the impl
// which underlying crypto to use for hasing cleartext passwrods.
type Passwords interface {
CreatePassword(password string) (string, error)
CheckPassword(hash, password string) error
}

View File

@@ -0,0 +1,72 @@
package passwords
import (
"time"
scrypt "github.com/elithrar/simple-scrypt"
log "github.com/sirupsen/logrus"
)
const (
// DefaultMaxTimeout default max timeout in ms
DefaultMaxTimeout = 500 * time.Millisecond
// DefaultMaxMemory default max memory in MB
DefaultMaxMemory = 64
)
// Options ...
type Options struct {
maxTimeout time.Duration
maxMemory int
}
// NewOptions ...
func NewOptions(maxTimeout time.Duration, maxMemory int) *Options {
return &Options{maxTimeout, maxMemory}
}
// ScryptPasswords ...
type ScryptPasswords struct {
options *Options
params scrypt.Params
}
// NewScryptPasswords ...
func NewScryptPasswords(options *Options) Passwords {
if options == nil {
options = &Options{}
}
if options.maxTimeout == 0 {
options.maxTimeout = DefaultMaxTimeout
}
if options.maxMemory == 0 {
options.maxMemory = DefaultMaxMemory
}
log.Info("Calibrating scrypt parameters ...")
params, err := scrypt.Calibrate(
options.maxTimeout,
options.maxMemory,
scrypt.DefaultParams,
)
if err != nil {
log.Fatalf("error calibrating scrypt params: %s", err)
}
log.WithField("params", params).Info("scrypt params")
return &ScryptPasswords{options, params}
}
// CreatePassword ...
func (sp *ScryptPasswords) CreatePassword(password string) (string, error) {
hash, err := scrypt.GenerateFromPassword([]byte(password), sp.params)
return string(hash), err
}
// CheckPassword ...
func (sp *ScryptPasswords) CheckPassword(hash, password string) error {
return scrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}

49
internal/robots.go Normal file
View File

@@ -0,0 +1,49 @@
package internal
import (
"fmt"
"net/http"
"github.com/julienschmidt/httprouter"
log "github.com/sirupsen/logrus"
)
const robotsTpl = `User-Agent: *
Disallow: /
Allow: /
Allow: /twt
Allow: /user
Allow: /feed
Allow: /about
Allow: /help
Allow: /blogs
Allow: /privacy
Allow: /support
Allow: /search
Allow: /external
Allow: /atom.xml
Allow: /media
`
// RobotsHandler ...
func (s *Server) RobotsHandler() httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
ctx := NewContext(s.config, s.db, r)
text, err := RenderString(robotsTpl, ctx)
if err != nil {
log.WithError(err).Errorf("error rendering robots.txt")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(text)))
if r.Method == http.MethodHead {
return
}
_, _ = w.Write([]byte(text))
}
}

715
internal/server.go Normal file
View File

@@ -0,0 +1,715 @@
package internal
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
rice "github.com/GeertJohan/go.rice"
"github.com/NYTimes/gziphandler"
humanize "github.com/dustin/go-humanize"
"github.com/gabstv/merger"
"github.com/justinas/nosurf"
"github.com/prologic/observe"
"github.com/robfig/cron"
log "github.com/sirupsen/logrus"
"github.com/unrolled/logger"
"git.mills.io/prologic/spyda"
"git.mills.io/prologic/spyda/internal/auth"
"git.mills.io/prologic/spyda/internal/passwords"
"git.mills.io/prologic/spyda/internal/session"
"git.mills.io/prologic/spyda/internal/webmention"
)
var (
metrics *observe.Metrics
webmentions *webmention.WebMention
)
func init() {
metrics = observe.NewMetrics("twtd")
}
// Server ...
type Server struct {
bind string
config *Config
tmplman *TemplateManager
router *Router
server *http.Server
// Blogs Cache
blogs *BlogsCache
// Messages Cache
msgs *MessagesCache
// Feed Cache
cache *Cache
// Feed Archiver
archive Archiver
// Data Store
db Store
// Scheduler
cron *cron.Cron
// Dispatcher
tasks *Dispatcher
// Auth
am *auth.Manager
// Sessions
sc *SessionStore
sm *session.Manager
// API
api *API
// POP3 Service
pop3Service *POP3Service
// SMTP Service
smtpService *SMTPService
// Passwords
pm passwords.Passwords
}
func (s *Server) render(name string, w http.ResponseWriter, ctx *Context) {
if ctx.Authenticated && ctx.Username != "" {
ctx.NewMessages = s.msgs.Get(ctx.User.Username)
}
buf, err := s.tmplman.Exec(name, ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, err = buf.WriteTo(w)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// AddRouter ...
func (s *Server) AddRoute(method, path string, handler http.Handler) {
s.router.Handler(method, path, handler)
}
// AddShutdownHook ...
func (s *Server) AddShutdownHook(f func()) {
s.server.RegisterOnShutdown(f)
}
// Shutdown ...
func (s *Server) Shutdown(ctx context.Context) error {
s.cron.Stop()
s.tasks.Stop()
s.smtpService.Stop()
if err := s.server.Shutdown(ctx); err != nil {
log.WithError(err).Error("error shutting down server")
return err
}
if err := s.db.Close(); err != nil {
log.WithError(err).Error("error closing store")
return err
}
return nil
}
// Run ...
func (s *Server) Run() (err error) {
idleConnsClosed := make(chan struct{})
go func() {
if err = s.ListenAndServe(); err != http.ErrServerClosed {
// Error starting or closing listener:
log.WithError(err).Fatal("HTTP server ListenAndServe")
}
}()
sigch := make(chan os.Signal, 1)
signal.Notify(sigch, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigch
log.Infof("Received signal %s", sig)
log.Info("Shutting down...")
// We received an interrupt signal, shut down.
if err = s.Shutdown(context.Background()); err != nil {
// Error from closing listeners, or context timeout:
log.WithError(err).Fatal("Error shutting down HTTP server")
}
close(idleConnsClosed)
<-idleConnsClosed
return
}
// ListenAndServe ...
func (s *Server) ListenAndServe() error {
return s.server.ListenAndServe()
}
// AddCronJob ...
func (s *Server) AddCronJob(spec string, job cron.Job) error {
return s.cron.AddJob(spec, job)
}
func (s *Server) setupMetrics() {
ctime := time.Now()
// server uptime counter
metrics.NewCounterFunc(
"server", "uptime",
"Number of nanoseconds the server has been running",
func() float64 {
return float64(time.Since(ctime).Nanoseconds())
},
)
// sessions
metrics.NewGaugeFunc(
"server", "sessions",
"Number of active in-memory sessions (non-persistent)",
func() float64 {
return float64(s.sc.Count())
},
)
// database keys
metrics.NewGaugeFunc(
"db", "feeds",
"Number of database /feeds keys",
func() float64 {
return float64(s.db.LenFeeds())
},
)
metrics.NewGaugeFunc(
"db", "sessions",
"Number of database /sessions keys",
func() float64 {
return float64(s.db.LenSessions())
},
)
metrics.NewGaugeFunc(
"db", "users",
"Number of database /users keys",
func() float64 {
return float64(s.db.LenUsers())
},
)
metrics.NewGaugeFunc(
"db", "tokens",
"Number of database /tokens keys",
func() float64 {
return float64(s.db.LenTokens())
},
)
// feed cache sources
metrics.NewGauge(
"cache", "sources",
"Number of feed sources being fetched by the global feed cache",
)
// feed cache size
metrics.NewGauge(
"cache", "feeds",
"Number of unique feeds in the global feed cache",
)
// feed cache size
metrics.NewGauge(
"cache", "twts",
"Number of active twts in the global feed cache",
)
// blogs cache size
metrics.NewGaugeFunc(
"cache", "blogs",
"Number of blogs in the blogs cache",
func() float64 {
return float64(s.blogs.Count())
},
)
// feed cache processing time
metrics.NewGauge(
"cache", "last_processed_seconds",
"Number of seconds for a feed cache cycle",
)
// feed cache limited fetch (feed exceeded MaxFetchLImit or unknown size)
metrics.NewCounter(
"cache", "limited",
"Number of feed cache fetches affected by MaxFetchLimit",
)
// archive size
metrics.NewCounter(
"archive", "size",
"Number of items inserted into the global feed archive",
)
// archive errors
metrics.NewCounter(
"archive", "error",
"Number of items errored inserting into the global feed archive",
)
// server info
metrics.NewGaugeVec(
"server", "info",
"Server information",
[]string{"full_version", "version", "commit"},
)
metrics.GaugeVec("server", "info").
With(map[string]string{
"full_version": spyda.FullVersion(),
"version": spyda.Version,
"commit": spyda.Commit,
}).Set(1)
// old avatars
metrics.NewCounter(
"media", "old_avatar",
"Count of old Avtar (PNG) conversions",
)
// old media
metrics.NewCounter(
"media", "old_media",
"Count of old Media (PNG) served",
)
s.AddRoute("GET", "/metrics", metrics.Handler())
}
func (s *Server) setupCronJobs() error {
for name, jobSpec := range Jobs {
if jobSpec.Schedule == "" {
continue
}
job := jobSpec.Factory(s.config, s.blogs, s.cache, s.archive, s.db)
if err := s.cron.AddJob(jobSpec.Schedule, job); err != nil {
return err
}
log.Infof("Started background job %s (%s)", name, jobSpec.Schedule)
}
return nil
}
func (s *Server) runStartupJobs() {
time.Sleep(time.Second * 5)
log.Info("running startup jobs")
for name, jobSpec := range StartupJobs {
job := jobSpec.Factory(s.config, s.blogs, s.cache, s.archive, s.db)
log.Infof("running %s now...", name)
job.Run()
}
// Merge store
if err := s.db.Merge(); err != nil {
log.WithError(err).Error("error merging store")
}
}
func (s *Server) initRoutes() {
if s.config.Debug {
s.router.ServeFiles("/css/*filepath", http.Dir("./internal/static/css"))
s.router.ServeFiles("/img/*filepath", http.Dir("./internal/static/img"))
s.router.ServeFiles("/js/*filepath", http.Dir("./internal/static/js"))
} else {
cssBox := rice.MustFindBox("static/css").HTTPBox()
imgBox := rice.MustFindBox("static/img").HTTPBox()
jsBox := rice.MustFindBox("static/js").HTTPBox()
s.router.ServeFilesWithCacheControl("/css/:commit/*filepath", cssBox)
s.router.ServeFilesWithCacheControl("/img/:commit/*filepath", imgBox)
s.router.ServeFilesWithCacheControl("/js/:commit/*filepath", jsBox)
}
s.router.NotFound = http.HandlerFunc(s.NotFoundHandler)
s.router.GET("/about", s.PageHandler("about"))
s.router.GET("/help", s.PageHandler("help"))
s.router.GET("/privacy", s.PageHandler("privacy"))
s.router.GET("/abuse", s.PageHandler("abuse"))
s.router.GET("/", s.TimelineHandler())
s.router.HEAD("/", s.TimelineHandler())
s.router.GET("/robots.txt", s.RobotsHandler())
s.router.HEAD("/robots.txt", s.RobotsHandler())
s.router.GET("/discover", s.am.MustAuth(s.DiscoverHandler()))
s.router.GET("/mentions", s.am.MustAuth(s.MentionsHandler()))
s.router.GET("/search", s.SearchHandler())
s.router.HEAD("/twt/:hash", s.PermalinkHandler())
s.router.GET("/twt/:hash", s.PermalinkHandler())
s.router.GET("/bookmark/:hash", s.BookmarkHandler())
s.router.POST("/bookmark/:hash", s.BookmarkHandler())
s.router.HEAD("/conv/:hash", s.ConversationHandler())
s.router.GET("/conv/:hash", s.ConversationHandler())
s.router.GET("/feeds", s.am.MustAuth(s.FeedsHandler()))
s.router.POST("/feed", s.am.MustAuth(s.FeedHandler()))
s.router.POST("/post", s.am.MustAuth(s.PostHandler()))
s.router.PATCH("/post", s.am.MustAuth(s.PostHandler()))
s.router.DELETE("/post", s.am.MustAuth(s.PostHandler()))
// Private Messages
s.router.GET("/messages", s.am.MustAuth(s.ListMessagesHandler()))
s.router.GET("/messages/:msgid", s.am.MustAuth(s.ViewMessageHandler()))
s.router.POST("/messages/send", s.am.MustAuth(s.SendMessageHandler()))
s.router.POST("/messages/delete", s.am.MustAuth(s.DeleteMessagesHandler()))
s.router.POST("/blog", s.am.MustAuth(s.CreateOrUpdateBlogHandler()))
s.router.GET("/blogs/:author", s.ListBlogsHandler())
s.router.GET("/blog/:author/:year/:month/:date/:slug", s.ViewBlogHandler())
s.router.HEAD("/blog/:author/:year/:month/:date/:slug", s.ViewBlogHandler())
s.router.GET("/blog/:author/:year/:month/:date/:slug/edit", s.EditBlogHandler())
s.router.GET("/blog/:author/:year/:month/:date/:slug/delete", s.DeleteBlogHandler())
s.router.GET("/blog/:author/:year/:month/:date/:slug/publish", s.PublishBlogHandler())
if s.config.OpenProfiles {
s.router.GET("/user/:nick/", s.ProfileHandler())
s.router.GET("/user/:nick/config.yaml", s.UserConfigHandler())
} else {
s.router.GET("/user/:nick/", s.am.MustAuth(s.ProfileHandler()))
s.router.GET("/user/:nick/config.yaml", s.am.MustAuth(s.UserConfigHandler()))
}
s.router.GET("/user/:nick/avatar", s.AvatarHandler())
s.router.HEAD("/user/:nick/avatar", s.AvatarHandler())
s.router.GET("/user/:nick/followers", s.FollowersHandler())
s.router.GET("/user/:nick/following", s.FollowingHandler())
s.router.GET("/user/:nick/bookmarks", s.BookmarksHandler())
s.router.GET("/pod/avatar", s.PodAvatarHandler())
// WebMentions
s.router.POST("/user/:nick/webmention", s.WebMentionHandler())
// External Feeds
s.router.GET("/external", s.ExternalHandler())
s.router.GET("/externalAvatar", s.ExternalAvatarHandler())
s.router.HEAD("/externalAvatar", s.ExternalAvatarHandler())
// External Queries (protected by a short-lived token)
s.router.GET("/whoFollows", s.WhoFollowsHandler())
// Syndication Formats (RSS, Atom, JSON Feed)
s.router.HEAD("/atom.xml", s.SyndicationHandler())
s.router.HEAD("/user/:nick/atom.xml", s.SyndicationHandler())
s.router.GET("/atom.xml", s.SyndicationHandler())
s.router.GET("/user/:nick/atom.xml", s.SyndicationHandler())
s.router.GET("/feed/:name/manage", s.am.MustAuth(s.ManageFeedHandler()))
s.router.POST("/feed/:name/manage", s.am.MustAuth(s.ManageFeedHandler()))
s.router.POST("/feed/:name/archive", s.am.MustAuth(s.ArchiveFeedHandler()))
s.router.GET("/login", s.am.HasAuth(s.LoginHandler()))
s.router.POST("/login", s.LoginHandler())
s.router.GET("/logout", s.LogoutHandler())
s.router.POST("/logout", s.LogoutHandler())
s.router.GET("/register", s.am.HasAuth(s.RegisterHandler()))
s.router.POST("/register", s.RegisterHandler())
// Reset Password
s.router.GET("/resetPassword", s.ResetPasswordHandler())
s.router.POST("/resetPassword", s.ResetPasswordHandler())
s.router.GET("/newPassword", s.ResetPasswordMagicLinkHandler())
s.router.POST("/newPassword", s.NewPasswordHandler())
// Media Handling
s.router.GET("/media/:name", s.MediaHandler())
s.router.HEAD("/media/:name", s.MediaHandler())
s.router.POST("/upload", s.am.MustAuth(s.UploadMediaHandler()))
// Task State
s.router.GET("/task/:uuid", s.TaskHandler())
// User/Feed Lookups
s.router.GET("/lookup", s.am.MustAuth(s.LookupHandler()))
s.router.GET("/follow", s.am.MustAuth(s.FollowHandler()))
s.router.POST("/follow", s.am.MustAuth(s.FollowHandler()))
s.router.GET("/import", s.am.MustAuth(s.ImportHandler()))
s.router.POST("/import", s.am.MustAuth(s.ImportHandler()))
s.router.GET("/unfollow", s.am.MustAuth(s.UnfollowHandler()))
s.router.POST("/unfollow", s.am.MustAuth(s.UnfollowHandler()))
s.router.GET("/mute", s.am.MustAuth(s.MuteHandler()))
s.router.POST("/mute", s.am.MustAuth(s.MuteHandler()))
s.router.GET("/unmute", s.am.MustAuth(s.UnmuteHandler()))
s.router.POST("/unmute", s.am.MustAuth(s.UnmuteHandler()))
s.router.GET("/transferFeed/:name", s.TransferFeedHandler())
s.router.GET("/transferFeed/:name/:transferTo", s.TransferFeedHandler())
s.router.GET("/settings", s.am.MustAuth(s.SettingsHandler()))
s.router.POST("/settings", s.am.MustAuth(s.SettingsHandler()))
s.router.POST("/token/delete/:signature", s.am.MustAuth(s.DeleteTokenHandler()))
s.router.GET("/config", s.am.MustAuth(s.PodConfigHandler()))
s.router.GET("/manage/pod", s.ManagePodHandler())
s.router.POST("/manage/pod", s.ManagePodHandler())
s.router.GET("/manage/users", s.ManageUsersHandler())
s.router.POST("/manage/adduser", s.AddUserHandler())
s.router.POST("/manage/deluser", s.DelUserHandler())
s.router.GET("/deleteFeeds", s.DeleteAccountHandler())
s.router.POST("/delete", s.am.MustAuth(s.DeleteAllHandler()))
// Support / Report Abuse handlers
s.router.GET("/support", s.SupportHandler())
s.router.POST("/support", s.SupportHandler())
s.router.GET("/_captcha", s.CaptchaHandler())
s.router.GET("/report", s.ReportHandler())
s.router.POST("/report", s.ReportHandler())
}
// NewServer ...
func NewServer(bind string, options ...Option) (*Server, error) {
config := NewConfig()
for _, opt := range options {
if err := opt(config); err != nil {
return nil, err
}
}
settings, err := LoadSettings(filepath.Join(config.Data, "settings.yaml"))
if err != nil {
log.Warnf("error loading pod settings: %s", err)
} else {
if err := merger.MergeOverwrite(config, settings); err != nil {
log.WithError(err).Error("error merging pod settings")
return nil, err
}
}
if err := config.Validate(); err != nil {
log.WithError(err).Error("error validating config")
return nil, fmt.Errorf("error validating config: %w", err)
}
blogs, err := LoadBlogsCache(config.Data)
if err != nil {
log.WithError(err).Error("error loading blogs cache (re-creating)")
blogs = NewBlogsCache()
log.Info("updating blogs cache")
blogs.UpdateBlogs(config)
}
if len(blogs.Blogs) == 0 {
log.Info("empty blogs cache, updating...")
blogs.UpdateBlogs(config)
}
msgs, err := LoadMessagesCache(config.Data)
if err != nil {
log.WithError(err).Error("error loading messages cache (re-creating)")
msgs = NewMessagesCache()
}
log.Info("updating messages cache")
msgs.Refresh(config)
cache, err := LoadCache(config.Data)
if err != nil {
log.WithError(err).Error("error loading feed cache")
return nil, err
}
archive, err := NewDiskArchiver(filepath.Join(config.Data, archiveDir))
if err != nil {
log.WithError(err).Error("error creating feed archiver")
return nil, err
}
db, err := NewStore(config.Store)
if err != nil {
log.WithError(err).Error("error creating store")
return nil, err
}
if err := db.Merge(); err != nil {
log.WithError(err).Error("error merging store")
return nil, err
}
tmplman, err := NewTemplateManager(config, blogs, cache)
if err != nil {
log.WithError(err).Error("error creating template manager")
return nil, err
}
router := NewRouter()
am := auth.NewManager(auth.NewOptions("/login", "/register"))
tasks := NewDispatcher(10, 100) // TODO: Make this configurable?
pm := passwords.NewScryptPasswords(nil)
sc := NewSessionStore(db, config.SessionCacheTTL)
sm := session.NewManager(
session.NewOptions(
config.Name,
config.CookieSecret,
config.LocalURL().Scheme == "https",
config.SessionExpiry,
),
sc,
)
api := NewAPI(router, config, cache, archive, db, pm, tasks)
pop3Service := NewPOP3Service(config, db, pm, msgs, tasks)
smtpService := NewSMTPService(config, db, pm, msgs, tasks)
csrfHandler := nosurf.New(router)
csrfHandler.ExemptGlob("/api/v1/*")
server := &Server{
bind: bind,
config: config,
router: router,
tmplman: tmplman,
server: &http.Server{
Addr: bind,
Handler: logger.New(logger.Options{
Prefix: "spyda",
RemoteAddressHeaders: []string{"X-Forwarded-For"},
}).Handler(
gziphandler.GzipHandler(
sm.Handler(csrfHandler),
),
),
},
// API
api: api,
// POP3 Servicee
pop3Service: pop3Service,
// SMTP Servicee
smtpService: smtpService,
// Blogs Cache
blogs: blogs,
// Messages Cache
msgs: msgs,
// Feed Cache
cache: cache,
// Feed Archiver
archive: archive,
// Data Store
db: db,
// Schedular
cron: cron.New(),
// Dispatcher
tasks: tasks,
// Auth Manager
am: am,
// Session Manager
sc: sc,
sm: sm,
// Password Manager
pm: pm,
}
if err := server.setupCronJobs(); err != nil {
log.WithError(err).Error("error setting up background jobs")
return nil, err
}
server.cron.Start()
log.Info("started background jobs")
server.tasks.Start()
log.Info("started task dispatcher")
server.pop3Service.Start()
log.Info("started POP3 service")
server.smtpService.Start()
log.Info("started SMTP service")
server.setupWebMentions()
log.Infof("started webmentions processor")
server.setupMetrics()
log.Infof("serving metrics endpoint at %s/metrics", server.config.BaseURL)
// Log interesting configuration options
log.Infof("Instance Name: %s", server.config.Name)
log.Infof("Base URL: %s", server.config.BaseURL)
log.Infof("Admin User: %s", server.config.AdminUser)
log.Infof("Admin Name: %s", server.config.AdminName)
log.Infof("Admin Email: %s", server.config.AdminEmail)
log.Infof("Max Twts per Page: %d", server.config.TwtsPerPage)
log.Infof("Max Cache TTL: %s", server.config.MaxCacheTTL)
log.Infof("Max Cache Items: %d", server.config.MaxCacheItems)
log.Infof("Maximum length of Posts: %d", server.config.MaxTwtLength)
log.Infof("Open User Profiles: %t", server.config.OpenProfiles)
log.Infof("Open Registrations: %t", server.config.OpenRegistrations)
log.Infof("SMTP Host: %s", server.config.SMTPHost)
log.Infof("SMTP Port: %d", server.config.SMTPPort)
log.Infof("SMTP User: %s", server.config.SMTPUser)
log.Infof("SMTP From: %s", server.config.SMTPFrom)
log.Infof("Max Fetch Limit: %s", humanize.Bytes(uint64(server.config.MaxFetchLimit)))
log.Infof("Max Upload Size: %s", humanize.Bytes(uint64(server.config.MaxUploadSize)))
log.Infof("API Session Time: %s", server.config.APISessionTime)
// Warn about user registration being disabled.
if !server.config.OpenRegistrations {
log.Warn("Open Registrations are disabled as per configuration (no -R/--open-registrations)")
}
server.initRoutes()
api.initRoutes()
go server.runStartupJobs()
return server, nil
}

166
internal/session/manager.go Normal file
View File

@@ -0,0 +1,166 @@
package session
import (
"context"
"net/http"
"time"
"github.com/andreadipersio/securecookie"
log "github.com/sirupsen/logrus"
)
// Key ...
type Key int
const (
SessionKey Key = iota
)
// Options ...
type Options struct {
name string
secret string
secure bool
expiry time.Duration
}
// NewOptions ...
func NewOptions(name, secret string, secure bool, expiry time.Duration) *Options {
return &Options{name, secret, secure, expiry}
}
// Manager ...
type Manager struct {
options *Options
store Store
}
// NewManager ...
func NewManager(options *Options, store Store) *Manager {
return &Manager{options, store}
}
// Create ...
func (m *Manager) Create(w http.ResponseWriter) (*Session, error) {
sid, err := NewSessionID(m.options.secret)
if err != nil {
log.WithError(err).Error("error creating new session")
return nil, err
}
cookie := &http.Cookie{
Name: m.options.name,
Value: sid.String(),
Path: "/",
Secure: m.options.secure,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(m.options.expiry.Seconds()),
Expires: time.Now().Add(m.options.expiry),
}
securecookie.SetSecureCookie(w, m.options.secret, cookie)
return &Session{
store: m.store,
ID: sid.String(),
Data: make(Map),
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(m.options.expiry),
}, nil
}
// Validate ....
func (m *Manager) Validate(value string) (ID, error) {
sessionID, err := ValidateSessionID(value, m.options.secret)
return sessionID, err
}
// GetOrCreate ...
func (m *Manager) GetOrCreate(w http.ResponseWriter, r *http.Request) (*Session, error) {
cookie, err := securecookie.GetSecureCookie(
r,
m.options.secret,
m.options.name,
)
if err != nil {
sess, err := m.Create(w)
if err != nil {
log.WithError(err).Error("error creating new session")
return nil, err
}
if err = m.store.SetSession(sess.ID, sess); err != nil {
log.WithError(err).Errorf("error creating new session for %s", sess.ID)
return nil, err
}
return sess, nil
}
sid, err := m.Validate(cookie.Value)
if err != nil {
log.WithError(err).Error("error validating seesion")
return nil, err
}
sess, err := m.store.GetSession(sid.String())
if err != nil {
if err == ErrSessionNotFound {
log.WithError(err).Warnf("no session found for %s (creating new one)", sid)
m.Delete(w, r)
sess, err := m.Create(w)
if err != nil {
log.WithError(err).Error("error creating new session")
return nil, err
}
if err = m.store.SetSession(sess.ID, sess); err != nil {
log.WithError(err).Errorf("error creating new session for %s", sess.ID)
return nil, err
}
return sess, nil
}
log.WithError(err).Errorf("error loading session for %s", sid)
return nil, err
}
return sess, nil
}
// Delete ...
func (m *Manager) Delete(w http.ResponseWriter, r *http.Request) {
if sess := r.Context().Value(SessionKey); sess != nil {
sess := sess.(*Session)
if err := m.store.DelSession(sess.ID); err != nil {
log.WithError(err).Warnf("error deleting session %s", sess.ID)
}
}
cookie := &http.Cookie{
Name: m.options.name,
Value: "",
Secure: m.options.secure,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
Expires: time.Now(),
}
securecookie.SetSecureCookie(w, m.options.secret, cookie)
}
// Handler ...
func (m *Manager) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, err := m.GetOrCreate(w, r)
if err != nil {
log.WithError(err).Error("session error")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
ctx := context.WithValue(r.Context(), SessionKey, sess)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -0,0 +1,67 @@
package session
import (
"time"
"github.com/patrickmn/go-cache"
)
// MemoryStore represents an in-memory session store.
// This should be used only for testing and prototyping.
// Production systems should use a shared server store like redis
type MemoryStore struct {
entries *cache.Cache
}
// NewMemoryStore constructs and returns a new MemoryStore
func NewMemoryStore(sessionDuration time.Duration) *MemoryStore {
if sessionDuration < 0 {
sessionDuration = DefaultSessionDuration
}
return &MemoryStore{
entries: cache.New(sessionDuration, time.Minute),
}
}
// GetSession ...
func (s *MemoryStore) GetSession(sid string) (*Session, error) {
val, found := s.entries.Get(sid)
if !found {
return nil, ErrSessionNotFound
}
sess := val.(*Session)
return sess, nil
}
// SetSession ...
func (s *MemoryStore) SetSession(sid string, sess *Session) error {
s.entries.Set(sid, sess, cache.DefaultExpiration)
return nil
}
// HasSession ...
func (s *MemoryStore) HasSession(sid string) bool {
_, ok := s.entries.Get(sid)
return ok
}
// DelSession ...
func (s *MemoryStore) DelSession(sid string) error {
s.entries.Delete(sid)
return nil
}
// SyncSession ...
func (s *MemoryStore) SyncSession(sess *Session) error {
return nil
}
// GetAllSessions ...
func (s *MemoryStore) GetAllSessions() ([]*Session, error) {
var sessions []*Session
for _, item := range s.entries.Items() {
sess := item.Object.(*Session)
sessions = append(sessions, sess)
}
return sessions, nil
}

View File

@@ -0,0 +1,67 @@
package session
import (
"encoding/json"
"time"
)
// Map ...
type Map map[string]string
// Session ...
type Session struct {
store Store
ID string `json:"id"`
Data Map `json:"data"`
CreatedAt time.Time `json:"created"`
ExpiresAt time.Time `json:"expires"`
}
func NewSession(store Store) *Session {
return &Session{store: store}
}
func LoadSession(data []byte, sess *Session) error {
if err := json.Unmarshal(data, &sess); err != nil {
return err
}
if sess.Data == nil {
sess.Data = make(Map)
}
return nil
}
func (sess *Session) Expired() bool {
return sess.ExpiresAt.Before(time.Now())
}
func (sess *Session) Set(key, val string) error {
sess.Data[key] = val
return sess.store.SyncSession(sess)
}
func (sess *Session) Get(key string) (val string, ok bool) {
val, ok = sess.Data[key]
return
}
func (sess *Session) Has(key string) bool {
_, ok := sess.Data[key]
return ok
}
func (sess *Session) Del(key string) error {
delete(sess.Data, key)
return sess.store.SyncSession(sess)
}
func (sess *Session) Bytes() ([]byte, error) {
data, err := json.Marshal(sess)
if err != nil {
return nil, err
}
return data, nil
}

65
internal/session/sid.go Normal file
View File

@@ -0,0 +1,65 @@
package session
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
)
// InvalidSessionID represents an empty, invalid session ID
const InvalidSessionID ID = ""
const idLength = 32
const signedLength = idLength + sha256.Size
// ID represents a valid, digitally-signed session ID
type ID string
// ErrInvalidID is returned when an invalid session id is passed to ValidateID()
var ErrInvalidID = errors.New("Invalid Session ID")
// NewSessionID creates and returns a new digitally-signed session ID,
// using `signingKey` as the HMAC signing key. An error is returned only
// if there was an error generating random bytes for the session ID
func NewSessionID(signingKey string) (ID, error) {
buf := make([]byte, signedLength)
_, err := rand.Read(buf[:idLength])
if err != nil {
return InvalidSessionID, err
}
mac := hmac.New(sha256.New, []byte(signingKey))
_, _ = mac.Write(buf[:idLength])
sig := mac.Sum(nil)
copy(buf[idLength:], sig)
return ID(base64.URLEncoding.EncodeToString(buf)), nil
}
// ValidateSessionID validates the `id` parameter using the `signingKey`
// and returns an error if invalid, or a SignedID if valid
func ValidateSessionID(id string, signingKey string) (ID, error) {
buf, err := base64.URLEncoding.DecodeString(id)
if err != nil {
return InvalidSessionID, err
}
if len(buf) < signedLength {
return InvalidSessionID, ErrInvalidID
}
mac := hmac.New(sha256.New, []byte(signingKey))
_, _ = mac.Write(buf[:idLength])
messageMAC := mac.Sum(nil)
if !hmac.Equal(messageMAC, buf[idLength:]) {
return InvalidSessionID, ErrInvalidID
}
return ID(id), nil
}
func (sid ID) String() string {
return string(sid)
}

View File

@@ -0,0 +1,75 @@
package session
import (
"crypto/rand"
"encoding/base64"
"testing"
)
const testSigningKey = "a very secret key"
func TestNewID(t *testing.T) {
sid, err := NewSessionID(testSigningKey)
if err != nil {
t.Fatal(err)
}
if 0 == len(sid) {
t.Errorf("Signed ID string was empty")
}
_, err = ValidateSessionID(sid.String(), testSigningKey)
if nil != err {
t.Fatal(err)
}
}
func TestInvalidKey(t *testing.T) {
sid, err := NewSessionID(testSigningKey)
if err != nil {
t.Fatal(err)
}
_, err = ValidateSessionID(sid.String(), "some other signing key")
if nil == err {
t.Errorf("Was able to validate with incorrect signign key")
}
}
func TestModified(t *testing.T) {
sid, err := NewSessionID(testSigningKey)
if err != nil {
t.Fatal(err)
}
runes := []rune(sid.String())
runes[0]++
modsid := string(runes)
_, err = ValidateSessionID(modsid, testSigningKey)
if nil == err {
t.Errorf("Was able to validate modified encoded string")
}
}
func TestEmptyID(t *testing.T) {
_, err := ValidateSessionID("", testSigningKey)
if err == nil {
t.Error("Able to validate empty key")
}
}
func TestBadKey(t *testing.T) {
buf := make([]byte, signedLength)
if _, err := rand.Read(buf); nil != err {
t.Fatal(err)
}
badid := base64.URLEncoding.EncodeToString(buf)
_, err := ValidateSessionID(badid, testSigningKey)
if err == nil {
t.Error("Able to validate bad key")
}
}

32
internal/session/store.go Normal file
View File

@@ -0,0 +1,32 @@
package session
import (
"errors"
"time"
)
// DefaultSessionDuration is the default duration for
// saving session data in the store. Most Store implementations
// will automatically delete saved session data after this time.
const DefaultSessionDuration = time.Hour
var (
ErrSessionNotFound = errors.New("sessin not found or expired")
ErrSessionExpired = errors.New("session expired")
)
// Store represents a session data store.
// This is an abstract interface that can be implemented
// against several different types of data stores. For example,
// session data could be stored in memory in a concurrent map,
// or more typically in a shared key/value server store like redis.
type Store interface {
GetSession(sid string) (*Session, error)
SetSession(sid string, sess *Session) error
HasSession(sid string) bool
DelSession(sid string) error
SyncSession(sess *Session) error
GetAllSessions() ([]*Session, error)
}

90
internal/session_store.go Normal file
View File

@@ -0,0 +1,90 @@
package internal
import (
"time"
"github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus"
"git.mills.io/prologic/spyda/internal/session"
)
// SessionStore ...
type SessionStore struct {
store Store
cached *cache.Cache
}
func NewSessionStore(store Store, sessionCacheTTL time.Duration) *SessionStore {
return &SessionStore{
store: store,
cached: cache.New(sessionCacheTTL, time.Minute*5),
}
}
func (s *SessionStore) Count() int {
return s.cached.ItemCount()
}
func (s *SessionStore) GetSession(sid string) (*session.Session, error) {
val, found := s.cached.Get(sid)
if found {
return val.(*session.Session), nil
}
return s.store.GetSession(sid)
}
func (s *SessionStore) SetSession(sid string, sess *session.Session) error {
s.cached.Set(sid, sess, cache.DefaultExpiration)
if persist, ok := sess.Get("persist"); !ok || persist != "1" {
return nil
}
return s.store.SetSession(sid, sess)
}
func (s *SessionStore) HasSession(sid string) bool {
_, ok := s.cached.Get(sid)
if ok {
return true
}
return s.store.HasSession(sid)
}
func (s *SessionStore) DelSession(sid string) error {
if s.store.HasSession(sid) {
if err := s.store.DelSession(sid); err != nil {
log.WithError(err).Errorf("error deleting persistent session %s", sid)
return err
}
}
s.cached.Delete(sid)
return nil
}
func (s *SessionStore) SyncSession(sess *session.Session) error {
if persist, ok := sess.Get("persist"); ok && persist == "1" {
if err := s.store.SetSession(sess.ID, sess); err != nil {
log.WithError(err).Errorf("error persisting session %s", sess.ID)
return err
}
}
return s.SetSession(sess.ID, sess)
}
func (s *SessionStore) GetAllSessions() ([]*session.Session, error) {
var sessions []*session.Session
for _, item := range s.cached.Items() {
sess := item.Object.(*session.Session)
sessions = append(sessions, sess)
}
persistedSessions, err := s.store.GetAllSessions()
if err != nil {
log.WithError(err).Error("error getting all persisted sessions")
return sessions, err
}
return append(sessions, persistedSessions...), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,229 @@
i[class*="icss-"] {
position: relative;
display:inline-block;
font-style: normal;
background-color:currentColor;
box-sizing: border-box;
vertical-align: middle;
}
i[class*="icss-"]:before,
i[class*="icss-"]:after {
content: "";
border-width: 0;
position: absolute;
box-sizing: border-box;
}
/* Size */
[class*="icss-"].xxsmall {
font-size: .45em;
}
[class*="icss-"].xsmall {
font-size: .5em;
}
[class*="icss-"].small {
font-size: .65em;
}
[class*="icss-"].x1_5 {
font-size: 1.5em;
}
[class*="icss-"].x2 {
font-size: 2em;
}
[class*="icss-"].x2_5 {
font-size: 2.5em;
}
[class*="icss-"].x3 {
font-size: 3em;
}
[class*="icss-"].x4 {
font-size: 4em;
}
[class*="icss-"].x5 {
font-size: 5em;
}
/* Align text-bottom */
i[class*="icss-"].bottom {
vertical-align:text-bottom;
}
/* flip */
.flip {
transform: scaleX(-1);
}
/* rotate */
i[class*="icss-"].rot10 {
transform: rotate(10deg);
}
i[class*="icss-"].rot-10 {
transform: rotate(-10deg);
}
i[class*="icss-"].rot20 {
transform: rotate(20deg);
}
i[class*="icss-"].rot-20 {
transform: rotate(-20deg);
}
i[class*="icss-"].rot45 {
transform: rotate(45deg);
}
i[class*="icss-"].rot-45 {
transform: rotate(-45deg);
}
i[class*="icss-"].rot90 {
transform: rotate(90deg);
}
i[class*="icss-"].rot-90 {
transform: rotate(-90deg);
}
i[class*="icss-"].rot180 {
transform: rotate(180deg);
}
i[class*="icss-"].rot-180 {
transform: rotate(-180deg);
}
/* force animation */
i.icss-anim,
i.icss-anim:before,
i.icss-anim:after {
transition: all 1s;
}
/* Spin */
.icss-spin {
animation: spin 2s infinite linear;
}
.icss-pulse {
animation: spin 1s infinite steps(8);
}
.icss-spin-hover:hover {
animation: spin 2s infinite linear;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(359deg);
}
}
/* BELL */
@keyframes ring {
0%{transform:rotate(-15deg)}
2%{transform:rotate(15deg)}
4%{transform:rotate(-18deg)}
6%{transform:rotate(18deg)}
8%{transform:rotate(-22deg)}
10%{transform:rotate(22deg)}
12%{transform:rotate(-18deg)}
14%{transform:rotate(18deg)}
16%{transform:rotate(-12deg)}
18%{transform:rotate(12deg)}
20%,100%{transform:rotate(0deg)}
}
.icss-ring {
animation: ring 2s infinite ease;
}
.icss-ring-hover:hover {
animation: ring 2s infinite ease;
}
/* VERTICAL */
@keyframes vertical {
0%{transform:translate(0,-3px)}
4%{transform:translate(0,3px)}
8%{transform:translate(0,-3px)}
12%{transform:translate(0,3px)}
16%{transform:translate(0,-3px)}
20%{transform:translate(0,3px)}
22%,100%{transform:translate(0,0)}
}
.icss-vibes,
.icss-vibes-hover:hover {
animation: vertical 2s ease infinite;
}
/* HORIZONTAL */
@keyframes horizontal {
0%{transform:translate(0,0)}
6%{transform:translate(5px,0)}
12%{transform:translate(0,0)}
18%{transform:translate(5px,0)}
24%{transform:translate(0,0)}
30%{transform:translate(5px,0)}
36%,100%{transform:translate(0,0)}
}
.icss-shake,
.icss-shake-hover:hover {
animation: horizontal 2s ease infinite;
}
/* TADA */
@keyframes tada {
0% {transform: scale(1)}
10%,20% {transform:scale(.9) rotate(-8deg);}
30%,50%,70% {transform:scale(1.3) rotate(8deg)}
40%,60% {transform:scale(1.3) rotate(-8deg)}
80%,100% {transform:scale(1) rotate(0)}
}
.icss-tada,
.icss-tada-hover:hover{
animation: tada 2s linear infinite;
}
/* Reverse animation */
.icss-reverse {
animation-direction: reverse;
}
[class*="-hover"].icss-reverse:hover {
animation-direction: reverse;
}
/* Stack icons */
.icss-stack {
position: relative;
width: 1em;
height: 1em;
display: inline-block;
}
.icss-stack i[class*="icss-"] {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-.5em, -.5em);
}
.icss-stack i[class*="icss-"].bottom {
bottom: 0;
top: auto;
}
/* shadow icon */
.icss-shadow {
position: relative;
width: 1em;
height: 1em;
display: inline-block;
}
.icss-shadow i[class*="icss-"] {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-.5em, -.5em);
}
.icss-shadow i[class*="icss-"]:first-child {
top: 54%;
left: 54%;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
body, html {
overflow-x: hidden;
}
article p img {
display: block;
margin-top: 0.5rem;
}
article hgroup h3 {
margin-left: 60px;
}
article hgroup footer {
margin-left: 60px;
}
.invisible {
visibility:hidden;
width:0;
height:0;
}
.invisible.width-none{
width: 0px !important;
}
.nav {
z-index: 1050;
}
.captchaInput {
width: 50% !important;
}
/* Fix spacing for Search/Go button and icons */
.icss-search {
margin-right:20px !important;
}
#cookie-law{
background-color: rgb(50, 50, 50) !important;
padding-top: 10px;
padding-bottom: 10px;
margin-bottom: 20px;
}
#cookie-law p{
color: #ffffff;
font-size: 13px;
text-align: center;
background-color: rgb(50, 50, 50) !important;
margin-bottom: 0px;
}
#cookie-law p a[role=button]{
margin: 10px 0px 0px 10px;
padding: 5px 15px;
font-size: 13px;
color: #dcdcdc !important;
border-color: 1px solid var(--primary) !important;
border-radius: 0.25rem !important;
}
/* Footer Style */
footer{
border-top: 1px solid var(--primary);
background-color: rgba(234, 232, 232, 0.1);
font-size: 85%;
}
@media (min-width: 576px) {
footer {
font-size: 83%;
}
}
@media (min-width: 768px) {
footer {
font-size: 81%;
}
}
@media (min-width: 992px) {
footer {
font-size: 79%;
}
}
@media (min-width: 1200px) {
footer {
font-size: 77%;
}
}
body > footer{
display: flex;
flex-direction: row;
justify-content: space-between;
align-content: center;
}
footer .footer-menu{
display: flex;
flex-direction: row;
}
footer .footer-menu a{
margin-right: 10px;
padding-right: 10px;
position: relative;
}
footer .footer-menu a::after{
content: '';
height: 12px;
width: 1px;
background-color: #929292;
position: absolute;
right: 0px;
bottom: 4px;
}
footer .footer-menu a:last-child{
border: none;
padding-right: 0px;
margin-right: 0px;
}
footer .footer-menu a:last-child::after{
content: none;
}
@media (max-width: 576px) {
.toolbar-nav li {
padding: 1rem .2rem;
}
nav{
justify-content: unset;
-webkit-justify-content: unset;
-moz-justify-content: unset;
-ms-justify-content: unset;
}
nav.toolbar-nav ul{
margin-right: 0px;
margin-left: 0px;
display: flex;
display: -moz-box;
display: -webkit-flexbox;
display: -ms-flexbox;
display: -webkit-flex;
display: -moz-flex;
flex-direction: row;
-webkit-flex-direction: row;
-moz-flex-direction: row;
-ms-flex-direction: row;
justify-content: space-between;
-webkit-justify-content: space-between;
-moz-justify-content: space-between;
-ms-justify-content: space-between;
width: 100%;
}
nav.toolbar-nav ul li{
padding: 0.5rem 0px;
flex: 1;
-webkit-flex: 0 1 100%;
-moz-flex: 0 1 100%;
-ms-flex: 0 1 100%;
text-align: center;
}
[data-tooltip]:not(a):not(button):not(input){
border-bottom: none;
}
#cookie-law p a[role=button]{
display: block;
width: 20%;
margin: 10px auto 0px;
padding: 5px;
}
nav.pagination-nav{
justify-content: space-between;
}
}
@media (max-width: 1200px) {
/* Footer Style */
body > footer{
text-align: center;
flex-direction: column-reverse;
}
footer .footer-copyright{
margin-top: 10px;
}
footer .footer-menu{
justify-content: center;
}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 369.9 220.9" viewBox="0 0 369.9 220.9" xmlns="http://www.w3.org/2000/svg"><path d="m73.1 156.6v-93.3h-48.4v-22.2h122.8v22.2h-48.5v93.3z"/><path d="m320.7 64.8v-25.9h-25.7v25.9h-4.8l-9.3 20.5h14.1v71.3h25.7v-71.3h29v-20.5z"/><path d="m277.2 64.8h-17l-27.8 61.8-24.9-54.9c-1.5-3.3-3.2-5.7-5.2-7.2s-4.9-2.3-8.5-2.3c-3.7 0-6.6.8-8.6 2.5-2 1.6-3.8 4-5.3 7l-26.2 54.9-22.6-53h-25.5l32.9 76.3c1.2 2.8 2.8 5.1 4.8 6.7s4.9 2.5 8.7 2.5c3.4 0 6.2-.8 8.6-2.5 2.3-1.6 4.1-3.9 5.5-6.7l27.3-56.6 25.9 56.6c1.2 2.6 3 4.8 5.3 6.5 2.3 1.8 5.1 2.6 8.4 2.6 3.4 0 6.2-.9 8.6-2.6 2.3-1.7 4.1-3.9 5.3-6.5l29.1-64.6 9.2-20.5z"/><path d="m128.5 166.1v9.5h-1c-.8-3-1.8-5-3-6.1s-2.8-1.6-4.6-1.6c-1.4 0-2.6.4-3.4 1.1-.9.8-1.3 1.6-1.3 2.5 0 1.1.3 2.1 1 2.9.6.8 1.9 1.7 3.9 2.7l4.4 2.2c4.1 2 6.2 4.6 6.2 8 0 2.5-1 4.6-2.9 6.1-1.9 1.6-4.1 2.3-6.5 2.3-1.7 0-3.6-.3-5.8-.9-.7-.2-1.2-.3-1.7-.3s-.8.3-1.1.8h-1v-10h1c.6 2.9 1.7 5 3.3 6.4s3.4 2.2 5.4 2.2c1.4 0 2.5-.4 3.4-1.2s1.3-1.8 1.3-3c0-1.4-.5-2.6-1.5-3.5s-3-2.2-5.9-3.6c-2.9-1.5-4.9-2.8-5.8-4s-1.4-2.6-1.4-4.4c0-2.3.8-4.2 2.4-5.8 1.6-1.5 3.6-2.3 6.1-2.3 1.1 0 2.5.2 4 .7 1 .3 1.7.5 2 .5s.6-.1.8-.2.4-.4.7-.9h1z"/><path d="m152.3 166.1c4.2 0 7.6 1.6 10.2 4.8 2.2 2.8 3.3 5.9 3.3 9.5 0 2.5-.6 5-1.8 7.6s-2.8 4.5-4.9 5.8-4.4 2-7.1 2c-4.2 0-7.5-1.7-10-5-2.1-2.8-3.1-6-3.1-9.5 0-2.6.6-5.1 1.9-7.7s2.9-4.4 5-5.6c2-1.3 4.2-1.9 6.5-1.9zm-.9 2c-1.1 0-2.1.3-3.2 1-1.1.6-2 1.8-2.7 3.4s-1 3.7-1 6.2c0 4.1.8 7.6 2.4 10.5s3.8 4.4 6.4 4.4c2 0 3.6-.8 4.9-2.4s1.9-4.4 1.9-8.4c0-5-1.1-8.9-3.2-11.8-1.4-1.9-3.3-2.9-5.5-2.9z"/><path d="m197.5 184.3c-.8 3.7-2.2 6.5-4.4 8.5s-4.6 3-7.3 3c-3.2 0-6-1.3-8.3-4-2.4-2.7-3.5-6.3-3.5-10.8 0-4.4 1.3-8 3.9-10.7s5.8-4.1 9.4-4.1c2.8 0 5 .7 6.8 2.2s2.7 3 2.7 4.5c0 .8-.3 1.4-.8 1.9s-1.2.7-2.1.7c-1.2 0-2.1-.4-2.7-1.1-.3-.4-.5-1.2-.7-2.4-.1-1.2-.5-2.1-1.2-2.7s-1.7-.9-3-.9c-2 0-3.7.7-4.9 2.2-1.6 2-2.5 4.6-2.5 7.9s.8 6.3 2.4 8.8c1.6 2.6 3.8 3.8 6.7 3.8 2 0 3.8-.7 5.3-2 1.1-.9 2.2-2.6 3.3-5.1z"/><path d="m215.1 166.1v22.4c0 1.8.1 2.9.4 3.5s.6 1 1.1 1.3 1.4.4 2.7.4v1.1h-13.6v-1.1c1.4 0 2.3-.1 2.7-.4.5-.3.8-.7 1.1-1.3s.4-1.8.4-3.5v-10.8c0-3-.1-5-.3-5.9-.2-.7-.4-1.1-.7-1.4s-.7-.4-1.3-.4-1.2.2-2 .5l-.5-1.1 8.4-3.4h1.6zm-2.6-14.6c.9 0 1.6.3 2.2.9s.9 1.3.9 2.2c0 .8-.3 1.6-.9 2.2s-1.3.9-2.2.9-1.6-.3-2.2-.9-.9-1.3-.9-2.2.3-1.6.9-2.2 1.3-.9 2.2-.9z"/><path d="m242.5 190.9c-2.9 2.2-4.7 3.5-5.4 3.8-1.1.5-2.3.8-3.5.8-1.9 0-3.5-.7-4.8-2s-1.9-3.1-1.9-5.2c0-1.4.3-2.5.9-3.5.8-1.4 2.3-2.7 4.3-3.9 2.1-1.2 5.5-2.7 10.3-4.5v-1.1c0-2.8-.4-4.7-1.3-5.7s-2.2-1.6-3.9-1.6c-1.3 0-2.3.3-3 1-.8.7-1.2 1.5-1.2 2.4l.1 1.8c0 .9-.2 1.7-.7 2.2s-1.1.8-1.9.8-1.4-.3-1.9-.8-.7-1.3-.7-2.2c0-1.7.9-3.3 2.7-4.8s4.3-2.2 7.5-2.2c2.5 0 4.5.4 6.1 1.3 1.2.6 2.1 1.6 2.7 3 .4.9.5 2.7.5 5.4v9.5c0 2.7.1 4.3.2 4.9s.3 1 .5 1.2.5.3.8.3.6-.1.8-.2c.4-.3 1.3-1 2.5-2.2v1.7c-2.3 3-4.5 4.5-6.6 4.5-1 0-1.8-.3-2.4-1-.4-.9-.7-2-.7-3.7zm0-2v-10.7c-3.1 1.2-5 2.1-6 2.6-1.6.9-2.7 1.8-3.4 2.8s-1 2-1 3.2c0 1.5.4 2.7 1.3 3.7s1.9 1.5 3 1.5c1.5-.1 3.6-1.1 6.1-3.1z"/><path d="m267.9 151.5v37.1c0 1.8.1 2.9.4 3.5s.6 1 1.2 1.3c.5.3 1.5.4 3 .4v1.1h-13.7v-1.1c1.3 0 2.2-.1 2.6-.4.5-.3.8-.7 1.1-1.3s.4-1.8.4-3.5v-25.4c0-3.2-.1-5.1-.2-5.8s-.4-1.2-.7-1.5-.7-.4-1.2-.4-1.2.2-2 .5l-.5-1.1 8.3-3.4z"/><path d="m102.6 186.7c1.3 0 2.4.4 3.2 1.3.9.9 1.3 1.9 1.3 3.2 0 1.2-.4 2.3-1.3 3.2s-1.9 1.3-3.2 1.3c-1.2 0-2.3-.4-3.2-1.3s-1.3-2-1.3-3.2c0-1.3.4-2.3 1.3-3.2.9-.8 2-1.3 3.2-1.3z"/></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

View File

@@ -0,0 +1,797 @@
// Umbrella JS http://umbrellajs.com/
// -----------
// Small, lightweight jQuery alternative
// @author Francisco Presencia Fandos https://francisco.io/
// @inspiration http://youmightnotneedjquery.com/
// Initialize the library
var u = function(parameter, context) {
// Make it an instance of u() to avoid needing 'new' as in 'new u()' and just
// use 'u().bla();'.
// @reference http://stackoverflow.com/q/24019863
// @reference http://stackoverflow.com/q/8875878
if (!(this instanceof u)) {
return new u(parameter, context);
}
// No need to further processing it if it's already an instance
if (parameter instanceof u) {
return parameter;
}
// Parse it as a CSS selector if it's a string
if (typeof parameter === 'string') {
parameter = this.select(parameter, context);
}
// If we're referring a specific node as in on('click', function(){ u(this) })
// or the select() function returned a single node such as in '#id'
if (parameter && parameter.nodeName) {
parameter = [parameter];
}
// Convert to an array, since there are many 'array-like' stuff in js-land
this.nodes = this.slice(parameter);
};
// Map u(...).length to u(...).nodes.length
u.prototype = {
get length() {
return this.nodes.length;
}
};
// This made the code faster, read "Initializing instance variables" in
// https://developers.google.com/speed/articles/optimizing-javascript
u.prototype.nodes = [];
// Add class(es) to the matched nodes
u.prototype.addClass = function() {
return this.eacharg(arguments, function(el, name) {
el.classList.add(name);
});
};
// [INTERNAL USE ONLY]
// Add text in the specified position. It is used by other functions
u.prototype.adjacent = function(html, data, callback) {
if (typeof data === 'number') {
if (data === 0) {
data = [];
} else {
data = new Array(data).join().split(',').map(Number.call, Number);
}
}
// Loop through all the nodes. It cannot reuse the eacharg() since the data
// we want to do it once even if there's no "data" and we accept a selector
return this.each(function(node, j) {
var fragment = document.createDocumentFragment();
// Allow for data to be falsy and still loop once
u(data || {}).map(function(el, i) {
// Allow for callbacks that accept some data
var part = (typeof html === 'function') ? html.call(this, el, i, node, j) : html;
if (typeof part === 'string') {
return this.generate(part);
}
return u(part);
}).each(function(n) {
this.isInPage(n) ?
fragment.appendChild(u(n).clone().first()) :
fragment.appendChild(n);
});
callback.call(this, node, fragment);
});
};
// Add some html as a sibling after each of the matched elements.
u.prototype.after = function(html, data) {
return this.adjacent(html, data, function(node, fragment) {
node.parentNode.insertBefore(fragment, node.nextSibling);
});
};
// Add some html as a child at the end of each of the matched elements.
u.prototype.append = function(html, data) {
return this.adjacent(html, data, function(node, fragment) {
node.appendChild(fragment);
});
};
// [INTERNAL USE ONLY]
// Normalize the arguments to an array of strings
// Allow for several class names like "a b, c" and several parameters
u.prototype.args = function(args, node, i) {
if (typeof args === 'function') {
args = args(node, i);
}
// First flatten it all to a string http://stackoverflow.com/q/22920305
// If we try to slice a string bad things happen: ['n', 'a', 'm', 'e']
if (typeof args !== 'string') {
args = this.slice(args).map(this.str(node, i));
}
// Then convert that string to an array of not-null strings
return args.toString().split(/[\s,]+/).filter(function(e) {
return e.length;
});
};
// Merge all of the nodes that the callback return into a simple array
u.prototype.array = function(callback) {
callback = callback;
var self = this;
return this.nodes.reduce(function(list, node, i) {
var val;
if (callback) {
val = callback.call(self, node, i);
if (!val) val = false;
if (typeof val === 'string') val = u(val);
if (val instanceof u) val = val.nodes;
} else {
val = node.innerHTML;
}
return list.concat(val !== false ? val : []);
}, []);
};
// [INTERNAL USE ONLY]
// Handle attributes for the matched elements
u.prototype.attr = function(name, value, data) {
data = data ? 'data-' : '';
// This will handle those elements that can accept a pair with these footprints:
// .attr('a'), .attr('a', 'b'), .attr({ a: 'b' })
return this.pairs(name, value, function(node, name) {
return node.getAttribute(data + name);
}, function(node, name, value) {
node.setAttribute(data + name, value);
});
};
// Add some html before each of the matched elements.
u.prototype.before = function(html, data) {
return this.adjacent(html, data, function(node, fragment) {
node.parentNode.insertBefore(fragment, node);
});
};
// Get the direct children of all of the nodes with an optional filter
u.prototype.children = function(selector) {
return this.map(function(node) {
return this.slice(node.children);
}).filter(selector);
};
/**
* Deep clone a DOM node and its descendants.
* @return {[Object]} Returns an Umbrella.js instance.
*/
u.prototype.clone = function() {
return this.map(function(node, i) {
var clone = node.cloneNode(true);
var dest = this.getAll(clone);
this.getAll(node).each(function(src, i) {
for (var key in this.mirror) {
if (this.mirror[key]) {
this.mirror[key](src, dest.nodes[i]);
}
}
});
return clone;
});
};
/**
* Return an array of DOM nodes of a source node and its children.
* @param {[Object]} context DOM node.
* @param {[String]} tag DOM node tagName.
* @return {[Array]} Array containing queried DOM nodes.
*/
u.prototype.getAll = function getAll(context) {
return u([context].concat(u('*', context).nodes));
};
// Store all of the operations to perform when cloning elements
u.prototype.mirror = {};
/**
* Copy all JavaScript events of source node to destination node.
* @param {[Object]} source DOM node
* @param {[Object]} destination DOM node
* @return {[undefined]]}
*/
u.prototype.mirror.events = function(src, dest) {
if (!src._e) return;
for (var type in src._e) {
src._e[type].forEach(function(ref) {
u(dest).on(type, ref.callback);
});
}
};
/**
* Copy select input value to its clone.
* @param {[Object]} src DOM node
* @param {[Object]} dest DOM node
* @return {[undefined]}
*/
u.prototype.mirror.select = function(src, dest) {
if (u(src).is('select')) {
dest.value = src.value;
}
};
/**
* Copy textarea input value to its clone
* @param {[Object]} src DOM node
* @param {[Object]} dest DOM node
* @return {[undefined]}
*/
u.prototype.mirror.textarea = function(src, dest) {
if (u(src).is('textarea')) {
dest.value = src.value;
}
};
// Find the first ancestor that matches the selector for each node
u.prototype.closest = function(selector) {
return this.map(function(node) {
// Keep going up and up on the tree. First element is also checked
do {
if (u(node).is(selector)) {
return node;
}
} while ((node = node.parentNode) && node !== document);
});
};
// Handle data-* attributes for the matched elements
u.prototype.data = function(name, value) {
return this.attr(name, value, true);
};
// Loops through every node from the current call
u.prototype.each = function(callback) {
// By doing callback.call we allow "this" to be the context for
// the callback (see http://stackoverflow.com/q/4065353 precisely)
this.nodes.forEach(callback.bind(this));
return this;
};
// [INTERNAL USE ONLY]
// Loop through the combination of every node and every argument passed
u.prototype.eacharg = function(args, callback) {
return this.each(function(node, i) {
this.args(args, node, i).forEach(function(arg) {
// Perform the callback for this node
// By doing callback.call we allow "this" to be the context for
// the callback (see http://stackoverflow.com/q/4065353 precisely)
callback.call(this, node, arg);
}, this);
});
};
// Remove all children of the matched nodes from the DOM.
u.prototype.empty = function() {
return this.each(function(node) {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
});
};
// .filter(selector)
// Delete all of the nodes that don't pass the selector
u.prototype.filter = function(selector) {
// The default function if it's a CSS selector
// Cannot change name to 'selector' since it'd mess with it inside this fn
var callback = function(node) {
// Make it compatible with some other browsers
node.matches = node.matches || node.msMatchesSelector || node.webkitMatchesSelector;
// Check if it's the same element (or any element if no selector was passed)
return node.matches(selector || '*');
};
// filter() receives a function as in .filter(e => u(e).children().length)
if (typeof selector === 'function') callback = selector;
// filter() receives an instance of Umbrella as in .filter(u('a'))
if (selector instanceof u) {
callback = function(node) {
return (selector.nodes).indexOf(node) !== -1;
};
}
// Just a native filtering function for ultra-speed
return u(this.nodes.filter(callback));
};
// Find all the nodes children of the current ones matched by a selector
u.prototype.find = function(selector) {
return this.map(function(node) {
return u(selector || '*', node);
});
};
// Get the first of the nodes
u.prototype.first = function() {
return this.nodes[0] || false;
};
// [INTERNAL USE ONLY]
// Generate a fragment of HTML. This irons out the inconsistences
u.prototype.generate = function(html) {
// Table elements need to be child of <table> for some f***ed up reason
if (/^\s*<tr[> ]/.test(html)) {
return u(document.createElement('table')).html(html).children().children().nodes;
} else if (/^\s*<t(h|d)[> ]/.test(html)) {
return u(document.createElement('table')).html(html).children().children().children().nodes;
} else if (/^\s*</.test(html)) {
return u(document.createElement('div')).html(html).children().nodes;
} else {
return document.createTextNode(html);
}
};
// Change the default event for the callback. Simple decorator to preventDefault
u.prototype.handle = function() {
var args = this.slice(arguments).map(function(arg) {
if (typeof arg === 'function') {
return function(e) {
e.preventDefault();
arg.apply(this, arguments);
};
}
return arg;
}, this);
return this.on.apply(this, args);
};
// Find out whether the matched elements have a class or not
u.prototype.hasClass = function() {
// Check if any of them has all of the classes
return this.is('.' + this.args(arguments).join('.'));
};
// Set or retrieve the html from the matched node(s)
u.prototype.html = function(text) {
// Needs to check undefined as it might be ""
if (text === undefined) {
return this.first().innerHTML || '';
}
// If we're attempting to set some text
// Loop through all the nodes
return this.each(function(node) {
// Set the inner html to the node
node.innerHTML = text;
});
};
// Check whether any of the nodes matches the selector
u.prototype.is = function(selector) {
return this.filter(selector).length > 0;
};
/**
* Internal use only. This function checks to see if an element is in the page's body. As contains is inclusive and determining if the body contains itself isn't the intention of isInPage this case explicitly returns false.
https://developer.mozilla.org/en-US/docs/Web/API/Node/contains
* @param {[Object]} node DOM node
* @return {Boolean} The Node.contains() method returns a Boolean value indicating whether a node is a descendant of a given node or not.
*/
u.prototype.isInPage = function isInPage(node) {
return (node === document.body) ? false : document.body.contains(node);
};
// Get the last of the nodes
u.prototype.last = function() {
return this.nodes[this.length - 1] || false;
};
// Merge all of the nodes that the callback returns
u.prototype.map = function(callback) {
return callback ? u(this.array(callback)).unique() : this;
};
// Delete all of the nodes that equals the filter
u.prototype.not = function(filter) {
return this.filter(function(node) {
return !u(node).is(filter || true);
});
};
// Removes the callback to the event listener for each node
u.prototype.off = function(events, cb, cb2) {
var cb_filter_off = (cb == null && cb2 == null);
var sel = null;
var cb_to_be_removed = cb;
if (typeof cb === 'string') {
sel = cb;
cb_to_be_removed = cb2;
}
return this.eacharg(events, function(node, event) {
u(node._e ? node._e[event] : []).each(function(ref) {
if (cb_filter_off || (ref.orig_callback === cb_to_be_removed && ref.selector === sel)) {
node.removeEventListener(event, ref.callback);
}
});
});
};
// Attach a callback to the specified events
u.prototype.on = function(events, cb, cb2) {
var sel = null;
var orig_callback = cb;
if (typeof cb === 'string') {
sel = cb;
orig_callback = cb2;
cb = function(e) {
var args = arguments;
var targetFound = false;
u(e.currentTarget)
.find(sel)
.each(function(target) {
if (target === e.target || target.contains(e.target)) {
targetFound = true;
try {
Object.defineProperty(e, 'currentTarget', {
get: function() {
return target;
}
});
} catch (err) {}
cb2.apply(target, args);
}
});
// due to e.currentEvent reassigning a second (or subsequent) handler may
// not be fired for a single event, so chekc and apply if needed.
if (!targetFound && e.currentTarget === e.target) {
cb2.apply(e.target, args);
}
};
}
// Add the custom data as arguments to the callback
var callback = function(e) {
return cb.apply(this, [e].concat(e.detail || []));
};
return this.eacharg(events, function(node, event) {
node.addEventListener(event, callback);
// Store it so we can dereference it with `.off()` later on
node._e = node._e || {};
node._e[event] = node._e[event] || [];
node._e[event].push({
callback: callback,
orig_callback: orig_callback,
selector: sel
});
});
};
// [INTERNAL USE ONLY]
// Take the arguments and a couple of callback to handle the getter/setter pairs
// such as: .css('a'), .css('a', 'b'), .css({ a: 'b' })
u.prototype.pairs = function(name, value, get, set) {
// Convert it into a plain object if it is not
if (typeof value !== 'undefined') {
var nm = name;
name = {};
name[nm] = value;
}
if (typeof name === 'object') {
// Set the value of each one, for each of the { prop: value } pairs
return this.each(function(node) {
for (var key in name) {
set(node, key, name[key]);
}
});
}
// Return the style of the first one
return this.length ? get(this.first(), name) : '';
};
// [INTERNAL USE ONLY]
// Parametize an object: { a: 'b', c: 'd' } => 'a=b&c=d'
u.prototype.param = function(obj) {
return Object.keys(obj).map(function(key) {
return this.uri(key) + '=' + this.uri(obj[key]);
}.bind(this)).join('&');
};
// Travel the matched elements one node up
u.prototype.parent = function(selector) {
return this.map(function(node) {
return node.parentNode;
}).filter(selector);
};
// Add nodes at the beginning of each node
u.prototype.prepend = function(html, data) {
return this.adjacent(html, data, function(node, fragment) {
node.insertBefore(fragment, node.firstChild);
});
};
// Delete the matched nodes from the DOM
u.prototype.remove = function() {
// Loop through all the nodes
return this.each(function(node) {
// Perform the removal only if the node has a parent
if (node.parentNode) {
node.parentNode.removeChild(node);
}
});
};
// Removes a class from all of the matched nodes
u.prototype.removeClass = function() {
// Loop the combination of each node with each argument
return this.eacharg(arguments, function(el, name) {
// Remove the class using the native method
el.classList.remove(name);
});
};
// Replace the matched elements with the passed argument.
u.prototype.replace = function(html, data) {
var nodes = [];
this.adjacent(html, data, function(node, fragment) {
nodes = nodes.concat(this.slice(fragment.children));
node.parentNode.replaceChild(fragment, node);
});
return u(nodes);
};
// Scroll to the first matched element
u.prototype.scroll = function() {
this.first().scrollIntoView({
behavior: 'smooth'
});
return this;
};
// [INTERNAL USE ONLY]
// Select the adequate part from the context
u.prototype.select = function(parameter, context) {
// Allow for spaces before or after
parameter = parameter.replace(/^\s*/, '').replace(/\s*$/, '');
if (/^</.test(parameter)) {
return u().generate(parameter);
}
return (context || document).querySelectorAll(parameter);
};
// Convert forms into a string able to be submitted
// Original source: http://stackoverflow.com/q/11661187
u.prototype.serialize = function() {
var self = this;
// Store the class in a variable for manipulation
return this.slice(this.first().elements).reduce(function(query, el) {
// We only want to match enabled elements with names, but not files
if (!el.name || el.disabled || el.type === 'file') return query;
// Ignore the checkboxes that are not checked
if (/(checkbox|radio)/.test(el.type) && !el.checked) return query;
// Handle multiple selects
if (el.type === 'select-multiple') {
u(el.options).each(function(opt) {
if (opt.selected) {
query += '&' + self.uri(el.name) + '=' + self.uri(opt.value);
}
});
return query;
}
// Add the element to the object
return query + '&' + self.uri(el.name) + '=' + self.uri(el.value);
}, '').slice(1);
};
// Travel the matched elements at the same level
u.prototype.siblings = function(selector) {
return this.parent().children(selector).not(this);
};
// Find the size of the first matched element
u.prototype.size = function() {
return this.first().getBoundingClientRect();
};
// [INTERNAL USE ONLY]
// Force it to be an array AND also it clones them
// http://toddmotto.com/a-comprehensive-dive-into-nodelists-arrays-converting-nodelists-and-understanding-the-dom/
u.prototype.slice = function(pseudo) {
// Check that it's not a valid object
if (!pseudo ||
pseudo.length === 0 ||
typeof pseudo === 'string' ||
pseudo.toString() === '[object Function]') return [];
// Accept also a u() object (that has .nodes)
return pseudo.length ? [].slice.call(pseudo.nodes || pseudo) : [pseudo];
};
// [INTERNAL USE ONLY]
// Create a string from different things
u.prototype.str = function(node, i) {
return function(arg) {
// Call the function with the corresponding nodes
if (typeof arg === 'function') {
return arg.call(this, node, i);
}
// From an array or other 'weird' things
return arg.toString();
};
};
// Set or retrieve the text content from the matched node(s)
u.prototype.text = function(text) {
// Needs to check undefined as it might be ""
if (text === undefined) {
return this.first().textContent || '';
}
// If we're attempting to set some text
// Loop through all the nodes
return this.each(function(node) {
// Set the text content to the node
node.textContent = text;
});
};
// Activate/deactivate classes in the elements
u.prototype.toggleClass = function(classes, addOrRemove) {
/* jshint -W018 */
// Check if addOrRemove was passed as a boolean
if (!!addOrRemove === addOrRemove) {
return this[addOrRemove ? 'addClass' : 'removeClass'](classes);
}
/* jshint +W018 */
// Loop through all the nodes and classes combinations
return this.eacharg(classes, function(el, name) {
el.classList.toggle(name);
});
};
// Call an event manually on all the nodes
u.prototype.trigger = function(events) {
var data = this.slice(arguments).slice(1);
return this.eacharg(events, function(node, event) {
var ev;
// Allow the event to bubble up and to be cancelable (as default)
var opts = {
bubbles: true,
cancelable: true,
detail: data
};
try {
// Accept different types of event names or an event itself
ev = new window.CustomEvent(event, opts);
} catch (e) {
ev = document.createEvent('CustomEvent');
ev.initCustomEvent(event, true, true, data);
}
node.dispatchEvent(ev);
});
};
// [INTERNAL USE ONLY]
// Removed duplicated nodes, used for some specific methods
u.prototype.unique = function() {
return u(this.nodes.reduce(function(clean, node) {
var istruthy = node !== null && node !== undefined && node !== false;
return (istruthy && clean.indexOf(node) === -1) ? clean.concat(node) : clean;
}, []));
};
// [INTERNAL USE ONLY]
// Encode the different strings https://gist.github.com/brettz9/7147458
u.prototype.uri = function(str) {
return encodeURIComponent(str).replace(/!/g, '%21').replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29').replace(/\*/g, '%2A').replace(/%20/g, '+');
};
u.prototype.wrap = function(selector) {
function findDeepestNode(node) {
while (node.firstElementChild) {
node = node.firstElementChild;
}
return u(node);
}
// 1) Construct dom node e.g. u('<a>'),
// 2) clone the currently matched node
// 3) append cloned dom node to constructed node based on selector
return this.map(function(node) {
return u(selector).each(function(n) {
findDeepestNode(n)
.append(node.cloneNode(true));
node
.parentNode
.replaceChild(n, node);
});
});
};
// Export it for webpack
if (typeof module === 'object' && module.exports) {
// Avoid breaking it for `import { u } from ...`. Add `import u from ...`
module.exports = u;
module.exports.u = u;
}

View File

@@ -0,0 +1,70 @@
(function () {
if (typeof window.Element === "undefined" || "classList" in document.documentElement) return;
var prototype = Array.prototype,
push = prototype.push,
splice = prototype.splice,
join = prototype.join;
function DOMTokenList(el) {
this.el = el;
// The className needs to be trimmed and split on whitespace
// to retrieve a list of classes.
var classes = el.className.replace(/^\s+|\s+$/g,'').split(/\s+/);
for (var i = 0; i < classes.length; i++) {
push.call(this, classes[i]);
}
};
DOMTokenList.prototype = {
add: function(token) {
if(this.contains(token)) return;
push.call(this, token);
this.el.className = this.toString();
},
contains: function(token) {
return this.el.className.indexOf(token) != -1;
},
item: function(index) {
return this[index] || null;
},
remove: function(token) {
if (!this.contains(token)) return;
for (var i = 0; i < this.length; i++) {
if (this[i] == token) break;
}
splice.call(this, i, 1);
this.el.className = this.toString();
},
toString: function() {
return join.call(this, ' ');
},
toggle: function(token) {
if (!this.contains(token)) {
this.add(token);
} else {
this.remove(token);
}
return this.contains(token);
}
};
window.DOMTokenList = DOMTokenList;
function defineElementGetter (obj, prop, getter) {
if (Object.defineProperty) {
Object.defineProperty(obj, prop,{
get : getter
});
} else {
obj.__defineGetter__(prop, getter);
}
}
defineElementGetter(Element.prototype, 'classList', function () {
return new DOMTokenList(this);
});
})();

View File

@@ -0,0 +1,122 @@
/*
* Twix v1.0 - a lightweight library for making AJAX requests.
* Author: Neil Cowburn (neilco@gmail.com)
*
* Copyright (c) 2013 Neil Cowburn (http://github.com/neilco/)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
var Twix = (function () {
function Twix() { }
Twix.ajax = function(options) {
options = options || {url:""};
options.type = options.type.toUpperCase() || 'GET';
options.headers = options.headers || {};
options.timeout = parseInt(options.timeout) || 0;
options.success = options.success || function() {};
options.error = options.error || function() {};
options.async = typeof options.async === 'undefined' ? true : options.async;
var client = new XMLHttpRequest();
if (options.timeout > 0) {
client.timeout = options.timeout;
client.ontimeout = function () {
options.error('timeout', 'timeout', client);
};
}
client.open(options.type, options.url, options.async);
for (var i in options.headers) {
if (options.headers.hasOwnProperty(i)) {
client.setRequestHeader(i, options.headers[i]);
}
}
client.send(options.data);
client.onreadystatechange = function() {
if (this.readyState == 4 && ((this.status >= 200 && this.status < 300) || this.status == 304)) {
var data = this.responseText;
var contentType = this.getResponseHeader('Content-Type');
if (contentType && contentType.match(/json/)) {
data = JSON.parse(this.responseText);
}
options.success(data, this.statusText, this);
} else if (this.readyState == 4) {
options.error(this.status, this.statusText, this);
}
};
if (options.async == false) {
if (client.readyState == 4 && ((client.status >= 200 && client.status < 300) || client.status == 304)) {
options.success(client.responseText, client);
} else if (client.readyState == 4) {
options.error(client.status, client.statusText, client);
}
}
return client;
};
var _ajax = function(type, url, data, callback) {
if (typeof data === "function") {
callback = data;
data = undefined;
}
return Twix.ajax({
url: url,
data: data,
type: type,
success: callback
});
};
Twix.get = function(url, data, callback) {
return _ajax("GET", url, data, callback);
};
Twix.head = function(url, data, callback) {
return _ajax("HEAD", url, data, callback);
};
Twix.post = function(url, data, callback) {
return _ajax("POST", url, data, callback);
};
Twix.patch = function(url, data, callback) {
return _ajax("PATCH", url, data, callback);
};
Twix.put = function(url, data, callback) {
return _ajax("PUT", url, data, callback);
};
Twix.delete = function(url, data, callback) {
return _ajax("DELETE", url, data, callback);
};
Twix.options = function(url, data, callback) {
return _ajax("OPTIONS", url, data, callback);
};
return Twix;
})();
__ = Twix;

View File

@@ -0,0 +1,61 @@
// Creare's 'Implied Consent' EU Cookie Law Banner v:2.4
// Conceived by Robert Kent, James Bavington & Tom Foyster
var dropCookie = true; // false disables the Cookie, allowing you to style the banner
var cookieDuration = 14; // Number of days before the cookie expires, and the banner reappears
var cookieName = "complianceCookie"; // Name of our cookie
var cookieValue = "on"; // Value of cookie
function createDiv() {
u("body").prepend(
'<div id="cookie-law" class="container-fluid"><p>This website uses cookies. By continuing we assume your permission to deploy cookies, as detailed in our <a href="/privacy" rel="nofollow" title="Privacy Policy">privacy policy</a>. <a role="button" href="javascript:void(0);" onclick="removeMe();">Close</a></p></div>'
);
createCookie(window.cookieName, window.cookieValue, window.cookieDuration); // Create the cookie
}
function createCookie(name, value, days) {
if (days) {
var date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
var expires = "; expires=" + date.toGMTString();
} else var expires = "";
if (window.dropCookie) {
document.cookie = name + "=" + value + expires + "; path=/";
}
}
function checkCookie(name) {
var nameEQ = name + "=";
var ca = document.cookie.split(";");
for (var i = 0; i < ca.length; i++) {
var c = ca[i];
while (c.charAt(0) == " ") c = c.substring(1, c.length);
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
}
return null;
}
function eraseCookie(name) {
createCookie(name, "", -1);
}
window.onload = function() {
if (checkCookie(window.cookieName) != window.cookieValue) {
createDiv();
}
};
function removeMe() {
var element = document.getElementById("cookie-law");
element.parentNode.removeChild(element);
}
u("#burgerMenu").on("click", function(e) {
e.preventDefault();
if (u("#mainNav").hasClass("responsive")) {
u("#mainNav").removeClass("responsive");
} else {
u("#mainNav").addClass("responsive");
}
});

44
internal/stats.go Normal file
View File

@@ -0,0 +1,44 @@
package internal
import (
"expvar"
"runtime"
"time"
)
// var (
// stats *expvar.Map
// )
// func init() {
// stats = NewStats("stats")
// }
// TimeVar ...
type TimeVar struct{ v time.Time }
// Set ...
func (o *TimeVar) Set(date time.Time) { o.v = date }
// Add ...
func (o *TimeVar) Add(duration time.Duration) { o.v = o.v.Add(duration) }
// String ...
func (o *TimeVar) String() string { return o.v.Format(time.RFC3339) }
// NewStats ...
func NewStats(name string) *expvar.Map {
stats := expvar.NewMap(name)
stats.Set("goroutines", expvar.Func(func() interface{} {
return runtime.NumGoroutine()
}))
stats.Set("cgocall", expvar.Func(func() interface{} {
return runtime.NumCgoCall()
}))
stats.Set("cpus", expvar.Func(func() interface{} {
return runtime.NumCPU()
}))
return stats
}

65
internal/store.go Normal file
View File

@@ -0,0 +1,65 @@
package internal
import (
"errors"
"fmt"
"git.mills.io/prologic/spyda/internal/session"
)
var (
ErrInvalidStore = errors.New("error: invalid store")
ErrUserNotFound = errors.New("error: user not found")
ErrTokenNotFound = errors.New("error: token not found")
ErrFeedNotFound = errors.New("error: feed not found")
ErrInvalidSession = errors.New("error: invalid session")
)
type Store interface {
Merge() error
Close() error
Sync() error
DelFeed(name string) error
HasFeed(name string) bool
GetFeed(name string) (*Feed, error)
SetFeed(name string, user *Feed) error
LenFeeds() int64
SearchFeeds(prefix string) []string
GetAllFeeds() ([]*Feed, error)
DelUser(username string) error
HasUser(username string) bool
GetUser(username string) (*User, error)
SetUser(username string, user *User) error
LenUsers() int64
SearchUsers(prefix string) []string
GetAllUsers() ([]*User, error)
GetSession(sid string) (*session.Session, error)
SetSession(sid string, sess *session.Session) error
HasSession(sid string) bool
DelSession(sid string) error
SyncSession(sess *session.Session) error
LenSessions() int64
GetAllSessions() ([]*session.Session, error)
GetUserTokens(user *User) ([]*Token, error)
SetToken(signature string, token *Token) error
DelToken(signature string) error
LenTokens() int64
}
func NewStore(store string) (Store, error) {
u, err := ParseURI(store)
if err != nil {
return nil, fmt.Errorf("error parsing store uri: %s", err)
}
switch u.Type {
case "bitcask":
return newBitcaskStore(u.Path)
default:
return nil, ErrInvalidStore
}
}

View File

@@ -0,0 +1,180 @@
package internal
import (
"fmt"
"net/http"
"strings"
"git.mills.io/prologic/spyda/internal/session"
"github.com/julienschmidt/httprouter"
log "github.com/sirupsen/logrus"
"github.com/steambap/captcha"
)
// CaptchaHandler ...
func (s *Server) CaptchaHandler() httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
img, err := captcha.NewMathExpr(150, 50)
if err != nil {
log.WithError(err).Errorf("unable to get generate captcha image")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Save captcha text in session
sess := r.Context().Value(session.SessionKey)
if sess == nil {
log.Warn("no session found")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
_ = sess.(*session.Session).Set("captchaText", img.Text)
w.Header().Set("Content-Type", "image/png")
if err := img.WriteImage(w); err != nil {
log.WithError(err).Errorf("error sending captcha image response")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}
// SupportHandler ...
func (s *Server) SupportHandler() httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
ctx := NewContext(s.config, s.db, r)
if r.Method == "GET" {
ctx.Title = "Contact support"
s.render("support", w, ctx)
return
}
name := strings.TrimSpace(r.FormValue("name"))
email := strings.TrimSpace(r.FormValue("email"))
subject := strings.TrimSpace(r.FormValue("subject"))
message := strings.TrimSpace(r.FormValue("message"))
captchaInput := strings.TrimSpace(r.FormValue("captchaInput"))
// Get session
sess := r.Context().Value(session.SessionKey)
if sess == nil {
log.Warn("no session found")
ctx.Error = true
ctx.Message = "no session found, do you have cookies disabled?"
s.render("error", w, ctx)
return
}
// Get captcha text from session
captchaText, isCaptchaTextAvailable := sess.(*session.Session).Get("captchaText")
if !isCaptchaTextAvailable {
log.Warn("no captcha provided")
ctx.Error = true
ctx.Message = "no captcha text found"
s.render("error", w, ctx)
return
}
if captchaInput != captchaText {
log.Warn("incorrect captcha")
ctx.Error = true
ctx.Message = "Unable to match captcha text. Please try again."
s.render("error", w, ctx)
return
}
if err := SendSupportRequestEmail(s.config, name, email, subject, message); err != nil {
log.WithError(err).Errorf("unable to send support email for %s", email)
ctx.Error = true
ctx.Message = "Error sending support message! Please try again."
s.render("error", w, ctx)
return
}
log.Infof("support message email sent for %s", email)
ctx.Error = false
ctx.Message = fmt.Sprintf(
"Thank you for your message! Pod operator %s will get back to you soon!",
s.config.AdminName,
)
s.render("error", w, ctx)
}
}
// ReportHandler ...
func (s *Server) ReportHandler() httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
ctx := NewContext(s.config, s.db, r)
nick := strings.TrimSpace(r.FormValue("nick"))
url := NormalizeURL(r.FormValue("url"))
if nick == "" || url == "" {
ctx.Error = true
ctx.Message = "Both nick and url must be specified"
s.render("error", w, ctx)
return
}
if r.Method == "GET" {
ctx.Title = "Report abuse"
ctx.ReportNick = nick
ctx.ReportURL = url
s.render("report", w, ctx)
return
}
name := strings.TrimSpace(r.FormValue("name"))
email := strings.TrimSpace(r.FormValue("email"))
category := strings.TrimSpace(r.FormValue("category"))
message := strings.TrimSpace(r.FormValue("message"))
captchaInput := strings.TrimSpace(r.FormValue("captchaInput"))
// Get session
sess := r.Context().Value(session.SessionKey)
if sess == nil {
log.Warn("no session found")
ctx.Error = true
ctx.Message = "no session found, do you have cookies disabled?"
s.render("error", w, ctx)
return
}
// Get captcha text from session
captchaText, isCaptchaTextAvailable := sess.(*session.Session).Get("captchaText")
if !isCaptchaTextAvailable {
log.Warn("no captcha provided")
ctx.Error = true
ctx.Message = "no captcha text found"
s.render("error", w, ctx)
return
}
if captchaInput != captchaText {
log.Warn("incorrect captcha")
ctx.Error = true
ctx.Message = "Unable to match captcha text. Please try again."
s.render("error", w, ctx)
return
}
if err := SendReportAbuseEmail(s.config, nick, url, name, email, category, message); err != nil {
log.WithError(err).Errorf("unable to send report email for %s", email)
ctx.Error = true
ctx.Message = "Error sending report! Please try again."
s.render("error", w, ctx)
return
}
ctx.Error = false
ctx.Message = fmt.Sprintf(
"Thank you for your report! Pod operator %s will get back to you soon!",
s.config.AdminName,
)
s.render("error", w, ctx)
}
}

142
internal/templates.go Normal file
View File

@@ -0,0 +1,142 @@
package internal
import (
"bytes"
"fmt"
"html/template"
"io"
"os"
"path/filepath"
"strings"
"sync"
rice "github.com/GeertJohan/go.rice"
"github.com/Masterminds/sprig"
humanize "github.com/dustin/go-humanize"
log "github.com/sirupsen/logrus"
)
const (
templatesPath = "templates"
baseTemplate = "base.html"
partialsTemplate = "_partials.html"
baseName = "base"
)
type TemplateManager struct {
sync.RWMutex
debug bool
templates map[string]*template.Template
funcMap template.FuncMap
}
func NewTemplateManager(conf *Config, blogs *BlogsCache, cache *Cache) (*TemplateManager, error) {
templates := make(map[string]*template.Template)
funcMap := sprig.FuncMap()
funcMap["time"] = humanize.Time
funcMap["hostnameFromURL"] = HostnameFromURL
funcMap["prettyURL"] = PrettyURL
funcMap["isLocalURL"] = IsLocalURLFactory(conf)
funcMap["formatTwt"] = FormatTwtFactory(conf)
funcMap["unparseTwt"] = UnparseTwtFactory(conf)
funcMap["formatForDateTime"] = FormatForDateTime
funcMap["urlForBlog"] = URLForBlogFactory(conf, blogs)
funcMap["urlForConv"] = URLForConvFactory(conf, cache)
funcMap["isAdminUser"] = IsAdminUserFactory(conf)
m := &TemplateManager{debug: conf.Debug, templates: templates, funcMap: funcMap}
if err := m.LoadTemplates(); err != nil {
log.WithError(err).Error("error loading templates")
return nil, fmt.Errorf("error loading templates: %w", err)
}
return m, nil
}
func (m *TemplateManager) LoadTemplates() error {
m.Lock()
defer m.Unlock()
box, err := rice.FindBox("templates")
if err != nil {
log.WithError(err).Errorf("error finding templates")
return fmt.Errorf("error finding templates: %w", err)
}
err = box.Walk("", func(path string, info os.FileInfo, err error) error {
if err != nil {
log.WithError(err).Error("error talking templates")
return fmt.Errorf("error walking templates: %w", err)
}
fname := info.Name()
if !info.IsDir() && fname != baseTemplate {
// Skip _partials.html and also editor swap files, to improve the development
// cycle. Editors often add suffixes to their swap files, e.g "~" or ".swp"
// (Vim) and those files are not parsable as templates, causing panics.
if fname == partialsTemplate || !strings.HasSuffix(fname, ".html") {
return nil
}
name := strings.TrimSuffix(fname, filepath.Ext(fname))
t := template.New(name).Option("missingkey=zero")
t.Funcs(m.funcMap)
template.Must(t.Parse(box.MustString(fname)))
template.Must(t.Parse(box.MustString(partialsTemplate)))
template.Must(t.Parse(box.MustString(baseTemplate)))
m.templates[name] = t
}
return nil
})
if err != nil {
log.WithError(err).Error("error loading templates")
return fmt.Errorf("error loading templates: %w", err)
}
return nil
}
func (m *TemplateManager) Add(name string, template *template.Template) {
m.Lock()
defer m.Unlock()
m.templates[name] = template
}
func (m *TemplateManager) Exec(name string, ctx *Context) (io.WriterTo, error) {
if m.debug {
log.Debug("reloading templates in debug mode...")
if err := m.LoadTemplates(); err != nil {
log.WithError(err).Error("error reloading templates")
return nil, fmt.Errorf("error reloading templates: %w", err)
}
}
m.RLock()
template, ok := m.templates[name]
m.RUnlock()
if !ok {
log.WithField("name", name).Errorf("template not found")
return nil, fmt.Errorf("no such template: %s", name)
}
if ctx == nil {
ctx = &Context{}
}
buf := bytes.NewBuffer([]byte{})
err := template.ExecuteTemplate(buf, baseName, ctx)
if err != nil {
log.WithError(err).WithField("name", name).Errorf("error executing template")
return nil, fmt.Errorf("error executing template %s: %w", name, err)
}
return buf, nil
}

View File

@@ -0,0 +1,9 @@
{{define "content"}}
<article class="grid">
<div>
<hgroup>
<h2>404 Not Found</h2>
<h3>Ooops! The resource you are looking for is not here!</h3>
</hgroup>
</article>
{{end}}

View File

@@ -0,0 +1,27 @@
{{ define "pager" }}
{{ if .HasPages }}
<nav class="pagination-nav">
<ul>
<li>
{{ if .HasPrev }}
<a href="?p={{ .PrevPage }}">Prev</a>
{{ else }}
<a href="#" data-tooltip="No previous page">Prev</a>
{{ end }}
</li>
</ul>
<ul>
<li><small>Page {{ .Page }}/{{ .PageNums }} of {{ .Nums }} Twts</small></li>
</ul>
<ul>
<li>
{{ if .HasNext }}
<a href="?p={{ .NextPage }}">Next</a>
{{ else }}
<a href="#" data-tooltip="No next page">Next</a>
{{ end }}
</li>
</ul>
</nav>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,120 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="en" {{ with .Theme }}data-theme="{{ . }}"{{ end }}>
<head>
{{ if $.Debug }}
<link href="/css/01-pico.css" rel="stylesheet" />
<link href="/css/02-icss.css" rel="stylesheet" />
<link href="/css/03-icons.css" rel="stylesheet" />
<link href="/css/99-spyda.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="/img/favicon.png" />
{{ else }}
<link href="/css/{{ .Commit }}/spyda.min.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="/img/{{ .Commit}}/favicon.png" />
{{ end }}
{{ range .Alternatives }}
<link rel="alternate" type="{{ .Type }}" title="{{ .Title }}" href="{{ .URL }}" />
{{ end }}
{{ range .Links }}
<link href="{{ .Href }}" rel="{{ .Rel }}" />
{{ end }}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ .InstanceName }} {{ .Title }}</title>
{{ with .Meta.Title }}<meta name="title" content="{{ . }}">{{ end }}
{{ with .Meta.Author }}<meta name="author" content="{{ . }}">{{ end }}
{{ with .Meta.Keywords }}<meta name="keywords" content="{{ . }}">{{ end }}
{{ with .Meta.Description }}<meta name="description" content="{{ . }}">{{ end }}
<!-- OpenGraph Meta Tags -->
{{ with .Meta.Title }}<meta property="og:title" content="{{ . }}">{{ end }}
{{ with .Meta.Description }}<meta property="og:description" content="{{ . }}">{{ end }}
{{ with .Meta.UpdatedAt }}<meta property="og:updated_time" content="{{ . }}" />{{ end }}
{{ with .Meta.Image }}<meta property="og:image" content="{{ . }}">{{ end }}
{{ with .Meta.URL }}<meta property="og:url" content="{{ . }}">{{ end }}
<meta property="og:site_name" content="{{ .InstanceName }}">
</head>
<body>
<nav id="mainNav" class="container-fluid">
<ul>
<li class="logo">
<a href="/" class="contrast">
<svg aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 449 249.1" style="enable-background:new 0 0 449 249.1;" height="3.5rem">
<path fill="currentColor" d="m73.1 156.6v-93.3h-48.4v-22.2h122.8v22.2h-48.5v93.3z"></path>
<path fill="currentColor" d="m320.7 64.8v-25.9h-25.7v25.9h-4.8l-9.3 20.5h14.1v71.3h25.7v-71.3h29v-20.5z"></path>
<path fill="currentColor" d="m277.2 64.8h-17l-27.8 61.8-24.9-54.9c-1.5-3.3-3.2-5.7-5.2-7.2s-4.9-2.3-8.5-2.3c-3.7 0-6.6.8-8.6 2.5-2 1.6-3.8 4-5.3 7l-26.2 54.9-22.6-53h-25.5l32.9 76.3c1.2 2.8 2.8 5.1 4.8 6.7s4.9 2.5 8.7 2.5c3.4 0 6.2-.8 8.6-2.5 2.3-1.6 4.1-3.9 5.5-6.7l27.3-56.6 25.9 56.6c1.2 2.6 3 4.8 5.3 6.5 2.3 1.8 5.1 2.6 8.4 2.6 3.4 0 6.2-.9 8.6-2.6 2.3-1.7 4.1-3.9 5.3-6.5l29.1-64.6 9.2-20.5z"></path>
<path fill="currentColor" d="m128.5 166.1v9.5h-1c-.8-3-1.8-5-3-6.1s-2.8-1.6-4.6-1.6c-1.4 0-2.6.4-3.4 1.1-.9.8-1.3 1.6-1.3 2.5 0 1.1.3 2.1 1 2.9.6.8 1.9 1.7 3.9 2.7l4.4 2.2c4.1 2 6.2 4.6 6.2 8 0 2.5-1 4.6-2.9 6.1-1.9 1.6-4.1 2.3-6.5 2.3-1.7 0-3.6-.3-5.8-.9-.7-.2-1.2-.3-1.7-.3s-.8.3-1.1.8h-1v-10h1c.6 2.9 1.7 5 3.3 6.4s3.4 2.2 5.4 2.2c1.4 0 2.5-.4 3.4-1.2s1.3-1.8 1.3-3c0-1.4-.5-2.6-1.5-3.5s-3-2.2-5.9-3.6c-2.9-1.5-4.9-2.8-5.8-4s-1.4-2.6-1.4-4.4c0-2.3.8-4.2 2.4-5.8 1.6-1.5 3.6-2.3 6.1-2.3 1.1 0 2.5.2 4 .7 1 .3 1.7.5 2 .5s.6-.1.8-.2.4-.4.7-.9h1z"></path>
<path fill="currentColor" d="m152.3 166.1c4.2 0 7.6 1.6 10.2 4.8 2.2 2.8 3.3 5.9 3.3 9.5 0 2.5-.6 5-1.8 7.6s-2.8 4.5-4.9 5.8-4.4 2-7.1 2c-4.2 0-7.5-1.7-10-5-2.1-2.8-3.1-6-3.1-9.5 0-2.6.6-5.1 1.9-7.7s2.9-4.4 5-5.6c2-1.3 4.2-1.9 6.5-1.9zm-.9 2c-1.1 0-2.1.3-3.2 1-1.1.6-2 1.8-2.7 3.4s-1 3.7-1 6.2c0 4.1.8 7.6 2.4 10.5s3.8 4.4 6.4 4.4c2 0 3.6-.8 4.9-2.4s1.9-4.4 1.9-8.4c0-5-1.1-8.9-3.2-11.8-1.4-1.9-3.3-2.9-5.5-2.9z"></path>
<path fill="currentColor" d="m197.5 184.3c-.8 3.7-2.2 6.5-4.4 8.5s-4.6 3-7.3 3c-3.2 0-6-1.3-8.3-4-2.4-2.7-3.5-6.3-3.5-10.8 0-4.4 1.3-8 3.9-10.7s5.8-4.1 9.4-4.1c2.8 0 5 .7 6.8 2.2s2.7 3 2.7 4.5c0 .8-.3 1.4-.8 1.9s-1.2.7-2.1.7c-1.2 0-2.1-.4-2.7-1.1-.3-.4-.5-1.2-.7-2.4-.1-1.2-.5-2.1-1.2-2.7s-1.7-.9-3-.9c-2 0-3.7.7-4.9 2.2-1.6 2-2.5 4.6-2.5 7.9s.8 6.3 2.4 8.8c1.6 2.6 3.8 3.8 6.7 3.8 2 0 3.8-.7 5.3-2 1.1-.9 2.2-2.6 3.3-5.1z"></path>
<path fill="currentColor" d="m215.1 166.1v22.4c0 1.8.1 2.9.4 3.5s.6 1 1.1 1.3 1.4.4 2.7.4v1.1h-13.6v-1.1c1.4 0 2.3-.1 2.7-.4.5-.3.8-.7 1.1-1.3s.4-1.8.4-3.5v-10.8c0-3-.1-5-.3-5.9-.2-.7-.4-1.1-.7-1.4s-.7-.4-1.3-.4-1.2.2-2 .5l-.5-1.1 8.4-3.4h1.6zm-2.6-14.6c.9 0 1.6.3 2.2.9s.9 1.3.9 2.2c0 .8-.3 1.6-.9 2.2s-1.3.9-2.2.9-1.6-.3-2.2-.9-.9-1.3-.9-2.2.3-1.6.9-2.2 1.3-.9 2.2-.9z"></path>
<path fill="currentColor" d="m242.5 190.9c-2.9 2.2-4.7 3.5-5.4 3.8-1.1.5-2.3.8-3.5.8-1.9 0-3.5-.7-4.8-2s-1.9-3.1-1.9-5.2c0-1.4.3-2.5.9-3.5.8-1.4 2.3-2.7 4.3-3.9 2.1-1.2 5.5-2.7 10.3-4.5v-1.1c0-2.8-.4-4.7-1.3-5.7s-2.2-1.6-3.9-1.6c-1.3 0-2.3.3-3 1-.8.7-1.2 1.5-1.2 2.4l.1 1.8c0 .9-.2 1.7-.7 2.2s-1.1.8-1.9.8-1.4-.3-1.9-.8-.7-1.3-.7-2.2c0-1.7.9-3.3 2.7-4.8s4.3-2.2 7.5-2.2c2.5 0 4.5.4 6.1 1.3 1.2.6 2.1 1.6 2.7 3 .4.9.5 2.7.5 5.4v9.5c0 2.7.1 4.3.2 4.9s.3 1 .5 1.2.5.3.8.3.6-.1.8-.2c.4-.3 1.3-1 2.5-2.2v1.7c-2.3 3-4.5 4.5-6.6 4.5-1 0-1.8-.3-2.4-1-.4-.9-.7-2-.7-3.7zm0-2v-10.7c-3.1 1.2-5 2.1-6 2.6-1.6.9-2.7 1.8-3.4 2.8s-1 2-1 3.2c0 1.5.4 2.7 1.3 3.7s1.9 1.5 3 1.5c1.5-.1 3.6-1.1 6.1-3.1z"></path>
<path fill="currentColor" d="m267.9 151.5v37.1c0 1.8.1 2.9.4 3.5s.6 1 1.2 1.3c.5.3 1.5.4 3 .4v1.1h-13.7v-1.1c1.3 0 2.2-.1 2.6-.4.5-.3.8-.7 1.1-1.3s.4-1.8.4-3.5v-25.4c0-3.2-.1-5.1-.2-5.8s-.4-1.2-.7-1.5-.7-.4-1.2-.4-1.2.2-2 .5l-.5-1.1 8.3-3.4z"></path>
<path fill="currentColor" d="m102.6 186.7c1.3 0 2.4.4 3.2 1.3.9.9 1.3 1.9 1.3 3.2 0 1.2-.4 2.3-1.3 3.2s-1.9 1.3-3.2 1.3c-1.2 0-2.3-.4-3.2-1.3s-1.3-2-1.3-3.2c0-1.3.4-2.3 1.3-3.2.9-.8 2-1.3 3.2-1.3z"></path>
</svg>
</a>
</li>
<li>
<a href="/add">
<i class="icss-plus"></i>
Add site
</a>
</li>
</ul>
<ul>
{{ if .Authenticated }}
<li>
<a class="secondary" href="/manage">
<i class="icss-gear"></i>
Manage
</a>
</li>
<li>
<a class="secondary" href="/logout">
<i class="icss-exit"></i>
Logout
</a>
</li>
{{ else }}
<li>
<a href="/login">
<i class="icss-key"></i>
Login
</a>
</li>
{{ end }}
</ul>
</nav>
<main class="container">
{{template "content" . }}
</main>
<footer class="container">
<div class="footer-copyright"><a href="https://git.mills.io/prologic/spyda" target="_blank">spyda v{{ .SoftwareVersion }}</a>
·
Created with 💚 by <a href="https://github.com/prologic" target="_blank">James Mills</a>
·
&copy; 2021 <a href="https://github.com/prologic" target="_blank">James Mills</a>. All rights reserved.
</div>
<div class="footer-menu">
<a href="/about" target="_blank" class="menu-item">About</a>
<a href="/privacy" target="_blank" class="menu-item">Privacy</a>
<a href="/abuse" target="_blank" class="menu-item">Abuse</a>
<a href="/help" target="_blank" class="menu-item">Help</a>
<a href="/support" target="_blank" class="menu-item">Support</a>
<a href="/atom.xml" target="_blank">Atom&nbsp;<i class="icss-rss"></i></a>
</div>
</footer>
{{ if $.Debug }}
<script type="application/javascript" src="/js/01-umbrella.js"></script>
<script type="application/javascript" src="/js/02-polyfill.js"></script>
<script type="application/javascript" src="/js/03-twix.js"></script>
<script type="application/javascript" src="/js/99-spyda.js"></script>
{{ else }}
<script type="application/javascript" src="/js/{{ .Commit }}/spyda.min.js"></script>
{{ end }}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,32 @@
<html>
<head>
<title>veri</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
body{max-width:920px;margin:0 auto;padding:2em;font-family:-apple-system,blinkmacsystemfont,"Helvetica Neue","Helvetica","Segoe UI",roboto,oxygen-sans,ubuntu,cantarell,"Helvetica Neue",sans-serif;color:#394b41;}
.small{font-size:0.8em;color:grey;}
.placeholder{font-style:oblique;}
.bold{font-weight:bold;}
a{color:blue;}
a:hover{color:red;}
ol{padding:1em;}
li{border-bottom:solid 2px grey;}
input{width:100%;font-size:1.5em;}
pre{white-space:pre-wrap;}
@media (max-width:40em) {
body{padding:1em;}
}
</style>
</head>
<body>
<a href="/"><h1>veri</h1></a>
<p class="bold">This is a rudimentary cached copy of <a href="{{ .URL }}">{{ .URL }}</a>, go there to get the latest copy. No ownership of this content is implied.</p>
{{ if not . }}<p>No cache</p>{{ end }}
<h2>{{ .Title }}</h2>
<pre>
{{ .Content }}
</pre>
<p>If your page appears here and you do not want it to, please contact <a href="mailto:~ols/indexing@lists.sr.ht">~ols/indexing@lists.sr.ht</a>. Contact the same email if you want your page considered for indexing. Be aware this is a public mailing list.</p>
<p><a href="https://sr.ht/~ols/veri/">about</a></p>
</body>
</html>

View File

@@ -0,0 +1,9 @@
{{define "content"}}
<article class="grid">
<div>
<hgroup>
<h2>{{ if .Error }}Error{{ else }}Success {{ end }}</h2>
<h3>{{ .Message }}</h3>
</hgroup>
</article>
{{end}}

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>veri</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
</head>
<body>
<header>
<a href="/"><h1>veri</h1></a>
</header>
<main>
<form action="/search" method="get">
<input type="text" id="search" name="q"><br><br>
<input type="submit" value="Search">
</form>
</main>
<footer>
<p><a href="https://sr.ht/~ols/veri/">about</a></p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>veri</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
</head>
<body>
<header>
<a href="/"><h1>veri</h1></a>
</header>
<main>
<form action="/search" method="get">
<input type="text" id="search" name="q"><br><br>
<input type="submit" value="Search">
</form>
<p class="bold">These results are not ranked, they are just in alphabetical order of URL (this will change)</p>
<ol>
{{ if not . }}<p>No search results</p>{{ end }}
{{ range . }}
<li><a href="{{ .URL }}"><h3>{{ .Title }}</h3></a><p{{ if .Summary }}>{{ .Summary }}{{ else }} class="placeholder">No summary available{{ end }}</p><p class="small"><a href="{{ .URL }}">{{ .URL }}</a> {{ .Length }} <a href="/cache?id={{ .ID }}">View cached</a></p>
{{ end }}
</ol>
<p>If your page appears here and you do not want it to, please contact <a href="mailto:~ols/indexing@lists.sr.ht">~ols/indexing@lists.sr.ht</a>. Contact the same email if you want your page considered for indexing. Be aware this is a public mailing list.</p>
<footer>
<p><a href="https://sr.ht/~ols/veri/">about</a></p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,24 @@
{{define "content"}}
<article class="grid">
<div>
<hgroup>
<h2>Contact us</h2>
<h3>How can we help you?</h3>
</hgroup>
<form action="/support" method="POST">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="text" name="name" placeholder="Name" aria-label="Name" autofocus required>
<input type="email" name="email" placeholder="Email address" aria-label="Email" required>
<input type="text" name="subject" placeholder="Subject" aria-label="Subject" required>
<textarea name="message" placeholder="Message" aria-label="Message" rows="4" cols="50" required></textarea>
<small>Please solve this simple math problem below so we know you're a human!</small>
<img id="captcha" src="/_captcha" alt="captcha" height="50" width="150" />
<input type="text" name="captchaInput" class="captchaInput" placeholder="Captcha" aria-label="Captcha" required>
<button type="submit" class="contrast">Submit</button>
</form>
</div>
<div></div>
</article>
{{end}}

400
internal/utils.go Normal file
View File

@@ -0,0 +1,400 @@
package internal
import (
"bytes"
"context"
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"html/template"
"math"
"net/http"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"time"
// Blank import so we can handle image/jpeg
_ "image/gif"
_ "image/jpeg"
"git.mills.io/prologic/spyda"
"github.com/goware/urlx"
log "github.com/sirupsen/logrus"
"github.com/writeas/slug"
"golang.org/x/crypto/blake2b"
)
const (
CacheDir = "cache"
requestTimeout = time.Second * 30
DayAgo = time.Hour * 24
WeekAgo = DayAgo * 7
MonthAgo = DayAgo * 30
YearAgo = MonthAgo * 12
)
var (
ErrBadRequest = errors.New("error: request failed with non-200 response")
)
func GenerateRandomToken() string {
b := make([]byte, 16)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
func FastHash(s string) string {
sum := blake2b.Sum256([]byte(s))
// Base32 is URL-safe, unlike Base64, and shorter than hex.
encoding := base32.StdEncoding.WithPadding(base32.NoPadding)
hash := strings.ToLower(encoding.EncodeToString(sum[:]))
return hash
}
func IntPow(x, y int) int {
return int(math.Pow(float64(x), float64(y)))
}
func Slugify(uri string) string {
u, err := url.Parse(uri)
if err != nil {
log.WithError(err).Warnf("Slugify(): error parsing uri: %s", uri)
return ""
}
return slug.Make(fmt.Sprintf("%s/%s", u.Hostname(), u.Path))
}
func Request(conf *Config, method, url string, headers http.Header) (*http.Response, error) {
req, err := http.NewRequest(method, url, nil)
if err != nil {
log.WithError(err).Errorf("%s: http.NewRequest fail: %s", url, err)
return nil, err
}
if headers == nil {
headers = make(http.Header)
}
// Set a default User-Agent (if none set)
if headers.Get("User-Agent") == "" {
headers.Set(
"User-Agent",
fmt.Sprintf(
"spyda/%s (%s Support: %s)",
spyda.FullVersion(), conf.Name, URLForPage(conf.BaseURL, "support"),
),
)
}
req.Header = headers
client := http.Client{
Timeout: requestTimeout,
}
res, err := client.Do(req)
if err != nil {
log.WithError(err).Errorf("%s: client.Do fail: %s", url, err)
return nil, err
}
return res, nil
}
func ResourceExists(conf *Config, url string) bool {
res, err := Request(conf, http.MethodHead, url, nil)
if err != nil {
log.WithError(err).Errorf("error checking if %s exists", url)
return false
}
defer res.Body.Close()
return res.StatusCode/100 == 2
}
func FileExists(name string) bool {
if _, err := os.Stat(name); err != nil {
if os.IsNotExist(err) {
return false
}
}
return true
}
// CmdExists ...
func CmdExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
// RunCmd ...
func RunCmd(timeout time.Duration, command string, args ...string) error {
var (
ctx context.Context
cancel context.CancelFunc
)
if timeout > 0 {
ctx, cancel = context.WithTimeout(context.Background(), timeout)
} else {
ctx, cancel = context.WithCancel(context.Background())
}
defer cancel()
cmd := exec.CommandContext(ctx, command, args...)
out, err := cmd.CombinedOutput()
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
if ws, ok := exitError.Sys().(syscall.WaitStatus); ok && ws.Signal() == syscall.SIGKILL {
err = &ErrCommandKilled{Err: err, Signal: ws.Signal()}
} else {
err = &ErrCommandFailed{Err: err, Status: exitError.ExitCode()}
}
}
log.
WithError(err).
WithField("out", string(out)).
Errorf("error running command")
return err
}
return nil
}
// RenderString ...
func RenderString(tpl string, ctx *Context) (string, error) {
t := template.Must(template.New("tpl").Parse(tpl))
buf := bytes.NewBuffer([]byte{})
err := t.Execute(buf, ctx)
if err != nil {
return "", err
}
return buf.String(), nil
}
func IsLocalURLFactory(conf *Config) func(url string) bool {
return func(url string) bool {
if NormalizeURL(url) == "" {
return false
}
return strings.HasPrefix(NormalizeURL(url), NormalizeURL(conf.BaseURL))
}
}
func StringKeys(kv map[string]string) []string {
var res []string
for k := range kv {
res = append(res, k)
}
return res
}
func StringValues(kv map[string]string) []string {
var res []string
for _, v := range kv {
res = append(res, v)
}
return res
}
func MapStrings(xs []string, f func(s string) string) []string {
var res []string
for _, x := range xs {
res = append(res, f(x))
}
return res
}
func HasString(a []string, x string) bool {
for _, n := range a {
if x == n {
return true
}
}
return false
}
func UniqStrings(xs []string) []string {
set := make(map[string]bool)
for _, x := range xs {
if _, ok := set[x]; !ok {
set[x] = true
}
}
res := []string{}
for k := range set {
res = append(res, k)
}
return res
}
func RemoveString(xs []string, e string) []string {
res := []string{}
for _, x := range xs {
if x == e {
continue
}
res = append(res, x)
}
return res
}
type URI struct {
Type string
Path string
}
func (u URI) IsZero() bool {
return u.Type == "" && u.Path == ""
}
func (u URI) String() string {
return fmt.Sprintf("%s://%s", u.Type, u.Path)
}
func ParseURI(uri string) (*URI, error) {
parts := strings.Split(uri, "://")
if len(parts) == 2 {
return &URI{Type: strings.ToLower(parts[0]), Path: parts[1]}, nil
}
return nil, fmt.Errorf("invalid uri: %s", uri)
}
func NormalizeURL(url string) string {
if url == "" {
return ""
}
u, err := urlx.Parse(url)
if err != nil {
log.WithError(err).Errorf("error parsing url %s", url)
return ""
}
if u.Scheme == "http" && strings.HasSuffix(u.Host, ":80") {
u.Host = strings.TrimSuffix(u.Host, ":80")
}
if u.Scheme == "https" && strings.HasSuffix(u.Host, ":443") {
u.Host = strings.TrimSuffix(u.Host, ":443")
}
u.User = nil
u.Path = strings.TrimSuffix(u.Path, "/")
norm, err := urlx.Normalize(u)
if err != nil {
log.WithError(err).Errorf("error normalizing url %s", url)
return ""
}
return norm
}
// RedirectRefererURL constructs a Redirect URL from the given Request URL
// and possibly Referer, if the Referer's Base URL matches the Pod's Base URL
// will return the Referer URL otherwise the defaultURL. This is primarily used
// to redirect a user from a successful /login back to the page they were on.
func RedirectRefererURL(r *http.Request, conf *Config, defaultURL string) string {
referer := NormalizeURL(r.Header.Get("Referer"))
if referer != "" && strings.HasPrefix(referer, conf.BaseURL) {
return referer
}
return defaultURL
}
func HostnameFromURL(uri string) string {
u, err := url.Parse(uri)
if err != nil {
log.WithError(err).Warnf("HostnameFromURL(): error parsing url: %s", uri)
return uri
}
return u.Hostname()
}
func PrettyURL(uri string) string {
u, err := url.Parse(uri)
if err != nil {
log.WithError(err).Warnf("PrettyURL(): error parsing url: %s", uri)
return uri
}
return fmt.Sprintf("%s/%s", u.Hostname(), strings.TrimPrefix(u.EscapedPath(), "/"))
}
// IsAdminUserFactory returns a function that returns true if the user provided
// is the configured pod administrator, false otherwise.
func IsAdminUserFactory(conf *Config) func(user *User) bool {
return func(user *User) bool {
return NormalizeUsername(conf.AdminUser) == NormalizeUsername(user.Username)
}
}
func URLForPage(baseURL, page string) string {
return fmt.Sprintf(
"%s/%s",
strings.TrimSuffix(baseURL, "/"),
page,
)
}
func URLForCached(baseURL, hash string) string {
return fmt.Sprintf(
"%s/cached/%s",
strings.TrimSuffix(baseURL, "/"),
hash,
)
}
// SafeParseInt ...
func SafeParseInt(s string, d int) int {
n, e := strconv.Atoi(s)
if e != nil {
return d
}
return n
}
func FormatForDateTime(t time.Time) string {
var format string
dt := time.Since(t)
if dt > YearAgo {
format = "Mon, Jan 2 3:04PM 2006"
} else if dt > MonthAgo {
format = "Mon, Jan 2 3:04PM"
} else if dt > WeekAgo {
format = "Mon, Jan 2 3:04PM"
} else if dt > DayAgo {
format = "Mon 2, 3:04PM"
} else {
format = "3:04PM"
}
return format
}
// FormatRequest generates ascii representation of a request
func FormatRequest(r *http.Request) string {
return fmt.Sprintf(
"%s %v %s%v %v (%s)",
r.RemoteAddr,
r.Method,
r.Host,
r.URL,
r.Proto,
r.UserAgent(),
)
}

32
spyda.yml Normal file
View File

@@ -0,0 +1,32 @@
---
version: "3.8"
services:
spyda:
image: r.mills.io/prologic/spyda:latest
command: -d /data -s bitcask:///data/spyda.db -u https://spyda.dev
volumes:
- spyda:/data
networks:
- traefik
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- "node.hostname == dm6.mills.io"
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.services.spyda_spyda.loadbalancer.server.port=8000"
- "traefik.http.routers.spyda_spyda.rule=Host(`spyda.dev`)"
restart_policy:
condition: on-failure
networks:
traefik:
external: true
volumes:
spyda:
driver: local