Down For Maintenance
+
+ pathofexile.com is currently down for maintenance. Please try again later.
+Thanks for your patience!
+
diff --git a/.dockerignore b/.dockerignore index bbfd30f..2d578ec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ * !*.go !server/* +!go.mod +!go.sum diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..852d5ee --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [ccbrown] diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..f033944 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,12 @@ +name: Push +on: [push] +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Build + run: | + make docker-image diff --git a/.gitignore b/.gitignore index 3990fde..4edd60f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ *.db gggtracker +/server/bindata.go +/vendor diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index bf46dbe..0000000 --- a/.travis.yml +++ /dev/null @@ -1,6 +0,0 @@ -language: go -sudo: required -services: - - docker -script: - - make docker-image diff --git a/Dockerfile b/Dockerfile index f94d04f..6cacd63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ -FROM golang:alpine +FROM golang:1.20-alpine WORKDIR /go/src/github.com/ccbrown/gggtracker -ADD . . -RUN apk add --no-cache git && go get -t ./... -RUN go vet . && go test -v ./... +ADD . . +RUN go vet . +RUN go test -v ./... RUN go build . -ENTRYPOINT ["gggtracker"] +ENTRYPOINT ["./gggtracker"] diff --git a/README.md b/README.md index 96d1ea7..bfd6a82 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# Welcome! [](https://travis-ci.org/ccbrown/gggtracker) +# Welcome!  -This is the repository for gggtracker.com. If there's something you think the site is missing, please either a.) open an issue to request the feature or b.) develop the feature yourself and put in a pull request. +This is the repository for [gggtracker.com](https://gggtracker.com). If there's something you think the site is missing, please either a.) open an issue to request the feature or b.) develop the feature yourself and put in a pull request. ### Development -If you have Go installed, you can develop and run the server in the usual way: `go get ./... && go run main.go` +If you have Go installed, you can develop and run the server in the usual way: `go run main.go`  diff --git a/aws-sam/.gitignore b/aws-sam/.gitignore new file mode 100644 index 0000000..c39f315 --- /dev/null +++ b/aws-sam/.gitignore @@ -0,0 +1,3 @@ +/build +/dist.zip +*-packaged.cfn.yaml diff --git a/aws-sam/Makefile b/aws-sam/Makefile new file mode 100644 index 0000000..55590af --- /dev/null +++ b/aws-sam/Makefile @@ -0,0 +1,17 @@ +.PHONY: build build-environment-build lambda-environment-build + +dist.zip: build + zip -j dist.zip build/main + +build: + go generate ../... + docker-compose run --rm build-environment make build-environment-build + docker-compose run --rm lambda-environment make lambda-environment-build + +build-environment-build: + rm -rf ./build + go build -o ./build/main . + ./build/main --help + +lambda-environment-build: + ./build/main --help || (ldd ./build/main && exit 1) diff --git a/aws-sam/README.md b/aws-sam/README.md new file mode 100644 index 0000000..437266d --- /dev/null +++ b/aws-sam/README.md @@ -0,0 +1,3 @@ +# aws-sam + +This directory contains resources for deploying the tracker using the AWS Serverless Application Model (SAM). By deploying via serverless technologies, you can create a cost-effective (free or nearly free) scalable deployment. diff --git a/aws-sam/docker-compose.yaml b/aws-sam/docker-compose.yaml new file mode 100644 index 0000000..3641514 --- /dev/null +++ b/aws-sam/docker-compose.yaml @@ -0,0 +1,17 @@ +version: '3' +services: + build-environment: + image: golang:1.20.0 + platform: linux/amd64 + volumes: + - ../:/go/src/github.com/ccbrown/gggtracker + working_dir: /go/src/github.com/ccbrown/gggtracker/aws-sam + lambda-environment: + entrypoint: [] + environment: + LD_LIBRARY_PATH: '' + image: lambci/lambda:go1.x + platform: linux/amd64 + volumes: + - ../:/go/src/github.com/ccbrown/gggtracker + working_dir: /go/src/github.com/ccbrown/gggtracker/aws-sam diff --git a/aws-sam/gggtracker.cfn.yaml b/aws-sam/gggtracker.cfn.yaml new file mode 100644 index 0000000..b6bf81a --- /dev/null +++ b/aws-sam/gggtracker.cfn.yaml @@ -0,0 +1,290 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: github.com/ccbrown/gggtracker +Parameters: + BinaryMediaType: + Type: String + AllowedValues: + - '*~1*' + - '*/*' + Description: See https://forums.aws.amazon.com/thread.jspa?messageID=797934 + CodeS3Bucket: + Type: String + Description: The bucket that contains the code to deploy. + CodeS3Key: + Type: String + Description: The key for the code to deploy. + CertificateARN: + Type: String + Description: A certificate corresponding to DomainName. + DomainName: + Type: String + Description: The desired domain name. It'll be up to you to actually change your DNS after the stack is deployed. + GoogleAnalytics: + Type: String + Default: '' +Resources: + API: + Type: AWS::ApiGateway::RestApi + Properties: + BinaryMediaTypes: + - !Ref BinaryMediaType + Name: !Ref AWS::StackName + APIAccount: + Type: AWS::ApiGateway::Account + Properties: + CloudWatchRoleArn: !GetAtt APICloudWatchRole.Arn + APIBasePathMapping: + Type: AWS::ApiGateway::BasePathMapping + DependsOn: APIDomainName + Properties: + DomainName: !Ref DomainName + RestApiId: !Ref API + Stage: !Ref APIStage + APIBasePathMappingSubdomain1: + Type: AWS::ApiGateway::BasePathMapping + DependsOn: APISubdomainName1 + Properties: + DomainName: !Sub br.${DomainName} + RestApiId: !Ref API + Stage: !Ref APIStage + APIBasePathMappingSubdomain2: + Type: AWS::ApiGateway::BasePathMapping + DependsOn: APISubdomainName2 + Properties: + DomainName: !Sub ru.${DomainName} + RestApiId: !Ref API + Stage: !Ref APIStage + APIBasePathMappingSubdomain3: + Type: AWS::ApiGateway::BasePathMapping + DependsOn: APISubdomainName3 + Properties: + DomainName: !Sub th.${DomainName} + RestApiId: !Ref API + Stage: !Ref APIStage + APIBasePathMappingSubdomain4: + Type: AWS::ApiGateway::BasePathMapping + DependsOn: APISubdomainName4 + Properties: + DomainName: !Sub de.${DomainName} + RestApiId: !Ref API + Stage: !Ref APIStage + APIBasePathMappingSubdomain5: + Type: AWS::ApiGateway::BasePathMapping + DependsOn: APISubdomainName5 + Properties: + DomainName: !Sub fr.${DomainName} + RestApiId: !Ref API + Stage: !Ref APIStage + APIBasePathMappingSubdomain6: + Type: AWS::ApiGateway::BasePathMapping + DependsOn: APISubdomainName6 + Properties: + DomainName: !Sub es.${DomainName} + RestApiId: !Ref API + Stage: !Ref APIStage + APIBasePathMappingSubdomain7: + Type: AWS::ApiGateway::BasePathMapping + DependsOn: APISubdomainName7 + Properties: + DomainName: !Sub www.${DomainName} + RestApiId: !Ref API + Stage: !Ref APIStage + APIBasePathMappingSubdomain8: + Type: AWS::ApiGateway::BasePathMapping + DependsOn: APISubdomainName8 + Properties: + DomainName: !Sub jp.${DomainName} + RestApiId: !Ref API + Stage: !Ref APIStage + APICloudWatchRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: apigateway.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs + APIDomainName: + Type: AWS::ApiGateway::DomainName + Properties: + CertificateArn: !Ref CertificateARN + DomainName: !Ref DomainName + EndpointConfiguration: + Types: + - EDGE + APISubdomainName1: + Type: AWS::ApiGateway::DomainName + Properties: + CertificateArn: !Ref CertificateARN + DomainName: !Sub br.${DomainName} + EndpointConfiguration: + Types: + - EDGE + APISubdomainName2: + Type: AWS::ApiGateway::DomainName + Properties: + CertificateArn: !Ref CertificateARN + DomainName: !Sub ru.${DomainName} + EndpointConfiguration: + Types: + - EDGE + APISubdomainName3: + Type: AWS::ApiGateway::DomainName + Properties: + CertificateArn: !Ref CertificateARN + DomainName: !Sub th.${DomainName} + EndpointConfiguration: + Types: + - EDGE + APISubdomainName4: + Type: AWS::ApiGateway::DomainName + Properties: + CertificateArn: !Ref CertificateARN + DomainName: !Sub de.${DomainName} + EndpointConfiguration: + Types: + - EDGE + APISubdomainName5: + Type: AWS::ApiGateway::DomainName + Properties: + CertificateArn: !Ref CertificateARN + DomainName: !Sub fr.${DomainName} + EndpointConfiguration: + Types: + - EDGE + APISubdomainName6: + Type: AWS::ApiGateway::DomainName + Properties: + CertificateArn: !Ref CertificateARN + DomainName: !Sub es.${DomainName} + EndpointConfiguration: + Types: + - EDGE + APISubdomainName7: + Type: AWS::ApiGateway::DomainName + Properties: + CertificateArn: !Ref CertificateARN + DomainName: !Sub www.${DomainName} + EndpointConfiguration: + Types: + - EDGE + APISubdomainName8: + Type: AWS::ApiGateway::DomainName + Properties: + CertificateArn: !Ref CertificateARN + DomainName: !Sub jp.${DomainName} + EndpointConfiguration: + Types: + - EDGE + APIProxyResource: + Type: AWS::ApiGateway::Resource + Properties: + ParentId: !GetAtt API.RootResourceId + PathPart: '{proxy+}' + RestApiId: !Ref API + APIRootMethod: + Type: AWS::ApiGateway::Method + Properties: + AuthorizationType: NONE + HttpMethod: ANY + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${APIFunction.Arn}/invocations + ResourceId: !GetAtt API.RootResourceId + RestApiId: !Ref API + APIProxyMethod: + Type: AWS::ApiGateway::Method + Properties: + AuthorizationType: NONE + HttpMethod: ANY + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${APIFunction.Arn}/invocations + ResourceId: !Ref APIProxyResource + RestApiId: !Ref API + APIDeployment: + Type: AWS::ApiGateway::Deployment + DependsOn: + - APIRootMethod + - APIProxyMethod + Properties: + RestApiId: !Ref API + APIStage: + Type: AWS::ApiGateway::Stage + DependsOn: + - APIAccount + Properties: + DeploymentId: !Ref APIDeployment + MethodSettings: + - HttpMethod: '*' + LoggingLevel: ERROR + MetricsEnabled: true + ResourcePath: /* + RestApiId: !Ref API + StageName: stage + DynamoDBTable: + Type: AWS::DynamoDB::Table + Properties: + AttributeDefinitions: + - AttributeName: hk + AttributeType: B + - AttributeName: rk + AttributeType: B + KeySchema: + - AttributeName: hk + KeyType: HASH + - AttributeName: rk + KeyType: RANGE + ProvisionedThroughput: + ReadCapacityUnits: 25 + WriteCapacityUnits: 50 + APIFunction: + Type: AWS::Lambda::Function + Properties: + Code: + S3Bucket: !Ref CodeS3Bucket + S3Key: !Ref CodeS3Key + Environment: + Variables: + GGGTRACKER_DYNAMODB_TABLE: !Ref DynamoDBTable + GGGTRACKER_GA: !Ref GoogleAnalytics + Handler: main + MemorySize: 128 + Role: !GetAtt AppFunctionRole.Arn + Runtime: go1.x + Timeout: 60 + APIFunctionAPIPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !GetAtt APIFunction.Arn + Principal: apigateway.amazonaws.com + SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${API}/*/*/* + AppFunctionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: gggtracker + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: dynamodb:* + Resource: + - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${DynamoDBTable} + - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${DynamoDBTable}/* diff --git a/aws-sam/main.go b/aws-sam/main.go new file mode 100644 index 0000000..f9ca730 --- /dev/null +++ b/aws-sam/main.go @@ -0,0 +1,160 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/base64" + "flag" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "strings" + + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-sdk-go-v2/aws/external" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh/terminal" + "golang.org/x/sys/unix" + + "github.com/ccbrown/gggtracker/server" +) + +type ResponseWriter struct { + header http.Header + statusCode int + body *bytes.Buffer +} + +func (w *ResponseWriter) Header() http.Header { + if w.header == nil { + w.header = http.Header{} + } + return w.header +} + +func (w *ResponseWriter) Write(b []byte) (int, error) { + if w.body == nil { + w.body = &bytes.Buffer{} + } + return w.body.Write(b) +} + +func (w *ResponseWriter) WriteHeader(statusCode int) { + w.statusCode = statusCode +} + +func (w *ResponseWriter) APIGatewayProxyResponse() (*events.APIGatewayProxyResponse, error) { + ret := &events.APIGatewayProxyResponse{} + if w.statusCode != 0 { + ret.StatusCode = w.statusCode + } else { + ret.StatusCode = http.StatusOK + } + for key, values := range w.header { + if len(values) > 1 { + return nil, fmt.Errorf("header has multiple values: " + key) + } else if len(values) == 1 { + if ret.Headers == nil { + ret.Headers = map[string]string{} + } + ret.Headers[key] = values[0] + } + } + if w.body != nil { + ret.Body = base64.StdEncoding.EncodeToString(w.body.Bytes()) + ret.IsBase64Encoded = true + } + return ret, nil +} + +func NewRequest(request *events.APIGatewayProxyRequest) (*http.Request, error) { + resource, err := url.ParseRequestURI(request.Path) + if err != nil { + return nil, errors.Wrap(err, "unable to parse request URI") + } + if len(request.QueryStringParameters) > 0 { + query := url.Values{} + for key, value := range request.QueryStringParameters { + query.Set(key, value) + } + resource.RawQuery = query.Encode() + } + req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(request.HTTPMethod + " " + resource.RequestURI() + " HTTP/1.0\r\n\r\n"))) + if err != nil { + return nil, errors.Wrap(err, "unable to create request") + } + + req.Proto = "HTTP/1.1" + req.ProtoMinor = 1 + + if request.Body != "" { + var body []byte + if request.IsBase64Encoded { + body, err = base64.StdEncoding.DecodeString(request.Body) + if err != nil { + return nil, errors.Wrap(err, "unable to decode base64 body") + } + } else { + body = []byte(request.Body) + } + req.ContentLength = int64(len(body)) + req.Body = ioutil.NopCloser(bytes.NewReader(body)) + } + + for key, value := range request.Headers { + req.Header.Set(key, value) + } + req.Host = req.Header.Get("Host") + req.RemoteAddr = request.RequestContext.Identity.SourceIP + + return req, nil +} + +func Handler(handler http.Handler) func(*events.APIGatewayProxyRequest) (*events.APIGatewayProxyResponse, error) { + return func(request *events.APIGatewayProxyRequest) (*events.APIGatewayProxyResponse, error) { + req, err := NewRequest(request) + if err != nil { + return nil, err + } + resp := &ResponseWriter{} + handler.ServeHTTP(resp, req) + return resp.APIGatewayProxyResponse() + } +} + +func StartHTTPHandler() { + config, err := external.LoadDefaultAWSConfig() + if err != nil { + logrus.Fatal(err) + } + db, err := server.NewDynamoDBDatabase(dynamodb.New(config), os.Getenv("GGGTRACKER_DYNAMODB_TABLE")) + if err != nil { + logrus.Fatal(err) + } + defer db.Close() + + e := server.New(db, os.Getenv("GGGTRACKER_GA")) + lambda.Start(Handler(e)) +} + +func main() { + if !terminal.IsTerminal(unix.Stdout) { + logrus.SetFormatter(&logrus.JSONFormatter{}) + } + + flags := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + if err := flags.Parse(os.Args[1:]); err != nil { + if err == flag.ErrHelp { + // Exit with no error if --help was given. This is used to test the build. + os.Exit(0) + } + logrus.Fatal(err) + } + + StartHTTPHandler() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6e82471 --- /dev/null +++ b/go.mod @@ -0,0 +1,48 @@ +module github.com/ccbrown/gggtracker + +go 1.20 + +require ( + github.com/PuerkitoBio/goquery v1.3.0 + github.com/aws/aws-lambda-go v1.7.0 + github.com/aws/aws-sdk-go-v2 v0.16.0 + github.com/boltdb/bolt v1.3.1 + github.com/json-iterator/go v1.1.5 + github.com/labstack/echo v3.2.6+incompatible + github.com/pkg/errors v0.8.0 + github.com/sirupsen/logrus v1.0.4 + github.com/spf13/pflag v1.0.0 + github.com/spf13/viper v1.0.0 + github.com/stretchr/testify v1.2.2 + golang.org/x/crypto v0.0.0-20180228161326-91a49db82a88 + golang.org/x/sys v0.0.0-20180302081741-dd2ff4accc09 +) + +require ( + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/andybalholm/cascadia v1.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgrijalva/jwt-go v3.1.0+incompatible // indirect + github.com/fsnotify/fsnotify v1.4.7 // indirect + github.com/hashicorp/hcl v0.0.0-20171017181929-23c074d0eceb // indirect + github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af // indirect + github.com/labstack/gommon v0.2.2-0.20170925052817-57409ada9da0 // indirect + github.com/magiconair/properties v1.7.6 // indirect + github.com/mattn/go-colorable v0.0.9 // indirect + github.com/mattn/go-isatty v0.0.3 // indirect + github.com/mitchellh/mapstructure v0.0.0-20180220230111-00c29f56e238 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect + github.com/pelletier/go-toml v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/afero v1.0.2 // indirect + github.com/spf13/cast v1.2.0 // indirect + github.com/spf13/jwalterweatherman v0.0.0-20180109140146-7c0cea34c8ec // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 // indirect + golang.org/x/net v0.0.0-20181201002055-351d144fa1fc // indirect + golang.org/x/text v0.3.0 // indirect + gopkg.in/airbrake/gobrake.v2 v2.0.9 // indirect + gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 // indirect + gopkg.in/yaml.v2 v2.1.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3b536e7 --- /dev/null +++ b/go.sum @@ -0,0 +1,86 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/PuerkitoBio/goquery v1.3.0 h1:2LzdaeRwZjIMW7iKEei51jiCPB33mou4AI7QCzS4NgE= +github.com/PuerkitoBio/goquery v1.3.0/go.mod h1:T9ezsOHcCrDCgA8aF1Cqr3sSYbO/xgdy8/R/XiIMAhA= +github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o= +github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/aws/aws-lambda-go v1.7.0 h1:g3Ad7aw27B2lhQLIuK7Aha+cWSaHr7ZNlngveHkhZyo= +github.com/aws/aws-lambda-go v1.7.0/go.mod h1:zUsUQhAUjYzR8AuduJPCfhBuKWUaDbQiPOG+ouzmE1A= +github.com/aws/aws-sdk-go-v2 v0.16.0 h1:X5pkFnjRNdDEX18NwDGWMaWL5ocNQX0qIYEhEcsTy64= +github.com/aws/aws-sdk-go-v2 v0.16.0/go.mod h1:pFLIN9LDjOEwHfruGweAXEq0XaD6uRkY8FsRkxhuBIg= +github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= +github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.1.0+incompatible h1:FFziAwDQQ2dz1XClWMkwvukur3evtZx7x/wMHKM1i20= +github.com/dgrijalva/jwt-go v3.1.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/hashicorp/hcl v0.0.0-20171017181929-23c074d0eceb h1:1OvvPvZkn/yCQ3xBcM8y4020wdkMXPHLB4+NfoGWh4U= +github.com/hashicorp/hcl v0.0.0-20171017181929-23c074d0eceb/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/json-iterator/go v1.1.5 h1:gL2yXlmiIo4+t+y32d4WGwOjKGYcGOuyrg46vadswDE= +github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/labstack/echo v3.2.6+incompatible h1:28U/uXFFKBIP+VQIqq641LuYhMS7Br9ZlwEXERaohCc= +github.com/labstack/echo v3.2.6+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= +github.com/labstack/gommon v0.2.2-0.20170925052817-57409ada9da0 h1:7AIW1qc9sYYTZLamTsRKSmVvJDXkZZrIWXHDK4Gq4X0= +github.com/labstack/gommon v0.2.2-0.20170925052817-57409ada9da0/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4= +github.com/magiconair/properties v1.7.6 h1:U+1DqNen04MdEPgFiIwdOUiqZ8qPa37xgogX/sd3+54= +github.com/magiconair/properties v1.7.6/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mitchellh/mapstructure v0.0.0-20180220230111-00c29f56e238 h1:+MZW2uvHgN8kYvksEN3f7eFL2wpzk0GxmlFsMybWc7E= +github.com/mitchellh/mapstructure v0.0.0-20180220230111-00c29f56e238/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/pelletier/go-toml v1.1.0 h1:cmiOvKzEunMsAxyhXSzpL5Q1CRKpVv0KQsnAIcSEVYM= +github.com/pelletier/go-toml v1.1.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.0.4 h1:gzbtLsZC3Ic5PptoRG+kQj4L60qjK7H7XszrU163JNQ= +github.com/sirupsen/logrus v1.0.4/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/spf13/afero v1.0.2 h1:5bRmqmInNmNFkI9NG9O0Xc/Lgl9wOWWUUA/O8XZqTCo= +github.com/spf13/afero v1.0.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.2.0 h1:HHl1DSRbEQN2i8tJmtS6ViPyHx35+p51amrdsiTCrkg= +github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= +github.com/spf13/jwalterweatherman v0.0.0-20180109140146-7c0cea34c8ec h1:2ZXvIUGghLpdTVHR1UfvfrzoVlZaE/yOWC5LueIHZig= +github.com/spf13/jwalterweatherman v0.0.0-20180109140146-7c0cea34c8ec/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.0 h1:oaPbdDe/x0UncahuwiPxW1GYJyilRAdsPnq3e1yaPcI= +github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.0.0 h1:RUA/ghS2i64rlnn4ydTfblY8Og8QzcPtCcHvgMn+w/I= +github.com/spf13/viper v1.0.0/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 h1:gKMu1Bf6QINDnvyZuTaACm9ofY+PRh+5vFz4oxBZeF8= +github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw= +golang.org/x/crypto v0.0.0-20180228161326-91a49db82a88 h1:jLkAo/qlT9whgCLYC5GAJ9kcKrv3Wj8VCc4N+KJ4wpw= +golang.org/x/crypto v0.0.0-20180228161326-91a49db82a88/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +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-20181201002055-351d144fa1fc h1:a3CU5tJYVj92DY2LaA1kUkrsqD5/3mLDhx2NcNqyW+0= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sys v0.0.0-20180302081741-dd2ff4accc09 h1:wNPZbZUOH0tyqngVRXeF2iQm19+ssqyebJTCFBvxsow= +golang.org/x/sys v0.0.0-20180302081741-dd2ff4accc09/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/airbrake/gobrake.v2 v2.0.9 h1:7z2uVWwn7oVeeugY1DtlPAy5H+KYgB1KeKTnqjNatLo= +gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 h1:OAj3g0cR6Dx/R07QgQe8wkA9RNjB2u4i700xBkIT4e0= +gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +gopkg.in/yaml.v2 v2.1.1 h1:fxK3tv8mQPVEgxu/S2LJ040LyqiajHt+syP0CdDS/Sc= +gopkg.in/yaml.v2 v2.1.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go index 7af8c20..ad5ece1 100644 --- a/main.go +++ b/main.go @@ -2,12 +2,11 @@ package main import ( "fmt" - "net/http" - "path" + "strings" - log "github.com/Sirupsen/logrus" - "github.com/labstack/echo" - "github.com/labstack/echo/middleware" + "github.com/aws/aws-sdk-go-v2/aws/external" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + log "github.com/sirupsen/logrus" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -16,31 +15,45 @@ import ( func main() { pflag.IntP("port", "p", 8080, "the port to listen on") - pflag.String("staticdir", "./server/static", "the static files to serve") + pflag.String("staticdir", "", "this argument is ignored and will be removed") pflag.String("ga", "", "a google analytics account") - pflag.String("db", "./gggtracker.db", "the database path") + pflag.String("db", "./gggtracker.db", "the database file path") + pflag.String("dynamodb-table", "", "if given, DynamoDB will be used instead of a database file") pflag.String("forumsession", "", "the POESESSID cookie for a forum session") + pflag.String("reddit-auth", "", "the APPLICATION:SECRET to use as Reddit auth") viper.BindPFlags(pflag.CommandLine) pflag.Parse() viper.SetEnvPrefix("gggtracker") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) viper.AutomaticEnv() - e := echo.New() - - db, err := server.OpenDatabase(viper.GetString("db")) + var db server.Database + var err error + if tableName := viper.GetString("dynamodb-table"); tableName != "" { + config, err := external.LoadDefaultAWSConfig() + if err != nil { + log.Fatal(err) + } + db, err = server.NewDynamoDBDatabase(dynamodb.New(config), tableName) + } else { + db, err = server.NewBoltDatabase(viper.GetString("db")) + } if err != nil { log.Fatal(err) } defer db.Close() - redditIndexer, err := server.NewRedditIndexer(server.RedditIndexerConfiguration{ - Database: db, - }) - if err != nil { - log.Fatal(err) + if viper.GetString("reddit-auth") != "" { + redditIndexer, err := server.NewRedditIndexer(server.RedditIndexerConfiguration{ + Database: db, + Auth: viper.GetString("reddit-auth"), + }) + if err != nil { + log.Fatal(err) + } + defer redditIndexer.Close() } - defer redditIndexer.Close() if viper.GetString("forumsession") != "" { forumIndexer, err := server.NewForumIndexer(server.ForumIndexerConfiguration{ @@ -53,18 +66,6 @@ func main() { defer forumIndexer.Close() } - e.Use(middleware.Recover()) - - e.GET("/", server.IndexHandler(server.IndexConfiguration{ - GoogleAnalytics: viper.GetString("ga"), - })) - e.GET("/activity.json", server.ActivityHandler(db)) - e.GET("/rss", server.RSSHandler(db)) - e.GET("/rss.php", func(c echo.Context) error { - return c.Redirect(http.StatusMovedPermanently, server.AbsoluteURL(c, "/rss")) - }) - e.File("/favicon.ico", path.Join(viper.GetString("staticdir"), "favicon.ico")) - e.Static("/static", viper.GetString("staticdir")) - + e := server.New(db, viper.GetString("ga")) log.Fatal(e.Start(fmt.Sprintf(":%v", viper.GetInt("port")))) } diff --git a/server/activity_handler.go b/server/activity_handler.go index 6530cc1..6573a85 100644 --- a/server/activity_handler.go +++ b/server/activity_handler.go @@ -14,9 +14,24 @@ type jsonResponse struct { Next string `json:"next"` } -func ActivityHandler(db *Database) echo.HandlerFunc { +func ActivityHandler(db Database) echo.HandlerFunc { return func(c echo.Context) error { - activity, next := db.Activity(c.QueryParam("next"), 50) + locale := LocaleForRequest(c.Request()) + var filter func(Activity) bool + if c.QueryParams().Has("nohelp") && c.QueryParam("nohelp") != "false" { + filter = func(a Activity) bool { + if fp, ok := a.(*ForumPost); ok { + if fp.ForumId == locale.HelpForumId { + return false + } + } + return true + } + } + activity, next, err := db.Activity(locale, c.QueryParam("next"), 50, filter) + if err != nil { + return err + } response := jsonResponse{ Next: next, } @@ -35,6 +50,7 @@ func ActivityHandler(db *Database) echo.HandlerFunc { Data: a, }) } + c.Response().Header().Add("Cache-Control", "max-age=120") return c.JSON(200, response) } } diff --git a/server/bolt_database.go b/server/bolt_database.go new file mode 100644 index 0000000..5ded537 --- /dev/null +++ b/server/bolt_database.go @@ -0,0 +1,84 @@ +package server + +import ( + "encoding/base64" + + "github.com/boltdb/bolt" +) + +type BoltDatabase struct { + db *bolt.DB +} + +func NewBoltDatabase(path string) (*BoltDatabase, error) { + db, err := bolt.Open(path, 0600, nil) + if err != nil { + return nil, err + } + + db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte("activity")) + if err != nil { + return err + } + return nil + }) + + return &BoltDatabase{ + db: db, + }, nil +} + +func (db *BoltDatabase) AddActivity(activity []Activity) error { + return db.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("activity")) + for _, a := range activity { + k, v, err := marshalActivity(a) + if err != nil { + return err + } + b.Put(k, v) + } + return nil + }) +} + +func (db *BoltDatabase) Activity(locale *Locale, start string, count int, filter func(a Activity) bool) ([]Activity, string, error) { + ret := []Activity(nil) + next := "" + if err := db.db.View(func(tx *bolt.Tx) error { + c := tx.Bucket([]byte("activity")).Cursor() + var k, v []byte + if start == "" { + k, v = c.Last() + } else { + s, err := base64.RawURLEncoding.DecodeString(start) + if err != nil { + k, v = c.Last() + } else { + k, v = c.Seek(s) + if k != nil { + k, v = c.Prev() + } + } + } + for len(ret) < count && k != nil { + activity, err := unmarshalActivity(k, v) + if err != nil { + return err + } else if activity != nil && locale.ActivityFilter(activity) && (filter == nil || filter(activity)) { + ret = append(ret, activity) + next = base64.RawURLEncoding.EncodeToString(k) + } + k, v = c.Prev() + } + return nil + }); err != nil { + return nil, "", err + } + return ret, next, nil +} + +func (db *BoltDatabase) Close() error { + return db.db.Close() +} diff --git a/server/bolt_database_test.go b/server/bolt_database_test.go new file mode 100644 index 0000000..9e1c361 --- /dev/null +++ b/server/bolt_database_test.go @@ -0,0 +1,22 @@ +package server + +import ( + "io/ioutil" + "os" + "path" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBoltDatabase(t *testing.T) { + dir, err := ioutil.TempDir("testdata", "db") + require.NoError(t, err) + defer os.RemoveAll(dir) + + db, err := NewBoltDatabase(path.Join(dir, "test.db")) + require.NoError(t, err) + defer db.Close() + + testDatabase(t, db) +} diff --git a/server/common.go b/server/common.go index 459666c..048b4cc 100644 --- a/server/common.go +++ b/server/common.go @@ -1,6 +1,8 @@ package server import ( + "strings" + "github.com/labstack/echo" ) @@ -14,3 +16,17 @@ func AbsoluteURL(c echo.Context, resource string) string { } return scheme + "://" + c.Request().Host + resource } + +func SubdomainURL(c echo.Context, subdomain string) string { + scheme := c.Scheme() + if c.Request().Header.Get(echo.HeaderXForwardedProto) == "https" { + scheme = "https" + } + locale := LocaleForRequest(c.Request()) + host := strings.TrimPrefix(c.Request().Host, "www.") + host = strings.TrimPrefix(host, locale.Subdomain+".") + if subdomain != "" { + host = subdomain + "." + host + } + return scheme + "://" + host +} diff --git a/server/database.go b/server/database.go index 00a4891..8118b45 100644 --- a/server/database.go +++ b/server/database.go @@ -1,124 +1,88 @@ package server import ( - "encoding/base64" + "bytes" + "compress/gzip" "encoding/binary" - "encoding/json" + "io/ioutil" - "github.com/boltdb/bolt" + json "github.com/json-iterator/go" ) -type Database struct { - db *bolt.DB -} - -func OpenDatabase(path string) (*Database, error) { - db, err := bolt.Open(path, 0600, nil) - if err != nil { - return nil, err - } - - db.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists([]byte("activity")) - if err != nil { - return err - } - return nil - }) - - return &Database{ - db: db, - }, nil -} - -func (db *Database) Close() { - db.db.Close() -} - const ( ForumPostType = iota RedditCommentType RedditPostType ) -func (db *Database) AddActivity(activity []Activity) { - err := db.db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte("activity")) - for _, a := range activity { - buf, err := json.Marshal(a) - if err != nil { - return err - } - k := make([]byte, 10) - binary.BigEndian.PutUint64(k, uint64(a.ActivityTime().Unix())<<24) - switch a.(type) { - case *ForumPost: - k[5] = ForumPostType - case *RedditComment: - k[5] = RedditCommentType - case *RedditPost: - k[5] = RedditPostType - } - binary.BigEndian.PutUint32(k[6:], a.ActivityKey()) - b.Put(k, buf) - } - return nil - }) - if err != nil { - panic(err) +type Database interface { + AddActivity(activity []Activity) error + Activity(locale *Locale, start string, count int, filter func(a Activity) bool) ([]Activity, string, error) + Close() error +} + +const gzipMarker = 0 + +func marshalActivity(a Activity) (key, value []byte, err error) { + buf := &bytes.Buffer{} + buf.Write([]byte{gzipMarker}) + w := gzip.NewWriter(buf) + if err := json.NewEncoder(w).Encode(a); err != nil { + return nil, nil, err + } + w.Close() + k := make([]byte, 10) + binary.BigEndian.PutUint64(k, uint64(a.ActivityTime().Unix())<<24) + switch a.(type) { + case *ForumPost: + k[5] = ForumPostType + case *RedditComment: + k[5] = RedditCommentType + case *RedditPost: + k[5] = RedditPostType } + binary.BigEndian.PutUint32(k[6:], a.ActivityKey()) + return k, buf.Bytes(), nil } -func (db *Database) Activity(start string, count int) ([]Activity, string) { - ret := []Activity(nil) - next := "" - err := db.db.View(func(tx *bolt.Tx) error { - c := tx.Bucket([]byte("activity")).Cursor() - var k, v []byte - if start == "" { - k, v = c.Last() - } else { - s, err := base64.RawURLEncoding.DecodeString(start) - if err != nil { - k, v = c.Last() - } else { - k, v = c.Seek(s) - if k != nil { - k, v = c.Prev() - } - } +func unmarshalActivity(key, value []byte) (Activity, error) { + if len(value) > 0 && value[0] == gzipMarker { + r, err := gzip.NewReader(bytes.NewReader(value[1:])) + if err != nil { + return nil, err } - for i := 0; i < count && k != nil; i++ { - switch k[5] { - case ForumPostType: - post := &ForumPost{} - err := json.Unmarshal(v, post) - if err != nil { - return err - } - ret = append(ret, post) - case RedditCommentType: - comment := &RedditComment{} - err := json.Unmarshal(v, comment) - if err != nil { - return err - } - ret = append(ret, comment) - case RedditPostType: - post := &RedditPost{} - err := json.Unmarshal(v, post) - if err != nil { - return err - } - ret = append(ret, post) - } - next = base64.RawURLEncoding.EncodeToString(k) - k, v = c.Prev() + defer r.Close() + buf, err := ioutil.ReadAll(r) + if err != nil { + return nil, err + } + value = buf + } + + switch key[5] { + case ForumPostType: + post := &ForumPost{} + err := json.Unmarshal(value, post) + if err != nil { + return nil, err + } + if post.Id != 0 { + return post, nil + } + case RedditCommentType: + comment := &RedditComment{} + err := json.Unmarshal(value, comment) + if err != nil { + return nil, err + } + return comment, nil + case RedditPostType: + post := &RedditPost{} + err := json.Unmarshal(value, post) + if err != nil { + return nil, err } - return nil - }) - if err != nil { - panic(err) + return post, nil } - return ret, next + return nil, nil } diff --git a/server/database_test.go b/server/database_test.go index 55e7847..4193c8b 100644 --- a/server/database_test.go +++ b/server/database_test.go @@ -1,9 +1,6 @@ package server import ( - "io/ioutil" - "os" - "path" "testing" "time" @@ -11,14 +8,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestDatabase_ForumPosts(t *testing.T) { - dir, err := ioutil.TempDir("testdata", "db") - require.NoError(t, err) - defer os.RemoveAll(dir) +func testDatabase(t *testing.T, db Database) { + t.Run("ForumPosts", func(t *testing.T) { + testDatabase_ForumPosts(t, db) + }) +} - db, err := OpenDatabase(path.Join(dir, "test.db")) - require.NoError(t, err) - defer db.Close() +func testDatabase_ForumPosts(t *testing.T, db Database) { + locale := Locales[0] post1 := &ForumPost{ Id: 9000, @@ -34,18 +31,23 @@ func TestDatabase_ForumPosts(t *testing.T) { db.AddActivity([]Activity{post1, post2}) - posts, next := db.Activity("", 1) + all := func(a Activity) bool { return true } + + posts, next, err := db.Activity(locale, "", 1, all) + require.NoError(t, err) require.Equal(t, 1, len(posts)) assert.Equal(t, post1.Id, posts[0].(*ForumPost).Id) assert.Equal(t, post1.Poster, posts[0].(*ForumPost).Poster) assert.Equal(t, post1.Time.Unix(), posts[0].(*ForumPost).Time.Unix()) - posts, next = db.Activity(next, 1) + posts, next, err = db.Activity(locale, next, 1, all) + require.NoError(t, err) require.Equal(t, 1, len(posts)) assert.Equal(t, post2.Id, posts[0].(*ForumPost).Id) assert.Equal(t, post2.Poster, posts[0].(*ForumPost).Poster) assert.Equal(t, post2.Time.Unix(), posts[0].(*ForumPost).Time.Unix()) - posts, _ = db.Activity(next, 1) + posts, _, err = db.Activity(locale, next, 1, all) + require.NoError(t, err) require.Equal(t, 0, len(posts)) } diff --git a/server/dynamodb_database.go b/server/dynamodb_database.go new file mode 100644 index 0000000..cf7bdbc --- /dev/null +++ b/server/dynamodb_database.go @@ -0,0 +1,156 @@ +package server + +import ( + "context" + "encoding/base64" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" +) + +type DynamoDBDatabase struct { + client *dynamodb.Client + tableName string +} + +func NewDynamoDBDatabase(client *dynamodb.Client, tableName string) (*DynamoDBDatabase, error) { + return &DynamoDBDatabase{ + client: client, + tableName: tableName, + }, nil +} + +func dynamoDBActivityHashKey(locale *Locale) []byte { + return []byte("activity_by_locale:" + locale.Subdomain) +} + +func (db *DynamoDBDatabase) AddActivity(activity []Activity) error { + for _, locale := range Locales { + var remaining []Activity + for _, a := range activity { + if locale.ActivityFilter(a) { + remaining = append(remaining, a) + } + } + + for len(remaining) > 0 { + batch := remaining + const maxBatchSize = 25 + if len(batch) > maxBatchSize { + batch = batch[:maxBatchSize] + } + + writeRequests := make([]dynamodb.WriteRequest, len(batch)) + for i, a := range batch { + k, v, err := marshalActivity(a) + if err != nil { + return err + } + writeRequests[i] = dynamodb.WriteRequest{ + PutRequest: &dynamodb.PutRequest{ + Item: map[string]dynamodb.AttributeValue{ + "hk": dynamodb.AttributeValue{ + B: dynamoDBActivityHashKey(locale), + }, + "rk": dynamodb.AttributeValue{ + B: []byte(k), + }, + "v": dynamodb.AttributeValue{ + B: []byte(v), + }, + }, + }, + } + } + unprocessed := map[string][]dynamodb.WriteRequest{ + db.tableName: writeRequests, + } + + for len(unprocessed) > 0 { + result, err := db.client.BatchWriteItemRequest(&dynamodb.BatchWriteItemInput{ + RequestItems: unprocessed, + }).Send(context.Background()) + if err != nil { + return err + } + unprocessed = result.UnprocessedItems + } + + remaining = remaining[len(batch):] + } + } + return nil +} + +func (db *DynamoDBDatabase) Activity(locale *Locale, start string, count int, filter func(a Activity) bool) ([]Activity, string, error) { + var activity []Activity + + var startKey map[string]dynamodb.AttributeValue + if start != "" { + rk, _ := base64.RawURLEncoding.DecodeString(start) + startKey = map[string]dynamodb.AttributeValue{ + "hk": dynamodb.AttributeValue{ + B: dynamoDBActivityHashKey(locale), + }, + "rk": dynamodb.AttributeValue{ + B: rk, + }, + } + } + + condition := "hk = :hash" + attributeValues := map[string]dynamodb.AttributeValue{ + ":hash": dynamodb.AttributeValue{ + B: dynamoDBActivityHashKey(locale), + }, + } + + for len(activity) < count { + batchSize := count - len(activity) + if filter != nil { + // if we're filtering results, fetch extra + batchSize = count * 4 + if batchSize < 50 { + batchSize = 50 + } else if batchSize > 1000 { + batchSize = 1000 + } + } + result, err := db.client.QueryRequest(&dynamodb.QueryInput{ + TableName: aws.String(db.tableName), + KeyConditionExpression: aws.String(condition), + ExpressionAttributeValues: attributeValues, + ExclusiveStartKey: startKey, + Limit: aws.Int64(int64(batchSize)), + ScanIndexForward: aws.Bool(false), + }).Send(context.Background()) + if err != nil { + return nil, "", err + } + startKey = result.LastEvaluatedKey + for _, item := range result.Items { + if a, err := unmarshalActivity(item["rk"].B, item["v"].B); err != nil { + return nil, "", err + } else if a != nil && (filter == nil || filter(a)) { + activity = append(activity, a) + if len(activity) == count { + startKey = item + break + } + } + } + if result.LastEvaluatedKey == nil || len(activity) == count { + break + } + } + + var next string + if startKey != nil { + next = base64.RawURLEncoding.EncodeToString(startKey["rk"].B) + } + return activity, next, nil +} + +func (db *DynamoDBDatabase) Close() error { + return nil +} diff --git a/server/dynamodb_database_test.go b/server/dynamodb_database_test.go new file mode 100644 index 0000000..e448f0b --- /dev/null +++ b/server/dynamodb_database_test.go @@ -0,0 +1,111 @@ +package server + +import ( + "context" + "crypto/rand" + "encoding/base64" + "os" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/awserr" + "github.com/aws/aws-sdk-go-v2/aws/defaults" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/stretchr/testify/require" +) + +func newDynamoDBTestClient() (*dynamodb.Client, error) { + endpoint := os.Getenv("DYNAMODB_ENDPOINT") + + config := defaults.Config() + config.Region = "us-east-1" + config.EndpointResolver = aws.EndpointResolverFunc(func(service, region string) (aws.Endpoint, error) { + if endpoint != "" { + return aws.Endpoint{ + URL: endpoint, + }, nil + } + return aws.Endpoint{ + URL: "http://localhost:8000", + }, nil + }) + + credentialsBuf := make([]byte, 20) + if _, err := rand.Read(credentialsBuf); err != nil { + return nil, err + } + credentials := base64.RawURLEncoding.EncodeToString(credentialsBuf) + config.Credentials = aws.NewStaticCredentialsProvider(credentials, credentials, "") + config.Retryer = aws.DefaultRetryer{ + NumMaxRetries: 0, + } + + client := dynamodb.New(config) + if endpoint == "" { + if _, err := client.ListTablesRequest(&dynamodb.ListTablesInput{}).Send(context.Background()); err != nil { + if err, ok := err.(awserr.Error); ok && err.Code() == "RequestError" { + return nil, nil + } + } + } + return client, nil +} + +func createDynamoDBTable(client *dynamodb.Client, tableName string) error { + if _, err := client.CreateTableRequest(&dynamodb.CreateTableInput{ + AttributeDefinitions: []dynamodb.AttributeDefinition{ + { + AttributeName: aws.String("hk"), + AttributeType: dynamodb.ScalarAttributeTypeB, + }, { + AttributeName: aws.String("rk"), + AttributeType: dynamodb.ScalarAttributeTypeB, + }, + }, + KeySchema: []dynamodb.KeySchemaElement{ + { + AttributeName: aws.String("hk"), + KeyType: dynamodb.KeyTypeHash, + }, { + AttributeName: aws.String("rk"), + KeyType: dynamodb.KeyTypeRange, + }, + }, + ProvisionedThroughput: &dynamodb.ProvisionedThroughput{ + ReadCapacityUnits: aws.Int64(25), + WriteCapacityUnits: aws.Int64(25), + }, + TableName: &tableName, + }).Send(context.Background()); err != nil { + return err + } + return client.WaitUntilTableExists(context.Background(), &dynamodb.DescribeTableInput{ + TableName: aws.String(tableName), + }) +} + +func TestDynamoDBDatabase(t *testing.T) { + client, err := newDynamoDBTestClient() + require.NoError(t, err) + if client == nil { + t.Skip("launch a local dynamodb container to run this test: docker run --rm -it -p 8000:8000 dwmkerr/dynamodb -inMemory") + } + + const tableName = "TestDynamoDBDatabase" + + if _, err := client.DeleteTableRequest(&dynamodb.DeleteTableInput{ + TableName: aws.String(tableName), + }).Send(context.Background()); err == nil { + require.NoError(t, client.WaitUntilTableNotExists(context.Background(), &dynamodb.DescribeTableInput{ + TableName: aws.String(tableName), + })) + } + + require.NoError(t, createDynamoDBTable(client, tableName)) + + db, err := NewDynamoDBDatabase(client, tableName) + require.NoError(t, err) + defer db.Close() + + testDatabase(t, db) +} diff --git a/server/forum_indexer.go b/server/forum_indexer.go index ede70ad..915b049 100644 --- a/server/forum_indexer.go +++ b/server/forum_indexer.go @@ -13,72 +13,191 @@ import ( "github.com/PuerkitoBio/goquery" - log "github.com/Sirupsen/logrus" + log "github.com/sirupsen/logrus" ) +const UserAgent = "gggtracker.com (github.com/ccbrown/gggtracker)" + type ForumIndexer struct { configuration ForumIndexerConfiguration - closeSignal chan bool + closeSignal chan struct{} } type ForumIndexerConfiguration struct { - Database *Database + Database Database Session string } func NewForumIndexer(configuration ForumIndexerConfiguration) (*ForumIndexer, error) { ret := &ForumIndexer{ configuration: configuration, - closeSignal: make(chan bool), + closeSignal: make(chan struct{}), } go ret.run() return ret, nil } func (indexer *ForumIndexer) Close() { - indexer.closeSignal <- true + close(indexer.closeSignal) +} + +type ForumAccount struct { + Username string + Discriminator int } func (indexer *ForumIndexer) run() { log.Info("starting forum indexer") - posters := []string{ - "Chris", "Jonathan", "Erik", "Mark_GGG", "Samantha", "Rory", "Rhys", "Qarl", "Andrew_GGG", - "Damien_GGG", "Joel_GGG", "Ari", "Thomas", "BrianWeissman", "Edwin_GGG", "Support", "Dylan", - "MaxS", "Ammon_GGG", "Jess_GGG", "Robbie_GGG", "GGG_Neon", "Jason_GGG", "Henry_GGG", - "Michael_GGG", "Bex_GGG", "Cagan_GGG", "Daniel_GGG", "Kieren_GGG", "Yeran_GGG", "Gary_GGG", - "Dan_GGG", "Jared_GGG", "Brian_GGG", "RobbieL_GGG", "Arthur_GGG", "NickK_GGG", "Felipe_GGG", - "Alex_GGG", "Alexcc_GGG", "Andy", "CJ_GGG", "Eben_GGG", "Emma_GGG", "Ethan_GGG", - "Fitzy_GGG", "Hartlin_GGG", "Jake_GGG", "Lionel_GGG", "Melissa_GGG", "MikeP_GGG", "Novynn", - "Rachel_GGG", "Rob_GGG", "Roman_GGG", "Sarah_GGG", "SarahB_GGG", "Tom_GGG", "Natalia_GGG", + accounts := []ForumAccount{ + {Username: "Chris"}, + {Username: "Jonathan"}, + {Username: "Mark_GGG"}, + {Username: "Rory"}, + {Username: "Rhys"}, + {Username: "Joel_GGG"}, + {Username: "Ari"}, + {Username: "Thomas"}, + {Username: "Support"}, + {Username: "Jess_GGG"}, + {Username: "Robbie_GGG"}, + {Username: "GGG_Neon"}, + {Username: "Jason_GGG"}, + {Username: "Henry_GGG"}, + {Username: "Michael_GGG"}, + {Username: "Bex_GGG"}, + {Username: "Cagan_GGG"}, + {Username: "Kieren_GGG"}, + {Username: "Yeran_GGG"}, + {Username: "Gary_GGG"}, + {Username: "Dan_GGG"}, + {Username: "Jared_GGG"}, + {Username: "Brian_GGG"}, + {Username: "RobbieL_GGG"}, + {Username: "Arthur_GGG"}, + {Username: "NickK_GGG"}, + {Username: "Felipe_GGG"}, + {Username: "Alex_GGG"}, + {Username: "Alexcc_GGG"}, + {Username: "CJ_GGG"}, + {Username: "Eben_GGG"}, + {Username: "Emma_GGG"}, + {Username: "Ethan_GGG"}, + {Username: "Fitzy_GGG"}, + {Username: "Hartlin_GGG"}, + {Username: "Jake_GGG"}, + {Username: "Melissa_GGG"}, + {Username: "MikeP_GGG"}, + {Username: "Novynn"}, + {Username: "Rob_GGG"}, + {Username: "Roman_GGG"}, + {Username: "Tom_GGG"}, + {Username: "Natalia_GGG"}, + {Username: "Jeff_GGG"}, + {Username: "Lu_GGG"}, + {Username: "JuliaS_GGG"}, + {Username: "Alexander_GGG"}, + {Username: "SamC_GGG"}, + {Username: "AndrewE_GGG"}, + {Username: "Kyle_GGG"}, + {Username: "Stacey_GGG"}, + {Username: "Jatin_GGG"}, + {Username: "Community_Team"}, + {Username: "Nick_GGG"}, + {Username: "Guy_GGG"}, + {Username: "Ben_GGG"}, + {Username: "BenH_GGG"}, + {Username: "Nav_GGG"}, + {Username: "Will_GGG"}, + {Username: "Scott_GGG"}, + {Username: "JC_GGG"}, + {Username: "Dylan_GGG"}, + {Username: "Chulainn_GGG"}, + {Username: "Vash_GGG"}, + {Username: "Cameron_GGG"}, + {Username: "Jacob_GGG"}, + {Username: "Jenn_GGG"}, + {Username: "CoryA_GGG"}, + {Username: "Sian_GGG"}, + {Username: "Drew_GGG"}, + {Username: "Lisa_GGG"}, + {Username: "ThomasK_GGG"}, + {Username: "Whai_GGG"}, + {Username: "Scopey"}, + {Username: "Adam_GGG"}, + {Username: "Nichelle_GGG"}, + {Username: "Markus_GGG"}, + {Username: "Jarod_GGG"}, + {Username: "Joel_GGG", Discriminator: 1496}, + {Username: "Vinky_GGG"}, + {Username: "Edmund_GGG", Discriminator: 4844}, + {Username: "Clint"}, + {Username: "LeightonJ_GGG"}, + {Username: "Tai_GGG"}, + {Username: "ShaunB_GGG"}, + {Username: "Ayelen_GGG"}, + {Username: "Timothy_GGG"}, + {Username: "BenMH_GGG"}, + {Username: "Ian_GGG"}, + {Username: "EthanH_GGG"}, + {Username: "Yrone_GGG"}, + {Username: "Sam_GGG", Discriminator: 2420}, + {Username: "Ting_GGG", Discriminator: 2357}, + {Username: "Ramon_GGG"}, + {Username: "Sameer_GGG"}, + {Username: "TobyM_GGG"}, + {Username: "Lachlan_GGG"}, + {Username: "Ringatu_GGG"}, } - next := 0 timezone := (*time.Location)(nil) - for { + for timezone == nil { select { case <-indexer.closeSignal: return default: - if timezone == nil { - tz, err := indexer.sessionTimezone() - if err != nil { - log.WithError(err).Error("error getting forum timezone") + if tz, err := indexer.sessionTimezone(); err != nil { + log.WithError(err).Error("error getting forum timezone") + } else { + timezone = tz + log.WithField("timezone", timezone).Info("forum timezone obtained") + break + } + time.Sleep(time.Second) + } + } + + for { + for _, locale := range Locales { + select { + case <-indexer.closeSignal: + return + default: + logger := log.WithField("host", locale.ForumHost()) + if err := locale.RefreshForumIds(); err != nil { + logger.WithError(err).Error("error refreshing forum ids") } else { - timezone = tz - log.WithFields(log.Fields{ - "timezone": timezone, - }).Info("forum timezone obtained") + logger.Info("refreshed forum ids") } - } else { - indexer.index(posters[next], timezone) - next += 1 - if next >= len(posters) { - next = 0 + time.Sleep(time.Second) + } + } + for _, account := range accounts { + select { + case <-indexer.closeSignal: + return + default: + if err := indexer.index(account, timezone); err != nil { + if errors.Is(err, ErrForumMaintenance) { + log.Info("forum is under maintenance") + time.Sleep(30 * time.Second) + } else { + log.WithError(err).Error("error indexing forum account: " + account.Username) + } } + time.Sleep(time.Second) } - time.Sleep(time.Second) } } } @@ -99,7 +218,14 @@ func (indexer *ForumIndexer) requestDocument(resource string) (*goquery.Document Jar: jar, Timeout: time.Second * 10, } - resp, err := client.Get(urlString) + + req, err := http.NewRequest("GET", urlString, nil) + req.Header.Set("User-Agent", UserAgent) + if err != nil { + return nil, err + } + + resp, err := client.Do(req) if err != nil { return nil, err } @@ -108,18 +234,29 @@ func (indexer *ForumIndexer) requestDocument(resource string) (*goquery.Document return goquery.NewDocumentFromReader(resp.Body) } -var postURLExpression = regexp.MustCompile("^/forum/view-thread/([0-9]+)/page/([0-9]+)#p([0-9]+)") +var postURLExpression = regexp.MustCompile("^/forum/view-post/([0-9]+)") var threadURLExpression = regexp.MustCompile("^/forum/view-thread/([0-9]+)") var forumURLExpression = regexp.MustCompile("^/forum/view-forum/([0-9]+)") -func ScrapeForumPosts(doc *goquery.Document, timezone *time.Location) ([]*ForumPost, error) { +var ErrForumMaintenance = errors.New("forum is in maintenance") + +func ScrapeForumPosts(doc *goquery.Document, poster ForumAccount, timezone *time.Location) ([]*ForumPost, error) { posts := []*ForumPost(nil) err := error(nil) - doc.Find(".forumPostListTable tr").EachWithBreak(func(i int, sel *goquery.Selection) bool { + if doc.Find(".forumPostListTable").Length() == 0 { + err = errors.New("forum post list not found") + if topBar := doc.Find(".topBar"); topBar.Length() == 1 && topBar.Text() == "Down For Maintenance" { + err = ErrForumMaintenance + } + return nil, err + } + + doc.Find(".forumPostListTable > tbody > tr").EachWithBreak(func(i int, sel *goquery.Selection) bool { post := &ForumPost{ - Poster: sel.Find(".post_by_account").Text(), + Poster: poster.Username, + PosterDiscriminator: poster.Discriminator, } body, err := sel.Find(".content").Html() @@ -128,23 +265,21 @@ func ScrapeForumPosts(doc *goquery.Document, timezone *time.Location) ([]*ForumP } post.BodyHTML = body - text := sel.Find(".post_date").Text() - t, err := time.ParseInLocation("Jan _2, 2006 3:04:05 PM", text, timezone) - if err != nil { + timeText := sel.Find(".post_date").Text() + + if post.Time, err = time.ParseInLocation("Jan _2, 2006, 3:04:05 PM", timeText, timezone); err != nil { + log.WithField("text", timeText).Error("unable to parse time") return false } - post.Time = t sel.Find("a").Each(func(i int, sel *goquery.Selection) { href := sel.AttrOr("href", "") if match := postURLExpression.FindStringSubmatch(href); match != nil { n, _ := strconv.Atoi(match[1]) - post.ThreadId = n - n, _ = strconv.Atoi(match[2]) - post.PageNumber = n - n, _ = strconv.Atoi(match[3]) post.Id = n } else if match := threadURLExpression.FindStringSubmatch(href); match != nil { + n, _ := strconv.Atoi(match[1]) + post.ThreadId = n post.ThreadTitle = sel.Text() } else if match := forumURLExpression.FindStringSubmatch(href); match != nil { n, _ := strconv.Atoi(match[1]) @@ -164,39 +299,45 @@ func ScrapeForumPosts(doc *goquery.Document, timezone *time.Location) ([]*ForumP return posts, nil } -func (indexer *ForumIndexer) forumPosts(poster string, page int, timezone *time.Location) ([]*ForumPost, error) { - doc, err := indexer.requestDocument(fmt.Sprintf("/account/view-posts/%v/page/%v", poster, page)) +func (indexer *ForumIndexer) forumPosts(poster ForumAccount, page int, timezone *time.Location) ([]*ForumPost, error) { + doc, err := indexer.requestDocument(fmt.Sprintf("/account/view-posts/%v-%04d/page/%v", poster.Username, poster.Discriminator, page)) if err != nil { return nil, err } - return ScrapeForumPosts(doc, timezone) + posts, err := ScrapeForumPosts(doc, poster, timezone) + if err != nil { + return nil, err + } + return posts, nil } -func (indexer *ForumIndexer) index(poster string, timezone *time.Location) { +func (indexer *ForumIndexer) index(poster ForumAccount, timezone *time.Location) error { logger := log.WithFields(log.Fields{ "poster": poster, }) - cutoff := time.Now().Add(time.Hour * -12) + pageCutoff := time.Now().Add(-12 * time.Hour) + cutoff := time.Now().Add(-14 * 24 * time.Hour) activity := []Activity(nil) for page := 1; ; page++ { posts, err := indexer.forumPosts(poster, page, timezone) if err != nil { - logger.WithError(err).Error("error requesting forum posts") + return fmt.Errorf("error getting forum posts: %w", err) } done := len(posts) == 0 for _, post := range posts { - if post.Time.Before(cutoff) { + if post.Time.Before(pageCutoff) { done = true } + if post.Time.Before(cutoff) { + break + } activity = append(activity, post) } - logger.WithFields(log.Fields{ - "count": len(posts), - }).Info("received forum posts") + logger.WithField("count", len(activity)).Info("received forum posts") if done { break @@ -204,13 +345,15 @@ func (indexer *ForumIndexer) index(poster string, timezone *time.Location) { time.Sleep(time.Second) } - if len(activity) > 0 { - indexer.configuration.Database.AddActivity(activity) + if len(activity) == 0 { + return nil } + + return indexer.configuration.Database.AddActivity(activity) } func ScrapeForumTimezone(doc *goquery.Document) (*time.Location, error) { - sel := doc.Find("#timezone option[selected]") + sel := doc.Find(`select[name="preferences[timezone]"] option[selected]`) if sel == nil || sel.AttrOr("value", "") == "" { return nil, errors.New("unable to find timezone selection") } diff --git a/server/forum_indexer_test.go b/server/forum_indexer_test.go index f1600e5..4e6b756 100644 --- a/server/forum_indexer_test.go +++ b/server/forum_indexer_test.go @@ -21,7 +21,8 @@ func TestScrapeForumPosts(t *testing.T) { tz, err := time.LoadLocation("America/Los_Angeles") require.NoError(t, err) - posts, err := ScrapeForumPosts(doc, tz) + poster := ForumAccount{Username: "Chris"} + posts, err := ScrapeForumPosts(doc, poster, tz) require.NoError(t, err) assert.Equal(t, 10, len(posts)) @@ -31,11 +32,43 @@ func TestScrapeForumPosts(t *testing.T) { assert.Equal(t, 54, p.ForumId) assert.Equal(t, 1830139, p.ThreadId) assert.Equal(t, "Chris", p.Poster) + assert.Equal(t, 0, p.PosterDiscriminator) assert.Equal(t, "Photos of the Fan Meetup", p.ThreadTitle) assert.Equal(t, "Announcements", p.ForumName) - assert.Equal(t, 1, p.PageNumber) assert.Equal(t, "we had a great time too!", p.BodyHTML) assert.Equal(t, int64(1486332365), p.Time.Unix()) + + t.Run("LoggedOut", func(t *testing.T) { + f, err := os.Open("testdata/forum-posts-logged-out.html") + require.NoError(t, err) + defer f.Close() + + doc, err := goquery.NewDocumentFromReader(f) + require.NoError(t, err) + + tz, err := time.LoadLocation("America/Los_Angeles") + require.NoError(t, err) + + posts, err := ScrapeForumPosts(doc, poster, tz) + require.Error(t, err) + assert.Equal(t, 0, len(posts)) + }) + + t.Run("Maintenance", func(t *testing.T) { + f, err := os.Open("testdata/forum-maintenance.html") + require.NoError(t, err) + defer f.Close() + + doc, err := goquery.NewDocumentFromReader(f) + require.NoError(t, err) + + tz, err := time.LoadLocation("America/Los_Angeles") + require.NoError(t, err) + + posts, err := ScrapeForumPosts(doc, poster, tz) + assert.Equal(t, err, ErrForumMaintenance) + assert.Equal(t, 0, len(posts)) + }) } func TestScrapeForumTimezone(t *testing.T) { diff --git a/server/forum_post.go b/server/forum_post.go index 09afa7e..bd9971f 100644 --- a/server/forum_post.go +++ b/server/forum_post.go @@ -1,20 +1,49 @@ package server import ( + "encoding/json" "fmt" "time" ) type ForumPost struct { - Id int `json:"id"` - BodyHTML string `json:"body_html"` - Time time.Time `json:"time"` - Poster string `json:"poster"` - ThreadId int `json:"thread_id"` - ThreadTitle string `json:"thread_title"` - PageNumber int `json:"page_number"` - ForumId int `json:"forum_id"` - ForumName string `json:"forum_name"` + Id int `json:"id"` + BodyHTML string `json:"body_html"` + Time time.Time `json:"time"` + Poster string `json:"poster"` + PosterDiscriminator int `json:"poster_discriminator"` + ThreadId int `json:"thread_id"` + ThreadTitle string `json:"thread_title"` + ForumId int `json:"forum_id"` + ForumName string `json:"forum_name"` +} + +type forumPostWithHost struct { + Id int `json:"id"` + BodyHTML string `json:"body_html"` + Time time.Time `json:"time"` + Poster string `json:"poster"` + PosterDiscriminator int `json:"poster_discriminator"` + ThreadId int `json:"thread_id"` + ThreadTitle string `json:"thread_title"` + ForumId int `json:"forum_id"` + ForumName string `json:"forum_name"` + Host string `json:"host"` +} + +func (p ForumPost) MarshalJSON() ([]byte, error) { + return json.Marshal(&forumPostWithHost{ + Id: p.Id, + BodyHTML: p.BodyHTML, + Time: p.Time, + Poster: p.Poster, + PosterDiscriminator: p.PosterDiscriminator, + ThreadId: p.ThreadId, + ThreadTitle: p.ThreadTitle, + ForumId: p.ForumId, + ForumName: p.ForumName, + Host: p.Host(), + }) } func (p *ForumPost) ActivityTime() time.Time { @@ -25,6 +54,15 @@ func (p *ForumPost) ActivityKey() uint32 { return uint32(p.Id) } +func (p *ForumPost) Host() string { + for _, l := range Locales { + if l.ForumIds()[p.ForumId] { + return l.ForumHost() + } + } + return "www.pathofexile.com" +} + func (p *ForumPost) PostURL() string { - return fmt.Sprintf("https://www.pathofexile.com/forum/view-post/%v", p.Id) + return fmt.Sprintf("https://%v/forum/view-post/%v", p.Host(), p.Id) } diff --git a/server/index_handler.go b/server/index_handler.go index 404cfb9..2a291b1 100644 --- a/server/index_handler.go +++ b/server/index_handler.go @@ -11,22 +11,19 @@ var index = `
+
| - | Thread | -Poster | -Time | -Forum | +{{call $.Translate "Thread"}} | +{{call $.Translate "Poster"}} | +{{call $.Translate "Time"}} | +{{call $.Translate "Forum"}} | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ').attr('colspan', 6).text('Loading...'))) - $.get('activity.json?next=' + page, function(data) { + $.get('activity.json?next=' + page + canonicalNohelpParam, function(data) { var $tbody = $('#activity-table tbody'); $tbody.empty(); for (var i = 0; i < data.activity.length; ++i) { var type = data.activity[i].type; var activity = data.activity[i].data; + var subreddit = activity.subreddit || 'pathofexile'; var $tr = $(' | ||||||||||||||||||||||||||||||||||||||
| '); @@ -37,12 +48,12 @@ function loadActivity() { if (type == 'forum_post') { $tr.append($(' | ').append($('')
- .attr('href', 'https://www.pathofexile.com/forum/view-thread/' + activity.thread_id + '/filter-account-type/staff')
+ .attr('href', 'https://' + activity.host + '/forum/view-thread/' + activity.thread_id + '/filter-account-type/staff')
.append($(' '))
));
} else if (type == 'reddit_comment') {
$tr.append($(' | ').append($('')
- .attr('href', 'https://www.reddit.com/r/pathofexile/comments/' + activity.post_id)
+ .attr('href', 'https://www.reddit.com/r/' + subreddit + '/comments/' + activity.post_id)
.append($(' '))
));
} else {
@@ -54,7 +65,7 @@ function loadActivity() {
if (type == 'forum_post') {
$tr.append($(' | ').append($('') - .attr('href', 'https://www.pathofexile.com/forum/view-post/' + activity.id) + .attr('href', 'https://' + activity.host + '/forum/view-post/' + activity.id) .text(activity.thread_title) )); } else if (type == "reddit_post") { @@ -64,14 +75,14 @@ function loadActivity() { )); } else if (type == "reddit_comment") { $tr.append($(' | ').append($('') - .attr('href', 'https://www.reddit.com/r/pathofexile/comments/' + activity.post_id + '/-/' + activity.id + '/?context=3') + .attr('href', 'https://www.reddit.com/r/' + subreddit + '/comments/' + activity.post_id + '/-/' + activity.id + '/?context=3') .text(activity.post_title) )); } if (type == 'forum_post') { $tr.append($(' | ').append($('') - .attr('href', 'https://www.pathofexile.com/account/view-profile/' + encodeURIComponent(activity.poster)) + .attr('href', 'https://' + activity.host + '/account/view-profile/' + encodeURIComponent(activity.poster) + '-' + String(activity.poster_discriminator || 0).padStart(4, '0')) .text(activity.poster) )); } else { @@ -85,13 +96,13 @@ function loadActivity() { if (type == 'forum_post') { $tr.append($(' | ').append($('') - .attr('href', 'https://www.pathofexile.com/forum/view-forum/' + encodeURIComponent(activity.forum_id)) + .attr('href', 'https://' + activity.host + '/forum/view-forum/' + encodeURIComponent(activity.forum_id)) .text(activity.forum_name) )); } else { $tr.append($(' | ').append($('')
- .attr('href', 'https://www.reddit.com/r/pathofexile')
- .text('pathofexile')
+ .attr('href', 'https://www.reddit.com/r/' + subreddit)
+ .text(subreddit)
));
}
@@ -107,7 +118,7 @@ function loadActivity() {
$body.find('a').each(function() {
var r = $(this).attr('href');
if (r && (r.indexOf(':') < 0 || r.indexOf('/') <= r.indexOf(':'))) {
- var root = type == 'forum_post' ? 'https://www.pathofexile.com' : 'https://www.reddit.com';
+ var root = type == 'forum_post' ? 'https://' + activity.host : 'https://www.reddit.com';
$(this).attr('href', root + (r[0] == '/' ? '' : '/') + r);
}
});
@@ -134,14 +145,23 @@ function loadActivity() {
$tbody.append($tr);
}
- $('#activity-nav').empty().append($('').text('Next Page').attr('href', '#page=' + data.next).click(function() {
+ if (hideHelp) {
+ $('#hide-help-forum').hide();
+ $('#show-help-forum').attr('href', page ? '#page=' + page : '#').show();
+ } else {
+ $('#show-help-forum').hide();
+ $('#hide-help-forum').attr('href', (page ? '#page=' + page + '&' : '#') + 'nohelp').show();
+ }
+
+ $('#activity-nav').empty().append($('').text('Next Page').attr('href', '#page=' + data.next + canonicalNohelpParam).click(function() {
window.scrollTo(0, 0);
}));
}).fail(function() {
alert('Something went wrong. Better luck next time.');
currentPage = previousPage
if (currentPage !== undefined) {
- window.location.hash = 'page=' + currentPage;
+ var previousNohelpParam = previousHideHelp ? '&nohelp' : '';
+ window.location.hash = 'page=' + currentPage + previousNohelpParam;
} else {
window.location.hash = '';
}
diff --git a/server/static/style.css b/server/static/style.css
index c03a29c..f6714c3 100644
--- a/server/static/style.css
+++ b/server/static/style.css
@@ -1,6 +1,6 @@
a {
color: #C9BA8A;
- text-decoration: none;
+ text-decoration: none;
}
a:hover {
@@ -52,6 +52,23 @@ td {
text-align: right;
}
+div.notice {
+ background-color: #DFCF99;
+ padding: 0px 12px;
+ margin: 24px;
+ border: 1px solid #363636;
+ box-shadow: 3px 3px 16px 4px #000000;
+ width: 980px;
+ position: relative;
+ font-size: 14px;
+ color: #202020;
+}
+
+div.notice a, div.notice a:visited, div.notice a:active, div.notice a:hover {
+ color: #202020;
+ text-decoration: underline;
+}
+
div.content-box {
background-color: #202020;
padding: 12px;
@@ -82,6 +99,12 @@ div.container {
opacity: 1.0;
}
+#help-toggle {
+ position: absolute;
+ top: 24px;
+ right: 50px;
+}
+
div.content-box h1 {
padding: 6px;
padding-top: 0px;
@@ -107,11 +130,11 @@ footer {
}
footer a {
- text-decoration: underline;
+ text-decoration: underline;
}
td.poster {
- text-align: center;
+ text-align: center;
}
.forum td.poster a {
@@ -126,7 +149,7 @@ td.poster {
}
.reddit td.poster a {
- color: #f33f3f;
+ color: #f33f3f;
text-decoration: none;
font-weight: bold;
}
@@ -137,100 +160,124 @@ td.poster {
}
.reddit td {
- background-color: #07192c;
+ background-color: #07192c;
}
td.toggle {
- padding: 0px;
- width: 34px;
- height: 34px;
+ padding: 0px;
+ width: 34px;
+ height: 34px;
}
td.time {
- width: 200px;
- text-align: center;
+ width: 200px;
+ text-align: center;
}
td.forum {
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- max-width: 140px;
- text-align: center;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ max-width: 140px;
+ text-align: center;
}
.reddit .forum a::before {
- content: '/r/';
+ content: '/r/';
}
td.toggle {
- position: relative;
- padding: 0px;
+ position: relative;
+ padding: 0px;
}
td.toggle img {
- cursor: pointer;
- width: 18px;
- height: 18px;
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- filter: opacity(70%);
+ cursor: pointer;
+ width: 18px;
+ height: 18px;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ filter: opacity(70%);
}
.expander:hover, .collapser:hover {
- filter: opacity(100%);
+ filter: opacity(100%);
}
td.icon {
- position: relative;
- padding: 0px;
- width: 35px;
+ position: relative;
+ padding: 0px;
+ width: 35px;
}
td.icon img {
- position: absolute;
- height: 24px;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
+ position: absolute;
+ height: 24px;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+td.title {
+ max-width: 450px;
+ overflow: hidden;
}
td.body {
- background-color: #262626;
- padding: 10px;
+ background-color: #262626;
+ padding: 10px;
}
td.body blockquote {
- background: #323232;
- border-left: 5px solid #1a1a1a;
- margin: 1.5em 10px;
- padding: 0.5em 10px;
+ background: #323232;
+ border-left: 5px solid #1a1a1a;
+ margin: 1.5em 10px;
+ padding: 0.5em 10px;
+}
+
+td.body img {
+ max-width: 100%;
}
.spoilerHidden .spoilerContent {
- display: none;
+ display: none;
}
.spoilerTitle input[type="button"] {
- margin-left: 8px;
- color: #c9ba8a;
- background-color: #0f0f0f;
- border: 0;
- cursor: pointer;
- text-decoration: none;
+ margin-left: 8px;
+ color: #c9ba8a;
+ background-color: #0f0f0f;
+ border: 0;
+ cursor: pointer;
+ text-decoration: none;
}
.spoilerTitle input[type="button"]:hover {
- text-decoration: underline;
+ text-decoration: underline;
}
.spoiler {
- border: 1px solid #1a1a1a;
- background-color: #323232;
+ border: 1px solid #1a1a1a;
+ background-color: #323232;
}
.spoilerTitle, .spoilerContent {
- padding: 8px;
+ padding: 8px;
+}
+
+#locale-selection {
+ float: right;
+ list-style: none;
+ margin: 0px;
+}
+
+#locale-selection li {
+ display: inline;
+ opacity: 0.5;
+}
+
+#locale-selection li.selected-locale {
+ opacity: 1.0;
}
diff --git a/server/testdata/forum-maintenance.html b/server/testdata/forum-maintenance.html
new file mode 100644
index 0000000..6e1f145
--- /dev/null
+++ b/server/testdata/forum-maintenance.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
diff --git a/server/testdata/forum-posts-logged-out.html b/server/testdata/forum-posts-logged-out.html
new file mode 100644
index 0000000..c530980
--- /dev/null
+++ b/server/testdata/forum-posts-logged-out.html
@@ -0,0 +1,508 @@
+
+
+
+
+
+
+
+
+ Path of Exile
+
+
+
+
+
+
+
+
+
+
+ Down For Maintenance+
+
+
+ pathofexile.com is currently down for maintenance. Please try again later.
+
+
+
+ Tweets by pathofexile
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/testdata/forum-posts.html b/server/testdata/forum-posts.html
index 34b7a1f..9d23698 100644
--- a/server/testdata/forum-posts.html
+++ b/server/testdata/forum-posts.html
@@ -69,38 +69,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Login Required+
+
+
+
+
+ You must log in to visit this area.+
+
+
+
Forum: Announcements Thread: Upcoming Content Releases |
Looking to change your Supporter Titles? You can now find them here.
- +