Initial Codebase (untested)
This commit is contained in:
23
.dockerignore
Normal file
23
.dockerignore
Normal 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
34
.drone.yml
Normal 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
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
*~
|
||||
*.bak
|
||||
|
||||
**/.DS_Store
|
||||
|
||||
/data
|
||||
|
||||
/spyda
|
||||
/cmd/spyda/spyda
|
||||
68
Dockerfile
Normal file
68
Dockerfile
Normal 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
62
Makefile
Normal 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
324
cmd/spyda/main.go
Normal 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
12
docker-compose.yml
Normal 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
58
go.mod
Normal 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
427
go.sum
Normal 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
270
internal/api.go
Normal 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
70
internal/auth/manager.go
Normal 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
376
internal/bitcask_store.go
Normal 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
195
internal/config.go
Normal 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
175
internal/context.go
Normal 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
94
internal/errors.go
Normal 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
100
internal/handlers.go
Normal 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
15
internal/init.go
Normal 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
50
internal/jobs.go
Normal 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
554
internal/models.go
Normal 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
432
internal/options.go
Normal 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
14
internal/pages/about.md
Normal 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
7
internal/pages/help.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Help on using Spyda Search
|
||||
---
|
||||
|
||||
# Help
|
||||
|
||||
> ... TBD ...
|
||||
35
internal/pages/privacy.md
Normal file
35
internal/pages/privacy.md
Normal 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>
|
||||
9
internal/passwords/passwords.go
Normal file
9
internal/passwords/passwords.go
Normal 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
|
||||
}
|
||||
72
internal/passwords/scrypt_passwords.go
Normal file
72
internal/passwords/scrypt_passwords.go
Normal 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
49
internal/robots.go
Normal 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
715
internal/server.go
Normal 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
166
internal/session/manager.go
Normal 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))
|
||||
})
|
||||
}
|
||||
67
internal/session/memorystore.go
Normal file
67
internal/session/memorystore.go
Normal 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
|
||||
}
|
||||
67
internal/session/session.go
Normal file
67
internal/session/session.go
Normal 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
65
internal/session/sid.go
Normal 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)
|
||||
}
|
||||
75
internal/session/sid_test.go
Normal file
75
internal/session/sid_test.go
Normal 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
32
internal/session/store.go
Normal 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
90
internal/session_store.go
Normal 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
|
||||
}
|
||||
1180
internal/static/css/01-pico.css
Normal file
1180
internal/static/css/01-pico.css
Normal file
File diff suppressed because it is too large
Load Diff
229
internal/static/css/02-icss.css
Normal file
229
internal/static/css/02-icss.css
Normal 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%;
|
||||
}
|
||||
1217
internal/static/css/03-icons.css
Normal file
1217
internal/static/css/03-icons.css
Normal file
File diff suppressed because it is too large
Load Diff
184
internal/static/css/99-spyda.css
Normal file
184
internal/static/css/99-spyda.css
Normal 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;
|
||||
}
|
||||
}
|
||||
0
internal/static/img/.gitkeep
Normal file
0
internal/static/img/.gitkeep
Normal file
BIN
internal/static/img/favicon.png
Normal file
BIN
internal/static/img/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
1
internal/static/img/logo.svg
Normal file
1
internal/static/img/logo.svg
Normal 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 |
0
internal/static/js/.gitkeep
Normal file
0
internal/static/js/.gitkeep
Normal file
797
internal/static/js/01-umbrella.js
Normal file
797
internal/static/js/01-umbrella.js
Normal 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;
|
||||
}
|
||||
70
internal/static/js/02-polyfill.js
Normal file
70
internal/static/js/02-polyfill.js
Normal 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);
|
||||
});
|
||||
|
||||
})();
|
||||
122
internal/static/js/03-twix.js
Normal file
122
internal/static/js/03-twix.js
Normal 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;
|
||||
61
internal/static/js/99-spyda.js
Normal file
61
internal/static/js/99-spyda.js
Normal 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
44
internal/stats.go
Normal 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
65
internal/store.go
Normal 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
|
||||
}
|
||||
}
|
||||
180
internal/support_handlers.go
Normal file
180
internal/support_handlers.go
Normal 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
142
internal/templates.go
Normal 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
|
||||
}
|
||||
9
internal/templates/404.html
Normal file
9
internal/templates/404.html
Normal 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}}
|
||||
27
internal/templates/_partials.html
Normal file
27
internal/templates/_partials.html
Normal 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 }}
|
||||
120
internal/templates/base.html
Normal file
120
internal/templates/base.html
Normal 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>
|
||||
·
|
||||
© 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 <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}}
|
||||
32
internal/templates/cache.html
Normal file
32
internal/templates/cache.html
Normal 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>
|
||||
9
internal/templates/error.html
Normal file
9
internal/templates/error.html
Normal 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}}
|
||||
22
internal/templates/index.html
Normal file
22
internal/templates/index.html
Normal 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>
|
||||
29
internal/templates/search.html
Normal file
29
internal/templates/search.html
Normal 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>
|
||||
24
internal/templates/support.html
Normal file
24
internal/templates/support.html
Normal 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
400
internal/utils.go
Normal 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
32
spyda.yml
Normal 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
|
||||
Reference in New Issue
Block a user