├── .circleci ├── config.yml └── scripts │ └── read-changelog.go ├── .dockerignore ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── configs ├── config.example.json ├── config.go ├── config_test.go ├── constants │ └── constants.go ├── list_id.go └── timeout.go ├── controllers ├── account.go ├── analytics.go ├── bookmark.go ├── controller-factory.go ├── donation.go ├── errors.go ├── mail.go ├── membership.go ├── menuitems.go ├── news_v2.go ├── oauth.go ├── subscription.go └── user.go ├── dev-env-setup ├── docker-compose.yml ├── mysql │ ├── Dockerfile │ ├── initdb.sh │ └── mysql.cnf └── wait-for-it.sh ├── doc ├── auth.apib ├── donation.apib ├── index.apib ├── index.html ├── mail.apib ├── news │ ├── asset.apib │ ├── authors.apib │ ├── followup.apib │ ├── index_page.apib │ ├── post.apib │ ├── postcategory.apib │ ├── review.apib │ ├── tag.apib │ └── topic.apib ├── oauth.apib ├── periodic-donation.apib ├── prime-donation.apib ├── user-donation.apib └── user.apib ├── entrypoint.sh ├── globals ├── constants.go └── globals.go ├── go.mod ├── go.sum ├── internal ├── member_cms │ ├── graphql.go │ ├── method.go │ └── receipt.go ├── mongo │ ├── client.go │ ├── document.go │ └── operator.go ├── news │ ├── algolia.go │ ├── author.go │ ├── category.go │ ├── category_set.go │ ├── model.go │ ├── mongo.go │ ├── mongo_test.go │ ├── query.go │ ├── query_test.go │ └── section.go └── query │ └── query.go ├── main.go ├── membership_user.sql ├── middlewares ├── cache-control.go ├── jwt.go ├── mail-service.go └── recovery.go ├── migrations ├── 000001_add_existing_table_schemas.down.sql ├── 000001_add_existing_table_schemas.up.sql ├── 000002_add_linepay_info.down.sql ├── 000002_add_linepay_info.up.sql ├── 000003_update-send-receipt-default-value.down.sql ├── 000003_update-send-receipt-default-value.up.sql ├── 000004_update_payment_status_refunded.down.sql ├── 000004_update_payment_status_refunded.up.sql ├── 000005_increase_bank_result_msg_size.down.sql ├── 000005_increase_bank_result_msg_size.up.sql ├── 000006_add_receipt_header.down.sql ├── 000006_add_receipt_header.up.sql ├── 000007_add_donation_redesign_table_schemas.down.sql ├── 000007_add_donation_redesign_table_schemas.up.sql ├── 000008_update_donation_redesign_table_schemas.down.sql ├── 000008_update_donation_redesign_table_schemas.up.sql ├── 000009_add_mailjob_tables_schemas.down.sql ├── 000009_add_mailjob_tables_schemas.up.sql ├── 000010_create_roles_table.down.sql ├── 000010_create_roles_table.up.sql ├── 000011_create_users_roles_table.down.sql ├── 000011_create_users_roles_table.up.sql ├── 000012_add_users_activated.down.sql ├── 000012_add_users_activated.up.sql ├── 000013_add_roles_key.down.sql ├── 000013_add_roles_key.up.sql ├── 000014_add_user_source_and_multiple_roles.down.sql ├── 000014_add_user_source_and_multiple_roles.up.sql ├── 000015_add_user_reading_count_time.down.sql ├── 000015_add_user_reading_count_time.up.sql ├── 000016_add_user_reading_footprint.down.sql ├── 000016_add_user_reading_footprint.up.sql ├── 000017_increase_bookmarks_slug_size.down.sql ├── 000017_increase_bookmarks_slug_size.up.sql ├── 000018_update_users_bookmarks_schema.down.sql ├── 000018_update_users_bookmarks_schema.up.sql ├── 000019_change_ntch_roles.down.sql ├── 000019_change_ntch_roles.up.sql ├── 000020_add_receipt_no_column.down.sql ├── 000020_add_receipt_no_column.up.sql ├── 000021_add_receipt_serial_number_table.down.sql ├── 000021_add_receipt_serial_number_table.up.sql ├── 000022_add_name_to_users_table.down.sql └── 000022_add_name_to_users_table.up.sql ├── models ├── bookmark.go ├── donation.go ├── gin-response.go ├── model_user.go ├── oauth.go ├── registration.go ├── service.go ├── subscription.go ├── user_preferences.go ├── users_bookmarks.go └── users_posts.go ├── routers └── router.go ├── services └── mail.go ├── storage ├── analytics.go ├── bookmark.go ├── donation.go ├── errors.go ├── mailgroup.go ├── membership.go ├── news_v2.go ├── service.go ├── subscription.go └── user.go ├── template ├── authenticate.tmpl ├── role-actiontaker.tmpl ├── role-downgrade.tmpl ├── role-explorer.tmpl ├── role-trailblazer.tmpl ├── signin-otp.tmpl ├── signin.tmpl ├── success-donation-periodic.tmpl └── success-donation-prime.tmpl ├── tests ├── author_test.go ├── controller_account_test.go ├── controller_analytics_test.go ├── controller_bookmark_test.go ├── controller_donations_test.go ├── controller_mail_test.go ├── controller_subscriptions_test.go ├── controller_user_test.go ├── main_test.go ├── news_v2_test.go ├── pre_test_environment_setup.go └── structs.go └── utils ├── db.go ├── helpers.go ├── mocks ├── config.json └── mal-form-config.json ├── token.go └── utils.go /.circleci/scripts/read-changelog.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "go/build" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "strings" 11 | ) 12 | 13 | func main() { 14 | p, _ := build.Default.Import("github.com/twreporter/go-api", "", build.FindOnly) 15 | 16 | fname := filepath.Join(p.Dir, "CHANGELOG.md") 17 | file, err := os.Open(fname) 18 | if err != nil { 19 | fmt.Println("Cannot open CHANGELOG.md") 20 | return 21 | } 22 | 23 | defer file.Close() 24 | scanner := bufio.NewScanner(file) 25 | scanner.Split(bufio.ScanLines) 26 | 27 | re := regexp.MustCompile(`#{1,}\s+(?:\d+\.)(?:\d+\.)(?:\d+)`) 28 | for scanner.Scan() { 29 | ver := re.FindString(scanner.Text()) 30 | if ver != "" { 31 | // remove # 32 | ver = strings.Replace(ver, "#", "", -1) 33 | // remove whitespace 34 | ver = strings.Replace(ver, " ", "", -1) 35 | fmt.Print(ver) 36 | break 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | dev-env-setup 2 | tests 3 | doc 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Directories 2 | .idea 3 | .vscode 4 | build 5 | vendor 6 | tmp 7 | 8 | # Files 9 | .DS_Store 10 | go-api* 11 | gin-bin* 12 | configs/config.json 13 | cloud_sql_proxy 14 | dump.rdb 15 | runner.conf 16 | 17 | # Builds 18 | dev-env-setup/mysql/initdb.sql 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Define global user name 2 | ARG server_user=goapi 3 | 4 | # Start from a Alpine Linux image with the latest version of Go installed 5 | # and a workspace (GOPATH) configured at /go. 6 | FROM golang:1.14.4-alpine3.12 As build 7 | 8 | RUN apk add --update --no-cache \ 9 | tzdata \ 10 | ca-certificates \ 11 | git \ 12 | && update-ca-certificates 13 | 14 | ENV GO111MODULE on 15 | 16 | # Module cache pre-warm 17 | WORKDIR /go/cache 18 | 19 | COPY go.mod . 20 | COPY go.sum . 21 | RUN go mod download 22 | RUN go mod verify 23 | 24 | WORKDIR /go/src/github.com/twreporter/go-api 25 | 26 | # Copy the local package files to the container's workspace. 27 | COPY . . 28 | 29 | ENV DOCKERIZE_VERSION v0.6.1 30 | 31 | # Download mysql health check docker 32 | RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ 33 | && tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ 34 | && rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz 35 | 36 | # Build the migrate tool 37 | RUN MIGRATE_PATH=$(cat go.mod | grep migrate | awk '{print $1}') && \ 38 | go build -o /go/bin/migrate -tags 'mysql' ${MIGRATE_PATH}/cmd/migrate 39 | 40 | # Inherit global user argument 41 | ARG server_user 42 | 43 | # Add the user for running go-api 44 | RUN adduser -D -g '' ${server_user} 45 | 46 | # Install 47 | RUN go install 48 | 49 | # Minimize image size by only using the required binary 50 | FROM alpine:3.12 51 | 52 | COPY --from=build /go/bin /usr/local/bin /usr/local/bin/ 53 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 54 | COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo 55 | COPY --from=build /etc/passwd /etc/passwd 56 | 57 | ARG server_user 58 | 59 | WORKDIR /home/${server_user} 60 | 61 | COPY ./aws_credentials /home/${server_user}/.aws/credentials 62 | COPY ./pubsub_credentials /home/${server_user}/pubsub_credentials 63 | COPY ./entrypoint.sh /home/${server_user}/entrypoint.sh 64 | COPY ./migrations /home/${server_user}/migrations/ 65 | COPY ./template /home/${server_user}/template/ 66 | RUN chmod +x entrypoint.sh 67 | 68 | ENV GOOGLE_APPLICATION_CREDENTIALS /home/${server_user}/pubsub_credentials 69 | ENV MIGRATION_DIR /home/${server_user}/migrations/ 70 | 71 | # Specify the user for running go-api 72 | USER ${server_user} 73 | 74 | # Run the outyet command by default when the container starts. 75 | ENTRYPOINT ["./entrypoint.sh"] 76 | 77 | CMD ["go-api"] 78 | 79 | # Document that the service listens on port 8080. 80 | EXPOSE 8080 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-present, The Reporter, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DEV_ENV_SETUP_FOLDER ?= ./dev-env-setup 2 | DOCKER_COMPOSE_FILE ?= $(DEV_ENV_SETUP_FOLDER)/docker-compose.yml 3 | 4 | help: 5 | @echo "make env-up to build up environment by docker-compose" 6 | @echo "make env-down to stop/close environment by docker-compose" 7 | @echo "make start to start go-api server" 8 | @echo "make test to run the functional test" 9 | @echo "make create-migrations to create migration files(up&down). Enter migration_name from standard input." 10 | @echo "make upgrade-schema to the latest version." 11 | @echo "make downgrade-schema to remove all the migrations. Use with CAUTION." 12 | @echo "make goto-schema to go to the specific version. Enter schema_version from standard input. " 13 | 14 | env-up: 15 | @cp ./membership_user.sql $(DEV_ENV_SETUP_FOLDER)/mysql/initdb.sql 16 | @docker-compose -f $(DOCKER_COMPOSE_FILE) up --build -d 17 | 18 | env-down: 19 | @docker-compose -f $(DOCKER_COMPOSE_FILE) down 20 | 21 | start: 22 | @go run main.go 23 | 24 | test: 25 | @go test $$(glide novendor) 26 | 27 | DB_USER ?= test_membership 28 | DB_PASSWORD ?= test_membership 29 | DB_NAME ?= test_membership 30 | DB_ADDRESS ?= 127.0.0.1 31 | DB_PORT ?= 3306 32 | 33 | # Migration 34 | MIGRATION_NAME ?= $(shell read -p "Migration name: " migration_name; echo $$migration_name) 35 | MIGRATION_EXT ?= "sql" 36 | MIGRATION_DIR ?= "migrations" 37 | SCHEMA_VERSION ?= $(shell read -p "Schema version: " schema_version; echo $$schema_version) 38 | 39 | DB_CONN = mysql://$(DB_USER):$(DB_PASSWORD)@tcp\($(DB_HOST):$(DB_PORT)\)/$(DB_NAME) 40 | 41 | 42 | ################################################################################ 43 | # Database Migration 44 | # ################################################################################ 45 | 46 | create-migrations: check-migrate 47 | @migrate create -ext $(MIGRATION_EXT) -dir $(MIGRATION_DIR) -seq $(MIGRATION_NAME) 48 | 49 | upgrade-schema: check-migrate 50 | @echo Upgrade UP schema or to latest version 51 | migrate -database $(DB_CONN) -path $(MIGRATION_DIR) up $(UP) 52 | 53 | downgrade-schema: check-migrate 54 | @echo CAUTION: Remove DOWN schema or all the schema 55 | @migrate -database $(DB_CONN) -path $(MIGRATION_DIR) down $(DOWN) 56 | 57 | goto-schema: check-migrate 58 | @echo "Roll up or down to the specified version" 59 | @migrate -database $(DB_CONN) -path $(MIGRATION_DIR) goto $(SCHEMA_VERSION) 60 | 61 | force-schema: check-migrate 62 | @echo "Force to specified version without actually migrate" 63 | @migrate -database $(DB_CONN) -path $(MIGRATION_DIR) force $(SCHEMA_VERSION) 64 | 65 | check-version: check-migrate 66 | @echo "Check current migrate version" 67 | @migrate -database $(DB_CONN) -path $(MIGRATION_DIR) version 68 | 69 | check-migrate: 70 | @printf "Check if migrate CLI (https://github.com/golang-migrate/migrate/tree/master/cmd/migrate) is installed." 71 | @type migrate > /dev/null 72 | @echo ....OK 73 | 74 | .PHONY: help env-up env-down start test 75 | -------------------------------------------------------------------------------- /configs/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "Environment": "development", 3 | "CorsSettings": { 4 | "AllowOrigins": ["*"] 5 | }, 6 | "AppSettings": { 7 | "Protocol": "http", 8 | "Host": "testtest.twreporter.org", 9 | "Port": "8080", 10 | "Version": "v1", 11 | "Token": "", 12 | "Expiration": 168 13 | }, 14 | "EmailSettings": { 15 | "SMTPUsername": "", 16 | "SMTPPassword": "", 17 | "SMTPServer": "", 18 | "SMTPServerOwner": "", 19 | "SMTPPort": "", 20 | "ConnectionSecurity": "", 21 | "FeedbackName": "", 22 | "FeedbackEmail": "" 23 | }, 24 | "AmazonMailSettings": { 25 | "Sender": "", 26 | "AwsRegion": "", 27 | "CharSet": "" 28 | }, 29 | "DBSettings": { 30 | "Name": "test_membership", 31 | "User": "test_membership", 32 | "Password": "test_membership", 33 | "Address": "127.0.0.1", 34 | "Port": "3306" 35 | }, 36 | "MongoDBSettings": { 37 | "URL": "localhost", 38 | "DBName": "plate", 39 | "Timeout": 5 40 | }, 41 | "OauthSettings": { 42 | "FacebookSettings": { 43 | "ID": "", 44 | "Secret": "", 45 | "URL": "http://testtest.twreporter.org:8080/v1/auth/facebook/callback", 46 | "Statestr": "" 47 | }, 48 | "GoogleSettings": { 49 | "Id": "", 50 | "Secret": "", 51 | "Url": "http://testtest.twreporter.org:8080/v1/auth/google/callback", 52 | "Statestr": "" 53 | } 54 | }, 55 | "AlgoliaSettings": { 56 | "ApplicationID": "", 57 | "APIKey": "" 58 | }, 59 | "ConsumerSettings": { 60 | "Domain": "twreporter.org", 61 | "Protocol": "http", 62 | "Host": "testtest.twreporter.org", 63 | "Port": "3000" 64 | }, 65 | "EncryptSettings": { 66 | "Salt": "" 67 | }, 68 | "DonationSettings": { 69 | "TapPayPartnerKey": "YourTapPayPartnerKey", 70 | "TapPayUrl": "TapPaySandboxUrl", 71 | "CardSecretKey": "YourCardSecretKey" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /configs/config_test.go: -------------------------------------------------------------------------------- 1 | package configs_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/twreporter/go-api/configs" 10 | ) 11 | 12 | func TestLoadConf(t *testing.T) { 13 | // If environment variable is provided, 14 | // it should overwrite the default config 15 | t.Run("Environment variables overwrite default", func(t *testing.T) { 16 | 17 | const ( 18 | testEnvFirstLevel = "test" 19 | testEnvNestedConfig = "test_protocol" 20 | testEnvNestedUnderscoreKey = "test_issuer" 21 | testEnvNestedArray = "http://testhost1 http://testhost2" 22 | ) 23 | 24 | // First-level config 25 | os.Setenv("GOAPI_ENVIRONMENT", testEnvFirstLevel) 26 | // Nested config 27 | os.Setenv("GOAPI_APP_PROTOCOL", testEnvNestedConfig) 28 | // Nested config with underscore key 29 | os.Setenv("GOAPI_APP_JWT_ISSUER", testEnvNestedUnderscoreKey) 30 | // Nested config with array of values 31 | os.Setenv("GOAPI_CORS_ALLOW_ORIGINS", testEnvNestedArray) 32 | 33 | testConf, _ := configs.LoadConf("") 34 | 35 | //Validate output 36 | assert.Equal(t, testConf.Environment, testEnvFirstLevel) 37 | assert.Equal(t, testConf.App.Protocol, testEnvNestedConfig) 38 | assert.Equal(t, testConf.App.JwtIssuer, testEnvNestedUnderscoreKey) 39 | assert.Equal(t, testConf.Cors.AllowOrigins, []string{ 40 | "http://testhost1", 41 | "http://testhost2", 42 | }) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /configs/constants/constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | /* OAuth Types */ 5 | 6 | // Facebook ... 7 | Facebook = "Facebook" 8 | 9 | // Google ... 10 | Google = "Google" 11 | 12 | /* Gender Types */ 13 | 14 | // GenderMale ... 15 | GenderMale = "M" 16 | // GenderFemale ... 17 | GenderFemale = "F" 18 | // GenderOthers ... 19 | GenderOthers = "O" 20 | 21 | /* Privilege Types */ 22 | 23 | // PrivilegeRegistered ... 24 | PrivilegeRegistered = 5 25 | // PrivilegeMember ... 26 | PrivilegeMember = 10 27 | // PrivilegeAdmin ... 28 | PrivilegeAdmin = 50 29 | 30 | /* Role */ 31 | RoleExplorer = "explorer" 32 | RoleActionTaker = "action_taker" 33 | RoleTrailblazer = "trailblazer" 34 | 35 | RoleTrailblazerAmount = 500 36 | 37 | /* Member cms */ 38 | MemberCMSQueryTimeout = 5000 39 | ) 40 | -------------------------------------------------------------------------------- /configs/list_id.go: -------------------------------------------------------------------------------- 1 | package configs 2 | 3 | // Deprecated once v1 news endpoints are removed 4 | const ( 5 | HumanRightsAndSocietyListID = "5951db87507c6a0d00ab063c" 6 | EnvironmentAndEducationListID = "5951db9b507c6a0d00ab063d" 7 | PoliticsAndEconomyListID = "5951dbc2507c6a0d00ab0640" 8 | CultureAndArtListID = "57175d923970a5e46ff854db" 9 | InternationalListID = "5743d35a940ee41000e81f4a" 10 | LivingAndMedicalCareListID = "59783ad89092de0d00b41691" 11 | ReviewListID = "573177cb8c0c261000b3f6d2" 12 | PhotographyListID = "574d028748fa171000c45d48" 13 | ) 14 | -------------------------------------------------------------------------------- /configs/timeout.go: -------------------------------------------------------------------------------- 1 | package configs 2 | 3 | const ( 4 | // for controller 5 | TimeoutOfIndexPageController = 3 6 | ) 7 | -------------------------------------------------------------------------------- /controllers/analytics.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "context" 8 | "time" 9 | 10 | "gopkg.in/guregu/null.v3" 11 | "github.com/gin-gonic/gin" 12 | "github.com/twreporter/go-api/storage" 13 | "github.com/twreporter/go-api/models" 14 | "github.com/twreporter/go-api/internal/news" 15 | "go.mongodb.org/mongo-driver/bson/primitive" 16 | log "github.com/sirupsen/logrus" 17 | ) 18 | 19 | func NewAnalyticsController(gs storage.AnalyticsGormStorage, ms storage.AnalyticsMongoStorage) *AnalyticsController { 20 | return &AnalyticsController{gs, ms} 21 | } 22 | 23 | type AnalyticsController struct { 24 | gs storage.AnalyticsGormStorage 25 | ms storage.AnalyticsMongoStorage 26 | } 27 | 28 | type ( 29 | reqBody struct { 30 | PostID null.String `json:"post_id"` 31 | ReadPostsCount null.Bool `json:"read_posts_count"` 32 | ReadPostsSec null.Int `json:"read_posts_sec"` 33 | } 34 | respBody struct { 35 | UserID string `json:"user_id"` 36 | PostID string `json:"post_id"` 37 | ReadPostsCount null.Bool `json:"read_posts_count"` 38 | ReadPostsSec null.Int `json:"read_posts_sec"` 39 | } 40 | reqBodyFootprint struct { 41 | PostID null.String `json:"post_id"` 42 | } 43 | ) 44 | 45 | func (ac *AnalyticsController) SetUserAnalytics(c *gin.Context) (int, gin.H, error) { 46 | var req reqBody 47 | var resp respBody 48 | var isExisted bool 49 | var err error 50 | const twoHour = 7200 // seconds 51 | userID := c.Param("userID") 52 | if err = c.BindJSON(&req); err != nil { 53 | fmt.Println("Error decoding JSON:", err) 54 | return http.StatusBadRequest, gin.H{"status": "fail", "message": err.Error()}, nil 55 | } 56 | 57 | if req.PostID.Valid == false { 58 | return http.StatusBadRequest, gin.H{"status": "fail", "message": "post_id is required"}, nil 59 | } 60 | if req.ReadPostsSec.Valid && req.ReadPostsSec.Int64 < 0 { 61 | return http.StatusBadRequest, gin.H{"status": "fail", "message": "read_posts_sec cannot be negative"}, nil 62 | } 63 | 64 | readPostsSec := req.ReadPostsSec 65 | // read_post_sec maximum: 7200 seconds 66 | if readPostsSec.Valid && readPostsSec.Int64 > twoHour { 67 | log.Infof("read_posts_sec exceed two hour. seconds: %d, user: %s", req.ReadPostsSec.Int64, userID) 68 | readPostsSec = null.NewInt(twoHour, true) 69 | } 70 | resp.UserID = userID 71 | resp.PostID = req.PostID.String 72 | 73 | if null.Bool.ValueOrZero(req.ReadPostsCount) == true { 74 | isExisted, err = ac.gs.UpdateUserReadingPostCount(userID, req.PostID.String) 75 | if err != nil { 76 | return toResponse(err) 77 | } 78 | resp.ReadPostsCount = null.NewBool(true, true) 79 | } 80 | 81 | if null.Int.IsZero(readPostsSec) == false { 82 | // update read post time 83 | err = ac.gs.UpdateUserReadingPostTime(userID, req.PostID.String, int(readPostsSec.Int64)) 84 | if err != nil { 85 | return toResponse(err) 86 | } 87 | isExisted = false 88 | resp.ReadPostsSec = readPostsSec 89 | } 90 | 91 | if isExisted { 92 | return http.StatusOK, gin.H{"status": "success", "data": resp}, nil 93 | } 94 | return http.StatusCreated, gin.H{"status": "success", "data": resp}, nil 95 | } 96 | 97 | func (ac *AnalyticsController) GetUserAnalyticsReadingFootprint(c *gin.Context) (int, gin.H, error) { 98 | // parameter validation 99 | userID := c.Param("userID") 100 | limit, _ := strconv.Atoi(c.Query("limit")) 101 | offset, _ := strconv.Atoi(c.Query("offset")) 102 | 103 | if limit == 0 { 104 | limit = 10 105 | } 106 | 107 | // get footprint posts of target user 108 | footprints, total, err := ac.gs.GetFootprintsOfAUser(userID, limit, offset) 109 | if err != nil { 110 | return toResponse(err) 111 | } 112 | 113 | // fetch posts meta from mongo db 114 | type footprintMeta struct { 115 | bookmarkID string 116 | updatedAt time.Time 117 | } 118 | postIds := make([]string, len(footprints)) 119 | bookmarkMap := make(map[primitive.ObjectID]footprintMeta) 120 | for index, footprint := range footprints { 121 | postIds[index] = footprint.PostID 122 | objectID, err := primitive.ObjectIDFromHex(footprint.PostID) 123 | if err != nil { 124 | continue; 125 | } 126 | bookmarkMap[objectID] = footprintMeta{bookmarkID:footprint.BookmarkID, updatedAt:footprint.UpdatedAt} 127 | } 128 | var posts []news.MetaOfFootprint 129 | posts, err = ac.ms.GetPostsOfIDs(context.Background(), postIds) 130 | if err != nil { 131 | return toResponse(err) 132 | } 133 | 134 | // add bookmarks in posts 135 | for index, post := range posts { 136 | posts[index].BookmarkID = bookmarkMap[post.ID].bookmarkID 137 | posts[index].UpdatedAt = bookmarkMap[post.ID].updatedAt 138 | } 139 | 140 | return http.StatusOK, gin.H{"status": "ok", "records": posts, "meta": models.MetaOfResponse{ 141 | Total: total, 142 | Offset: offset, 143 | Limit: limit, 144 | }}, nil 145 | } 146 | 147 | func (ac *AnalyticsController) SetUserAnalyticsReadingFootprint(c *gin.Context) (int, gin.H, error) { 148 | var req reqBodyFootprint 149 | var isExisted bool 150 | var err error 151 | 152 | userID := c.Param("userID") 153 | if err = c.BindJSON(&req); err != nil { 154 | fmt.Println("Error decoding JSON:", err) 155 | return http.StatusBadRequest, gin.H{"status": "fail", "message": err.Error()}, nil 156 | } 157 | 158 | if req.PostID.Valid == false { 159 | return http.StatusBadRequest, gin.H{"status": "fail", "message": "post_id is required"}, nil 160 | } 161 | 162 | isExisted, err = ac.gs.UpdateUserReadingFootprint(userID, req.PostID.String) 163 | if err != nil { 164 | return toResponse(err) 165 | } 166 | 167 | if isExisted { 168 | return http.StatusOK, gin.H{"status": "success"}, nil 169 | } 170 | return http.StatusCreated, gin.H{"status": "success"}, nil 171 | } 172 | -------------------------------------------------------------------------------- /controllers/bookmark.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/pkg/errors" 9 | 10 | "github.com/twreporter/go-api/models" 11 | ) 12 | 13 | // GetBookmarksOfAUser given userID this func will list all the bookmarks belongs to the user 14 | func (mc *MembershipController) GetBookmarksOfAUser(c *gin.Context) (int, gin.H, error) { 15 | var err error 16 | var bookmarks []models.UserBookmark 17 | var bookmark models.Bookmark 18 | var total int 19 | 20 | // get userID according to the url param 21 | userID := c.Param("userID") 22 | 23 | // get bookmarkSlug in url param 24 | bookmarkSlug := c.Param("bookmarkSlug") 25 | 26 | // Get a specific bookmark from a user 27 | if bookmarkSlug != "" { 28 | host := c.Query("host") 29 | 30 | if bookmark, err = mc.Storage.GetABookmarkOfAUser(userID, bookmarkSlug, host); err != nil { 31 | return toResponse(err) 32 | } 33 | 34 | return http.StatusOK, gin.H{"status": "ok", "record": bookmark}, nil 35 | } 36 | 37 | limit, _ := strconv.Atoi(c.Query("limit")) 38 | offset, _ := strconv.Atoi(c.Query("offset")) 39 | 40 | if limit == 0 { 41 | limit = 10 42 | } 43 | 44 | if bookmarks, total, err = mc.Storage.GetBookmarksOfAUser(userID, limit, offset); err != nil { 45 | return toResponse(err) 46 | } 47 | 48 | // TODO The response JSON should be like 49 | // { 50 | // "status": "success", 51 | // "data": { 52 | // "meta": meta, 53 | // "records": bookmarks 54 | // } 55 | // } 56 | return http.StatusOK, gin.H{"status": "ok", "records": bookmarks, "meta": models.MetaOfResponse{ 57 | Total: total, 58 | Offset: offset, 59 | Limit: limit, 60 | }}, nil 61 | } 62 | 63 | // DeleteABookmarkOfAUser given userID and bookmarkHref, this func will remove the relationship between user and bookmark 64 | func (mc *MembershipController) DeleteABookmarkOfAUser(c *gin.Context) (int, gin.H, error) { 65 | bookmarkID := c.Param("bookmarkID") 66 | userID := c.Param("userID") 67 | 68 | if err := mc.Storage.DeleteABookmarkOfAUser(userID, bookmarkID); err != nil { 69 | return toResponse(err) 70 | } 71 | 72 | return http.StatusNoContent, gin.H{}, nil 73 | } 74 | 75 | // CreateABookmarkOfAUser given userID and bookmark POST body, this func will try to create bookmark record in the bookmarks table, 76 | // and build the relationship between bookmark and user 77 | func (mc *MembershipController) CreateABookmarkOfAUser(c *gin.Context) (int, gin.H, error) { 78 | var bookmark models.Bookmark 79 | var err error 80 | 81 | userID := c.Param("userID") 82 | if bookmark, err = mc.parseBookmarkPOSTBody(c); err != nil { 83 | // For legacy code, the response returns with status "error" 84 | // TODO rewrite with status "fail" 85 | return http.StatusBadRequest, gin.H{"status": "error", "message": err.Error()}, nil 86 | } 87 | 88 | if bookmark, err = mc.Storage.CreateABookmarkOfAUser(userID, bookmark); err != nil { 89 | return toResponse(err) 90 | } 91 | 92 | // TODO The response JSON should be like 93 | // { 94 | // "status": "success", 95 | // "data": bookmark 96 | // } 97 | return http.StatusCreated, gin.H{"status": "ok", "record": bookmark}, nil 98 | } 99 | 100 | func (mc *MembershipController) parseBookmarkPOSTBody(c *gin.Context) (models.Bookmark, error) { 101 | var bm models.Bookmark 102 | 103 | if err := c.Bind(&bm); err != nil { 104 | return models.Bookmark{}, errors.Wrap(err, "POST body is neither JSON nor x-www-form-urlencoded") 105 | } 106 | return bm, nil 107 | } 108 | -------------------------------------------------------------------------------- /controllers/controller-factory.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/twreporter/go-api/internal/news" 8 | 9 | "github.com/globalsign/mgo" 10 | "github.com/jinzhu/gorm" 11 | "github.com/twreporter/go-api/globals" 12 | "github.com/twreporter/go-api/services" 13 | "github.com/twreporter/go-api/storage" 14 | "github.com/twreporter/go-api/utils" 15 | "go.mongodb.org/mongo-driver/mongo" 16 | ) 17 | 18 | // ControllerFactory generates controlloers by given persistent storage connection 19 | // and mail service 20 | type ControllerFactory struct { 21 | gormDB *gorm.DB 22 | mgoSession *mgo.Session 23 | mailService services.MailService 24 | mongoClient *mongo.Client 25 | indexClient news.AlgoliaSearcher 26 | } 27 | 28 | // GetOAuthController returns OAuth struct 29 | func (cf *ControllerFactory) GetOAuthController(oauthType string) (oauth *OAuth) { 30 | gs := storage.NewGormStorage(cf.gormDB) 31 | oauth = &OAuth{Storage: gs} 32 | if oauthType == globals.GoogleOAuth { 33 | oauth.InitGoogleConfig() 34 | } else { 35 | oauth.InitFacebookConfig() 36 | } 37 | 38 | return oauth 39 | } 40 | 41 | // GetMembershipController returns *MembershipController struct 42 | func (cf *ControllerFactory) GetMembershipController() *MembershipController { 43 | gs := storage.NewGormStorage(cf.gormDB) 44 | return NewMembershipController(gs) 45 | } 46 | 47 | // GetAnalyticsController returns *AnalyticsController struct 48 | func (cf *ControllerFactory) GetAnalyticsController() *AnalyticsController { 49 | gs := storage.NewAnalyticsGormStorage(cf.gormDB) 50 | ms := storage.NewAnalyticsMongoStorage(cf.mongoClient) 51 | return NewAnalyticsController(gs, ms) 52 | } 53 | 54 | func (cf *ControllerFactory) GetNewsV2Controller() *newsV2Controller { 55 | return NewNewsV2Controller(storage.NewMongoV2Storage(cf.mongoClient), cf.indexClient, storage.NewNewsV2SqlStorage(cf.gormDB)) 56 | } 57 | 58 | // GetMailController returns *MailController struct 59 | func (cf *ControllerFactory) GetMailController() *MailController { 60 | var contrl *MailController 61 | 62 | contrl = NewMailController(cf.mailService, nil) 63 | 64 | templateDir := os.Getenv("GOAPI_HTML_TEMPLATE_DIR") 65 | 66 | if templateDir == "" { 67 | templateDir = utils.GetProjectRoot() + "/template" 68 | } 69 | 70 | contrl.LoadTemplateFiles( 71 | fmt.Sprintf("%s/signin.tmpl", templateDir), 72 | fmt.Sprintf("%s/signin-otp.tmpl", templateDir), 73 | fmt.Sprintf("%s/success-donation-prime.tmpl", templateDir), 74 | fmt.Sprintf("%s/success-donation-periodic.tmpl", templateDir), 75 | fmt.Sprintf("%s/authenticate.tmpl", templateDir), 76 | fmt.Sprintf("%s/role-explorer.tmpl", templateDir), 77 | fmt.Sprintf("%s/role-actiontaker.tmpl", templateDir), 78 | fmt.Sprintf("%s/role-trailblazer.tmpl", templateDir), 79 | fmt.Sprintf("%s/role-downgrade.tmpl", templateDir), 80 | ) 81 | 82 | return contrl 83 | } 84 | 85 | // GetMailService returns MailService it holds 86 | func (cf *ControllerFactory) GetMailService() services.MailService { 87 | return cf.mailService 88 | } 89 | 90 | // GetMgoSession returns *mgo.Session it holds 91 | func (cf *ControllerFactory) GetMgoSession() *mgo.Session { 92 | return cf.mgoSession 93 | } 94 | 95 | // GetMgoSession returns *gorm.DB it holds 96 | func (cf *ControllerFactory) GetGormDB() *gorm.DB { 97 | return cf.gormDB 98 | } 99 | 100 | // NewControllerFactory generate *ControllerFactory struct 101 | func NewControllerFactory(gormDB *gorm.DB, mgoSession *mgo.Session, mailSvc services.MailService, client *mongo.Client, sClient news.AlgoliaSearcher) *ControllerFactory { 102 | return &ControllerFactory{ 103 | gormDB: gormDB, 104 | mgoSession: mgoSession, 105 | mailService: mailSvc, 106 | mongoClient: client, 107 | indexClient: sClient, 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /controllers/errors.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/pkg/errors" 9 | 10 | "github.com/twreporter/go-api/storage" 11 | ) 12 | 13 | func toResponse(err error) (int, gin.H, error) { 14 | cause := errors.Cause(err) 15 | 16 | // For legacy storage errors, the NotFound and Conflict error type is responsed with status "error" if no explicit handlers in controller layer. 17 | // Try to migrate these errors to status "fail". 18 | // TODO: adjust client side errors 19 | switch { 20 | case storage.IsNotFound(err): 21 | return http.StatusNotFound, gin.H{"status": "error", "message": fmt.Sprintf("record not found. %s", cause.Error())}, nil 22 | case storage.IsConflict(err): 23 | return http.StatusConflict, gin.H{"status": "error", "message": fmt.Sprintf("record is already existed. %s", cause.Error())}, nil 24 | default: 25 | // omit itentionally 26 | } 27 | 28 | return http.StatusInternalServerError, gin.H{"status": "error", "message": fmt.Sprintf("internal server error. %s", cause.Error())}, nil 29 | } 30 | 31 | func toPostResponse(err error) (int, gin.H, error) { 32 | cause := errors.Cause(err) 33 | 34 | // For legacy post storage errors, payload contained `status`:"[Custom Error Message]" and `error`:"[Detail message]" 35 | // if no explicit handlers in controller layer specified. 36 | // Try to migrate these errors to `status`:"fail" or "error" and replace `error`field with corresponding `data` or `message` field 37 | // adhered to jsend payload. 38 | // TODO: adjust error payloads 39 | switch { 40 | case storage.IsNotFound(err): 41 | return http.StatusNotFound, gin.H{"status": fmt.Sprintf("record not found. %s", cause.Error()), "error": cause.Error()}, nil 42 | case storage.IsConflict(err): 43 | return http.StatusConflict, gin.H{"status": fmt.Sprintf("record is already existed. %s", cause.Error()), "error": cause.Error()}, nil 44 | default: 45 | // omit itentionally 46 | } 47 | 48 | return http.StatusInternalServerError, gin.H{"status": fmt.Sprintf("internal server error. %s", cause.Error()), "error": cause.Error()}, nil 49 | } 50 | -------------------------------------------------------------------------------- /controllers/membership.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/twreporter/go-api/storage" 5 | ) 6 | 7 | // NewMembershipController ... 8 | func NewMembershipController(s storage.MembershipStorage) *MembershipController { 9 | return &MembershipController{s} 10 | } 11 | 12 | // MembershipController ... 13 | type MembershipController struct { 14 | Storage storage.MembershipStorage 15 | } 16 | 17 | // Close is the method of Controller interface 18 | func (mc *MembershipController) Close() error { 19 | err := mc.Storage.Close() 20 | if err != nil { 21 | return err 22 | } 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /controllers/menuitems.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | // MenuItemsController ... 8 | type MenuItemsController struct{} 9 | 10 | // Retrieve ... 11 | func (u MenuItemsController) Retrieve(c *gin.Context) { 12 | c.JSON(200, gin.H{ 13 | "message": "pong", 14 | }) 15 | return 16 | } 17 | -------------------------------------------------------------------------------- /controllers/subscription.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "hash/crc32" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/twreporter/go-api/models" 10 | ) 11 | 12 | // IsWebPushSubscribed - which handles the HTTP Get request, 13 | // and try to check if the web push subscription is existed or not 14 | func (mc *MembershipController) IsWebPushSubscribed(c *gin.Context) (int, gin.H, error) { 15 | endpoint := c.Query("endpoint") 16 | if endpoint == "" { 17 | return http.StatusNotFound, gin.H{"status": "error", "message": "Fail to get a web push subscription since you do not provide endpoint in URL query param"}, nil 18 | } 19 | 20 | crc32Endpoint := crc32.Checksum([]byte(endpoint), crc32.IEEETable) 21 | 22 | wpSub, err := mc.Storage.GetAWebPushSubscription(crc32Endpoint, endpoint) 23 | if err != nil { 24 | return toResponse(err) 25 | } 26 | 27 | return http.StatusOK, gin.H{"status": "success", "data": wpSub}, nil 28 | } 29 | 30 | // SubscribeWebPush - which handles the HTTP POST request, 31 | // and try to create a web push subscription record into the persistent database 32 | func (mc *MembershipController) SubscribeWebPush(c *gin.Context) (int, gin.H, error) { 33 | // subscriptionBody is to store POST body 34 | type subscriptionBody struct { 35 | Endpoint string `json:"endpoint" form:"endpoint" binding:"required"` 36 | Keys string `json:"keys" form:"keys" binding:"required"` 37 | ExpirationTime string `json:"expirationTime" form:"expirationTime"` 38 | UserID string `json:"user_id" form:"user_id"` 39 | } 40 | 41 | var endpoint string 42 | var err error 43 | var expirationTime int64 44 | var crc32Endpoint uint32 45 | var sBody subscriptionBody 46 | var userID uint64 47 | var wpSub models.WebPushSubscription 48 | 49 | if err = c.Bind(&sBody); err != nil { 50 | return http.StatusBadRequest, gin.H{"status": "fail", "data": subscriptionBody{ 51 | Endpoint: "endpoint is required, and need to be a string", 52 | Keys: "keys is required, and need to be a string", 53 | ExpirationTime: "expirationTime is optional, if provide, need to be a string of timestamp", 54 | UserID: "user_id is optional, if provide, need to be a string", 55 | }}, nil 56 | } 57 | 58 | endpoint = sBody.Endpoint 59 | crc32Endpoint = crc32.Checksum([]byte(endpoint), crc32.IEEETable) 60 | 61 | wpSub = models.WebPushSubscription{ 62 | Endpoint: endpoint, 63 | Crc32Endpoint: crc32Endpoint, 64 | Keys: sBody.Keys, 65 | } 66 | 67 | if userID, err = strconv.ParseUint(sBody.UserID, 10, 0); err == nil { 68 | wpSub.SetUserID(uint(userID)) 69 | } 70 | 71 | if expirationTime, err = strconv.ParseInt(sBody.ExpirationTime, 10, 64); err == nil { 72 | wpSub.SetExpirationTime(expirationTime) 73 | } 74 | 75 | if err = mc.Storage.CreateAWebPushSubscription(wpSub); err != nil { 76 | return toResponse(err) 77 | } 78 | 79 | return http.StatusCreated, gin.H{"status": "success", "data": sBody}, nil 80 | } 81 | -------------------------------------------------------------------------------- /controllers/user.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/pkg/errors" 11 | "github.com/twreporter/go-api/globals" 12 | "github.com/twreporter/go-api/models" 13 | ) 14 | 15 | // GetUser given userID, this func will try to get the user record, joined with users_mailgroup table, from DB 16 | func (mc *MembershipController) GetUser(c *gin.Context) (int, gin.H, error) { 17 | userID := c.Param("userID") 18 | 19 | user, err := mc.Storage.GetUserByID(userID) 20 | if err != nil { 21 | return toResponse(err) 22 | } 23 | 24 | roles := make([]gin.H, len(user.Roles)) 25 | for i, role := range user.Roles { 26 | roles[i] = gin.H{ 27 | "id": role.ID, // does frontend need ID? 28 | "name": role.Name, 29 | "name_en": role.NameEn, 30 | "key": role.Key, 31 | } 32 | } 33 | 34 | var activated *time.Time 35 | if user.Activated.Valid && !user.Activated.Time.IsZero() { 36 | activated = &user.Activated.Time 37 | } 38 | 39 | mailGroups := make([]string, 0) 40 | for _, group := range user.MailGroups { 41 | for key, value := range globals.Conf.Mailchimp.InterestIDs { 42 | if value == group.MailgroupID { 43 | mailGroups = append(mailGroups, key) 44 | break 45 | } 46 | } 47 | } 48 | readPreferenceArr := make([]string, 0) 49 | if user.ReadPreference.Valid { 50 | readPreferenceArr = strings.Split(user.ReadPreference.String, ",") 51 | } 52 | 53 | return http.StatusOK, gin.H{"status": "success", "data": gin.H{ 54 | "user_id": userID, 55 | "first_name": user.FirstName.String, 56 | "last_name": user.LastName.String, 57 | "email": user.Email.String, 58 | "registration_date": user.RegistrationDate.Time, 59 | "activated": activated, 60 | "roles": roles, 61 | "read_preference": readPreferenceArr, 62 | "maillist": mailGroups, 63 | "agree_data_collection": user.AgreeDataCollection, 64 | "read_posts_count": user.ReadPostsCount, 65 | "read_posts_sec": user.ReadPostsSec, 66 | }, 67 | }, nil 68 | } 69 | 70 | // SetUser given userID and POST body, this func will try to create record in the related table, 71 | // and build the relationship between records and user 72 | func (mc *MembershipController) SetUser(c *gin.Context) (int, gin.H, error) { 73 | userID := c.Param("userID") 74 | var preferences models.UserPreference 75 | err := c.BindJSON(&preferences) 76 | if err != nil { 77 | fmt.Println("Error decoding JSON:", err) 78 | } 79 | 80 | // Convert maillist values using the mapping array 81 | maillists := make([]string, 0) 82 | for _, maillist := range preferences.Maillist { 83 | convertedMaillist, exists := globals.Conf.Mailchimp.InterestIDs[maillist] 84 | if !exists { 85 | return http.StatusBadRequest, gin.H{"status": "error", "message": "invalid maillist value"}, errors.New("Invalid maillist value") 86 | } 87 | maillists = append(maillists, convertedMaillist) 88 | } 89 | 90 | // Call UpdateReadPreferenceOfUser to save the preferences.ReadPreference to DB 91 | if err = mc.Storage.UpdateReadPreferenceOfUser(userID, preferences.ReadPreference); err != nil { 92 | return toResponse(err) 93 | } 94 | 95 | // Call CreateMaillistOfUser to save the preferences.Maillist to DB 96 | if err = mc.Storage.CreateMaillistOfUser(userID, maillists); err != nil { 97 | return toResponse(err) 98 | } 99 | 100 | return http.StatusCreated, gin.H{"status": "ok", "record": preferences}, nil 101 | } 102 | -------------------------------------------------------------------------------- /dev-env-setup/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | mysql: 4 | build: mysql/ 5 | ports: 6 | - '3306:3306' 7 | mongodb: 8 | image: thereporter/dev-mongo-go-api 9 | ports: 10 | - '27017:27017' 11 | depends_on: 12 | - mysql 13 | volumes: 14 | - ./wait-for-it.sh:/dev-env-setup/wait-for-it.sh 15 | command: > 16 | bash -c "/dev-env-setup/wait-for-it.sh mysql:3306 --timeout=120 17 | && mongod" 18 | -------------------------------------------------------------------------------- /dev-env-setup/mysql/Dockerfile: -------------------------------------------------------------------------------- 1 | # Start from official MySQL image 2 | From mysql:5.7.21 3 | 4 | # Add go-api test user 5 | ENV MYSQL_ALLOW_EMPTY_PASSWORD=true \ 6 | MYSQL_DATABASE=gorm \ 7 | MYSQL_USER=gorm \ 8 | MYSQL_PASSWORD=gorm 9 | 10 | # Add scripts for database scheme and developer account initialization 11 | RUN mkdir -p /init 12 | 13 | ADD initdb.sql /init/ 14 | ADD initdb.sh /docker-entrypoint-initdb.d/ 15 | ADD mysql.cnf /etc/mysql/conf.d/ 16 | 17 | EXPOSE 3306 18 | -------------------------------------------------------------------------------- /dev-env-setup/mysql/initdb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | mysql -u root -e "CREATE DATABASE IF NOT EXISTS test_membership" 4 | 5 | mysql -u root test_membership < /init/initdb.sql 6 | 7 | mysql -u root -e "CREATE USER 'test_membership'@'%' IDENTIFIED BY 'test_membership'" 8 | 9 | mysql -u root -e "GRANT ALL PRIVILEGES ON test_membership.* TO 'test_membership'@'%'" 10 | -------------------------------------------------------------------------------- /dev-env-setup/mysql/mysql.cnf: -------------------------------------------------------------------------------- 1 | # general client configuration 2 | [client] 3 | default-character-set=utf8mb4 4 | 5 | [mysqld] 6 | character-set-server=utf8mb4 7 | collation-server=utf8mb4_unicode_ci 8 | 9 | # SET character_set_client = utf8mb4 10 | # SET character_set_results = utf8mb4 11 | # SET character_set_connection = utf8mb4 12 | init_connect='SET NAMES utf8mb4' 13 | 14 | # "mysql" client configuration 15 | [mysql] 16 | default-character-set=utf8mb4 17 | -------------------------------------------------------------------------------- /dev-env-setup/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | cmdname=$(basename $0) 5 | 6 | echoerr() { if [[ $QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | wait_for() 25 | { 26 | if [[ $TIMEOUT -gt 0 ]]; then 27 | echoerr "$cmdname: waiting $TIMEOUT seconds for $HOST:$PORT" 28 | else 29 | echoerr "$cmdname: waiting for $HOST:$PORT without a timeout" 30 | fi 31 | start_ts=$(date +%s) 32 | while : 33 | do 34 | if [[ $ISBUSY -eq 1 ]]; then 35 | nc -z $HOST $PORT 36 | result=$? 37 | else 38 | (echo > /dev/tcp/$HOST/$PORT) >/dev/null 2>&1 39 | result=$? 40 | fi 41 | if [[ $result -eq 0 ]]; then 42 | end_ts=$(date +%s) 43 | echoerr "$cmdname: $HOST:$PORT is available after $((end_ts - start_ts)) seconds" 44 | break 45 | fi 46 | sleep 1 47 | done 48 | return $result 49 | } 50 | wait_for_wrapper() 51 | { 52 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 53 | if [[ $QUIET -eq 1 ]]; then 54 | timeout $BUSYTIMEFLAG $TIMEOUT $0 --quiet --child --host=$HOST --port=$PORT --timeout=$TIMEOUT & 55 | else 56 | timeout $BUSYTIMEFLAG $TIMEOUT $0 --child --host=$HOST --port=$PORT --timeout=$TIMEOUT & 57 | fi 58 | PID=$! 59 | trap "kill -INT -$PID" INT 60 | wait $PID 61 | RESULT=$? 62 | if [[ $RESULT -ne 0 ]]; then 63 | echoerr "$cmdname: timeout occurred after waiting $TIMEOUT seconds for $HOST:$PORT" 64 | fi 65 | return $RESULT 66 | } 67 | # process arguments 68 | while [[ $# -gt 0 ]] 69 | do 70 | case "$1" in 71 | *:* ) 72 | hostport=(${1//:/ }) 73 | HOST=${hostport[0]} 74 | PORT=${hostport[1]} 75 | shift 1 76 | ;; 77 | --child) 78 | CHILD=1 79 | shift 1 80 | ;; 81 | -q | --quiet) 82 | QUIET=1 83 | shift 1 84 | ;; 85 | -s | --strict) 86 | STRICT=1 87 | shift 1 88 | ;; 89 | -h) 90 | HOST="$2" 91 | if [[ $HOST == "" ]]; then break; fi 92 | shift 2 93 | ;; 94 | --host=*) 95 | HOST="${1#*=}" 96 | shift 1 97 | ;; 98 | -p) 99 | PORT="$2" 100 | if [[ $PORT == "" ]]; then break; fi 101 | shift 2 102 | ;; 103 | --port=*) 104 | PORT="${1#*=}" 105 | shift 1 106 | ;; 107 | -t) 108 | TIMEOUT="$2" 109 | if [[ $TIMEOUT == "" ]]; then break; fi 110 | shift 2 111 | ;; 112 | --timeout=*) 113 | TIMEOUT="${1#*=}" 114 | shift 1 115 | ;; 116 | --) 117 | shift 118 | CLI=("$@") 119 | break 120 | ;; 121 | --help) 122 | usage 123 | ;; 124 | *) 125 | echoerr "Unknown argument: $1" 126 | usage 127 | ;; 128 | esac 129 | done 130 | if [[ "$HOST" == "" || "$PORT" == "" ]]; then 131 | echoerr "Error: you need to provide a host and port to test." 132 | usage 133 | fi 134 | TIMEOUT=${TIMEOUT:-15} 135 | STRICT=${STRICT:-0} 136 | CHILD=${CHILD:-0} 137 | QUIET=${QUIET:-0} 138 | # check to see if timeout is from busybox? 139 | # check to see if timeout is from busybox? 140 | TIMEOUT_PATH=$(realpath $(which timeout)) 141 | if [[ $TIMEOUT_PATH =~ "busybox" ]]; then 142 | ISBUSY=1 143 | BUSYTIMEFLAG="-t" 144 | else 145 | ISBUSY=0 146 | BUSYTIMEFLAG="" 147 | fi 148 | if [[ $CHILD -gt 0 ]]; then 149 | wait_for 150 | RESULT=$? 151 | exit $RESULT 152 | else 153 | if [[ $TIMEOUT -gt 0 ]]; then 154 | wait_for_wrapper 155 | RESULT=$? 156 | else 157 | wait_for 158 | RESULT=$? 159 | fi 160 | fi 161 | if [[ $CLI != "" ]]; then 162 | if [[ $RESULT -ne 0 && $STRICT -eq 1 ]]; then 163 | echoerr "$cmdname: strict mode, refusing to execute subprocess" 164 | exit $RESULT 165 | fi 166 | exec "${CLI[@]}" 167 | else 168 | exit $RESULT 169 | fi 170 | -------------------------------------------------------------------------------- /doc/auth.apib: -------------------------------------------------------------------------------- 1 | ## Data Structures 2 | 3 | ### SigninRequest 4 | + email: user@example.com (required) 5 | + onboarding: https://accounts-twreporter.org/onboarding 6 | + destination: https://www.twreporter.org 7 | + errorRedirection: https://www.twreporter.org 8 | 9 | ### SigninResponse 10 | + data (object, required) - The user data 11 | + email: example@email.com (string) - The user email 12 | 13 | # Group Membership Service 14 | Twreporter Membership service api 15 | 16 | ## Signin [/v2/signin] 17 | Validate the logining user and send the activation email 18 | 19 | ### User signins [POST] 20 | + Request with Body (application/json) 21 | 22 | + Attributes(SigninRequest) 23 | 24 | + Response 200 (application/json) 25 | 26 | + Attributes 27 | + status: success (required) 28 | + data (SigninRequest, required) 29 | 30 | + Response 201 (application/json) 31 | 32 | + Attributes 33 | + status: success (required) 34 | + data (SigninRequest, required) 35 | 36 | + Response 400 37 | 38 | + Attributes 39 | + status: fail (required) 40 | + data 41 | + email: "email is required" 42 | + destination: "destination is optional" 43 | 44 | + Response 500 45 | 46 | + Attributes 47 | + status: error (required) 48 | + message: "Generating active token occurs error" 49 | 50 | 51 | ## Authenticate [/v2/authenticate] 52 | Validate the logining user and send the authentication email 53 | 54 | ### User authenticates [POST] 55 | + Request with Body (application/json) 56 | 57 | + Attributes(SigninRequest) 58 | 59 | + Response 200 (application/json) 60 | 61 | + Attributes 62 | + status: success (required) 63 | + data (SigninRequest, required) 64 | 65 | + Response 201 (application/json) 66 | 67 | + Attributes 68 | + status: success (required) 69 | + data (SigninRequest, required) 70 | 71 | + Response 400 72 | 73 | + Attributes 74 | + status: fail (required) 75 | + data 76 | + email: "email is required" 77 | + destination: "destination is optional" 78 | 79 | + Response 500 80 | 81 | + Attributes 82 | + status: error (required) 83 | + message: "Generating active token occurs error" 84 | 85 | ## Activate [/v2/activate{?email,token,destination}] 86 | Send identity token if valid user sigins 87 | 88 | ### Activate user [GET] 89 | + Parameters 90 | + email: user@example.com (required) 91 | + token: 26dlFidiTVY= (required) 92 | + destination: https://www.twreporter.org (optional) 93 | 94 | + Response 302 95 | 96 | + Headers 97 | 98 | Set-Cookie: id_token=; Domain=twreporter.org; Max-Age=15552000; HttpOnly; Secure 99 | 100 | ## Token [/v2/token] 101 | Authenticate user request and grant access token to the corresponding domain 102 | 103 | ### Dispatch access token [POST] 104 | + Request 105 | 106 | + Headers 107 | 108 | Cookie: id_token= 109 | 110 | + Response 200 111 | 112 | + Attributes 113 | + status: success (required) 114 | + data 115 | + jwt: access_token (required) 116 | 117 | + Response 401 118 | 119 | + Attributes 120 | + status: fail (required) 121 | + data 122 | + `req.Headers.Cookie.id_token`: id_token is invalid 123 | 124 | + Response 500 125 | 126 | + Attributes 127 | + status: error (required) 128 | + message: cannot get user data 129 | 130 | ## Logout [/v2/logout{?destination}] 131 | Invalidate the identity token set on the root domain 132 | 133 | ### User logouts [GET] 134 | + Parameters 135 | + destination: https://www.twreporter.org 136 | 137 | + Response 302 138 | 139 | ## 6-digit OTP Logins [/v3/signin] 140 | Validate the logining user and send the signin email with 6-digit code 141 | 142 | ### SignInV3 [POST] 143 | 144 | + Request with Body (application/json) 145 | 146 | + Attributes 147 | + email (string, required) - The email of the user 148 | 149 | + Response 200 (application/json) 150 | 151 | + Attributes 152 | + status: success (string, required) 153 | + data(SigninResponse) 154 | 155 | + Response 400 156 | 157 | + Attributes 158 | + status: fail (required) 159 | + message: Bad Request - The request body is missing required parameters or contains invalid data 160 | 161 | + Response 500 162 | 163 | + Attributes 164 | + status: error (required) 165 | + message: Internal Server Error - An error occurred while processing the request 166 | 167 | ## 6-digit OTP Logins [/v3/activate] 168 | Verify the logining user with email and 6-digit code 169 | 170 | ### ActivateV3 [POST] 171 | 172 | + Request with Body (application/json) 173 | 174 | + Attributes 175 | + email (string, required) - The email of the user 176 | + otp_code (string, required) - The 6-digit code in the signin mail 177 | 178 | + Response 200 (application/json) 179 | 180 | + Attributes 181 | + status: success (string, required) 182 | + data(SigninResponse) 183 | 184 | + Response 400 185 | 186 | + Attributes 187 | + status: fail (required) 188 | + message: Bad Request - The request body is missing required parameters or contains invalid data 189 | 190 | + Response 500 191 | 192 | + Attributes 193 | + status: error (required) 194 | + message: Internal Server Error - An error occurred while processing the request 195 | -------------------------------------------------------------------------------- /doc/donation.apib: -------------------------------------------------------------------------------- 1 | # Group Tappay Synchronization 2 | 3 | ## Tappay Record [/v1/tappay_query] 4 | 5 | ### Post to get a tappay record[POST] 6 | Post to get a tappay record 7 | 8 | + Request 9 | 10 | + Attributes (TappayServerRequest) 11 | 12 | + Headers 13 | 14 | Content-Type: application/json 15 | Cookie: id_token= 16 | Authorization: Bearer 17 | 18 | + Response 200 19 | 20 | + Attributes 21 | + status: Success (required) 22 | + data (TappayServerResponse) (required) 23 | 24 | + Response 401 (application/json) 25 | 26 | + Attributes (Error401Response) 27 | 28 | + Response 403 (application/json) 29 | 30 | + Attributes (Error403Response) 31 | 32 | + Response 404 (application/json) 33 | 34 | + Body 35 | 36 | { 37 | "status": "fail", 38 | "data": { 39 | "filters": "{\"order_number\": \"twreporter-123\"} cannot address a existing resource." 40 | } 41 | } 42 | 43 | + Response 500 44 | 45 | + Attributes (Error500Response) 46 | 47 | ## Data Structures 48 | ### TappayServerRequest 49 | + records_per_page: 1 (optional, number) 50 | + filters 51 | + `order_number`: `twreporter-153371414230837160610` (required) 52 | + `rec_trade_id`: LN201711088cHQHr (optional) 53 | + `bank_transaction_id`: TP201711088cHQHr (optional) 54 | 55 | ### TappayTradeRecords 56 | + record_status: 0 (number) 57 | 58 | ### TappayServerResponse 59 | + status: 0 (required, number) 60 | + msg: "" (required) 61 | + trade_records (array[TappayTradeRecords]) 62 | -------------------------------------------------------------------------------- /doc/index.apib: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: https://go-api.twreporter.org 3 | 4 | # TWreporter Go API 5 | TWReporter API for main site(https://www.twreporter.org) 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /doc/mail.apib: -------------------------------------------------------------------------------- 1 | # Group Email Service 2 | Email service of TWreporter Go API 3 | 4 | ## Thank-You Donation Email [/v1/mail/send_success_donation] 5 | Send thank-you donation email to a user. 6 | The email contains the following attributes 7 | - address - address of the user 8 | - amount - donation amount 9 | - card_info_last_four - last four number of credit card, e.g. 4242 10 | - card_info_type - type of credit card, e.g. VISA 11 | - currency - donation currency, e.g. TWD 12 | - donation_timestamp - timestamp of donation made, e.g. 1541641779 13 | - donation_link - URL of the web page of the donation 14 | - donation_method - the way user pay the money, e.g. 信用卡支付 15 | - donation_type - donation type, e.g. 定期定額 16 | - email - email of the user 17 | - name - name of the user 18 | - national_id - national id of the user 19 | - order_number - donation order number 20 | - phone_number - phone number of the user 21 | 22 | ### Send a Thank-You Donation Email to a User [POST] 23 | + Request 24 | 25 | + Headers 26 | 27 | Content-Type: application/json 28 | Authorization: Bearer 29 | 30 | + Attributes (DonationSuccessMailModel) 31 | 32 | + Response 204 33 | 34 | + Response 400 (application/json) 35 | 36 | + Body 37 | 38 | { 39 | "status": "fail", 40 | "data": { 41 | "address": "address is optional" 42 | "amount": "amount(number) is required", 43 | "card_info_last_four": "card_info_last_four is optional", 44 | "card_info_type": "card_info_type is optional", 45 | "currency": "currency is optional, default is TWD", 46 | "donation_timestamp": "donation_timestamp is optional, default is current timestamp", 47 | "donation_link": "donation_link is optional", 48 | "donation_method": "donation_method is required", 49 | "donation_type": "donation_type is required", 50 | "email": "email is required", 51 | "name": "name is optional", 52 | "national_id": "national_id is optional", 53 | "order_number": "order_number is required", 54 | "phone_number": "phone_number is optional" 55 | } 56 | } 57 | 58 | + Response 401 (application/json) 59 | 60 | + Body 61 | 62 | { 63 | "status": "fail", 64 | "data": { 65 | "req.Headers.Authorization": "JWT is not valid" 66 | } 67 | } 68 | 69 | + Response 500 (application/json) 70 | 71 | 72 | + Body 73 | 74 | { 75 | "status": "error", 76 | "message": "unknown error." 77 | } 78 | 79 | 80 | ## Data Structures 81 | ### DonationSuccessMailModel 82 | + address: 台北市南京東路一段100號 83 | + amount: 500 (required, number) 84 | + `card_info_last_four`: 4242 85 | + `card_info_type`: visa 86 | + currency: TWD 87 | + `donation_timestamp`: 1541641779 88 | + `donation_link`: `https://support.twreporter.org/` 89 | + `donation_method`: 信用卡支付 (required) 90 | + `donation_type`: 定期定額 (required) 91 | + email: developer@twreporter.org (required) 92 | + name: 王小明 93 | + `national_id`: A12345678 94 | + `order_number`: `twreporter-154081514233102449410` (required) 95 | + `phone_number`: 0225602020 96 | -------------------------------------------------------------------------------- /doc/news/asset.apib: -------------------------------------------------------------------------------- 1 | # Data Structures 2 | 3 | ## author 4 | + id: 5edf118c3e631f0600198935 (required) 5 | + job_title: 特約記者 (required) 6 | + name: 王大明 (required) 7 | 8 | ## category 9 | 10 | + id: 5edf118c3e631f0600198935 (required) 11 | + sort_order: 0 (number, required) 12 | + name: 文章類別 (required) 13 | 14 | ## image 15 | + id: 5edf118c3e631f0600198935 (required) 16 | + description: image description (required) 17 | + filetype: image/jpeg (required) 18 | + resized_targets (required) 19 | + mobile (resizeImage, required) 20 | + tiny (resizeImage, required) 21 | + desktop (resizeImage, required) 22 | + tablet (resizeImage, required) 23 | + w400 (resizeImage, required) 24 | 25 | ## tag 26 | + id: 5edf118c3e631f0600198935 (required) 27 | + name: tag name (required) 28 | 29 | ## paragraph 30 | + alignment: center (required) 31 | + content: context (array[string], required) 32 | + id: 1234 (required) 33 | + styles (object, required) 34 | + type: unstyled (required) 35 | 36 | ## paragraphs 37 | + api_data (array[paragraph], fixed-type) 38 | 39 | ## resizeImage 40 | + height: 600 (number, required) 41 | + width: 800 (number, required) 42 | + url: http://image.link (required) 43 | 44 | ## video 45 | + id: 5edf118c3e631f0600198935 (required) 46 | + title: video title (required) 47 | + filetype: video/mp4 (required) 48 | + size: 65536 (required) 49 | + url: http://video.url (required) 50 | 51 | ## meta 52 | + offset: 0 (number, required) 53 | + limit: 10 (number, required) 54 | + total: 100 (number, required) 55 | 56 | ## followup 57 | + title: followup title 58 | + date: `2020-06-8T16:00:00Z` 59 | + summary: followup summary 60 | + content (paragraphs, required) 61 | 62 | ## followupForMember 63 | + post_title: post title (required) 64 | + post_slug: post slug (required) 65 | + title: followup title 66 | + date: `2020-06-8T16:00:00Z` 67 | + summary: followup summary -------------------------------------------------------------------------------- /doc/news/authors.apib: -------------------------------------------------------------------------------- 1 | # Group Authors 2 | 3 | ## Author List [/v2/authors{?keywords,sort,offset,limit}] 4 | A list contains information of the selected authors 5 | 6 | ### Get a list of authors [GET] 7 | 8 | + Parameters 9 | + keywords: `小明` (optional) - Keywords of the names of authors for searching 10 | + sort: `-updated_at` (optional) - Field to sort by 11 | + Default: `-updated_at` 12 | + Members 13 | + `updated_at` - Sort by updated_at ascending 14 | + `-updated_at` - Sort by updated_at descending 15 | + offset: `0` (integer, optional) - The number of authors to skip 16 | + Default: `0` 17 | + limit: `10` (integer, optional) - The maximum number of authors to return at a time 18 | + Default: `10` 19 | 20 | + Response 200 (application/json) 21 | + Attributes 22 | + status: success (required) 23 | + data (required) 24 | + meta(meta, fixed-type, required) 25 | + records (array[Author], fixed-type, required) 26 | 27 | + Response 204 28 | 29 | + Response 500 (application/json) 30 | 31 | + Attributes 32 | + status: error (required) 33 | + message: Unexpected error. (required) 34 | 35 | + Response 504 (application/json) 36 | 37 | + Attributes 38 | + status: error (required) 39 | + message: Query upstream server timeout. (required) 40 | 41 | ## Author [/v2/authors/{author_id}] 42 | Information of the author referenced by `author_id` 43 | 44 | ## Get author by id [GET] 45 | 46 | + Parameters 47 | + `author_id`: 5edf118c3e631f0600198935 48 | 49 | + Response 200 (application/json) 50 | 51 | + Attributes 52 | + status: success (required) 53 | + data (Author, required) 54 | 55 | + Response 404 (application/json) 56 | 57 | + Attributes 58 | + status: fail (required) 59 | + data (required) 60 | + `author_id`: Cannot find the author from the author_id (required) 61 | 62 | + Response 500 (application/json) 63 | 64 | + Attributes 65 | + status: error (required) 66 | + message: Unexpected error. (required) 67 | 68 | + Response 504 (application/json) 69 | 70 | + Attributes 71 | + status: error (required) 72 | + message: Query upstream server timeout. (required) 73 | 74 | ## Posts of an Author [/v2/authors/{author_id}/posts] 75 | Posts of designed/engineered/photographed/written by an author ordered by last updated time. 76 | 77 | ### Get posts of an author [GET] 78 | 79 | + Response 200 (application/json) 80 | 81 | + Attributes 82 | + status: success (required) 83 | + data 84 | + meta (meta, fixed-type, required) 85 | + records (array[MetaOfPost], fixed-type, required) 86 | 87 | + Response 500 (application/json) 88 | 89 | + Attributes 90 | + status: error (required) 91 | + message: Unexpected error. (required) 92 | 93 | + Response 504 (application/json) 94 | 95 | + Attributes 96 | + status: error (required) 97 | + message: Query upstream server timeout. (required) 98 | 99 | 100 | # Data Structures 101 | 102 | ## Author 103 | + id: 5edf118c3e631f0600198935 (required) 104 | + email: `contact@twreporter.org` (required) 105 | + `job_title`: `特約記者` (required) 106 | + bio: `大家好,我是王小明` (required) 107 | + name: `王小明` (required) 108 | + thumbnail (image, required) 109 | + updated_at: `2021-01-27T12:00:00Z` (required) -------------------------------------------------------------------------------- /doc/news/followup.apib: -------------------------------------------------------------------------------- 1 | # Group Post Followups 2 | 3 | ## Followup List [/v2/post_followups{?limit,offset}] 4 | A list contains information of the post followups. 5 | 6 | ### Get post followups [GET] 7 | 8 | + Parameters 9 | + offset: `0` (integer, optional) - The number of followups to skip 10 | + Default: `0` 11 | + limit: `10` (integer, optional) - The maximum number of followups to return 12 | + Default: `10` 13 | 14 | + Request 15 | 16 | + Headers 17 | 18 | Content-Type: application/json 19 | 20 | + Response 200 (application/json) 21 | 22 | + Attributes 23 | + status: success (string, required) - The status of the API request (e.g. "success", "error") 24 | + meta (meta, fixed-type, required) 25 | + data (array[followup], fixed-type, required) 26 | 27 | + Response 500 28 | 29 | + Attributes 30 | + status: error (required) 31 | + message: Unexpected error. -------------------------------------------------------------------------------- /doc/news/index_page.apib: -------------------------------------------------------------------------------- 1 | # Group Index Page 2 | 3 | ## Index Page [/v2/index_page] 4 | A map contains consists mutiple post groups classified into following sections. 5 | 6 | * LatestSection 7 | * EditorPicksSection 8 | * LatestTopicSection 9 | * ReviewsSection 10 | * TopicsSection 11 | * PhotoSection 12 | * InfographicSection 13 | * World 14 | * Humanrights 15 | * PoliticsAndSociety 16 | * Health 17 | * Environment 18 | * Econ 19 | * Culture 20 | * Education 21 | 22 | ## Get current index page [GET] 23 | 24 | + Response 200 (application/json) 25 | 26 | + Attributes 27 | + status: success (required) 28 | + data (IndexPage, required) 29 | 30 | + Response 204 31 | 32 | + Response 500 (application/json) 33 | 34 | + Attributes 35 | + status: error (required) 36 | + message: Unexpected error. (required) 37 | 38 | + Response 504 (application/json) 39 | 40 | + Attributes 41 | + status: error (required) 42 | + message: Query upstream server timeout. (required) 43 | 44 | # Data Structures 45 | 46 | ## IndexPage 47 | + `latest_section` (array[MetaOfPost], fixed-type) - latest_section must contain exactly the latest 6 posts sorted by published_date 48 | + `editor_picks_section` (array[MetaOfPost], fixed-type) - editor_picks_section must contain exactly latest 6 posts sorted by updated_at 49 | + `latest_topic_section` (array[MetaOfTopic], fixed-type) - latest_topic_section must contain exactly latest 1 topic sorted by published_date 50 | + `reviews_section` (array[MetaOfPost], fixed-type) - reviews_section must contain exactly 4 posts sorted by published_date 51 | + `topics_section` (array[MetaOfTopic], fixed-type) - topics_section must contain exactly latest 4 topics (excluded latest one) sorted by published_date 52 | + `photos_section` (array[MetaOfPost], fixed-type) - photos_section must contain exactly 4 photo posts sorted by published_date 53 | + `infographics_section` (array[MetaOfPost], fixed-type) - infographics_section must contain exactly 6 infographic posts sorted by published_date 54 | + `world` (array[MetaOfPost], fixed-type) - world must contain the latest post in world categroy sorted by published_date 55 | + `humanrights` (array[MetaOfPost], fixed-type) - humanrights must contain the latest post in humanrights categroy sorted by published_date 56 | + `politics_and_society` (array[MetaOfPost], fixed-type) - politics_and_society must contain the latest post in politics and society categroy sorted by published_date 57 | + `health` (array[MetaOfPost], fixed-type) - health must contain the latest post in health categroy sorted by published_date 58 | + `environment` (array[MetaOfPost], fixed-type) - environment must contain the latest post in environment categroy sorted by published_date 59 | + `econ` (array[MetaOfPost], fixed-type) - econ must contain the latest post in econ categroy sorted by published_date 60 | + `culture` (array[MetaOfPost], fixed-type) - culture must contain the latest post in culture categroy sorted by published_date 61 | + `education` (array[MetaOfPost], fixed-type) - education must contain the latest post in education categroy sorted by published_date 62 | -------------------------------------------------------------------------------- /doc/news/post.apib: -------------------------------------------------------------------------------- 1 | # Group Posts 2 | 3 | ## Post List [/v2/posts{?category_id,subcategory_id,tag_id,id,sort,offset,limit,toggleBookmark}] 4 | A list contains meta(brief) information of the selected posts. 5 | 6 | ## Get a list of posts [GET] 7 | 8 | + Parameters 9 | + `category_id`: `5edf118c3e631f0600198935` (optional) - Search for posts of the categories referenced by the category_id 10 | + `subcategory_id`: `5edf118c3e631f0600198935` (optional) - Search for posts of the subcategories referenced by the sucategory_id 11 | + `tag_id`: `5edf118c3e631f0600198935` (optional) - Search for posts with the tags referenced by the tag ids 12 | + id: `5edf118c3e631f0600198935` (optional) - Search for posts with the referenced ids 13 | + sort: `-published_date` (optional) - which field to sort by 14 | + Default: `-published_date` 15 | + Members 16 | + `published_date` - sort by published_date ascending 17 | + `-published_date` - sort by published_date descending 18 | + `updated_at` - sort by updated_at ascending 19 | + `-updated_at` - sort by updated_at descending 20 | + offset: `0` (integer, optional) - The number of posts to skip 21 | + Default: `0` 22 | + limit: `10` (integer, optional) - The maximum number of posts to return 23 | + Default: `10` 24 | + toggleBookmark: `1` (integer, optional) - set 1 to fetch bookmark id as well 25 | 26 | + Response 200 (application/json) 27 | 28 | + Attributes 29 | + status: success (required) 30 | + data 31 | + meta (meta, fixed-type, required) 32 | + records (array[MetaOfPost], fixed-type, required) 33 | 34 | + Response 400 (application/json) 35 | 36 | + Attributes 37 | + status: error (required) 38 | + message: Category and subcategory are not consistent. (required) 39 | 40 | + Response 500 (application/json) 41 | 42 | + Attributes 43 | + status: error (required) 44 | + message: Unexpected error. (required) 45 | 46 | + Response 504 (application/json) 47 | 48 | + Attributes 49 | + status: error (required) 50 | + message: Query upstream server timeout. (required) 51 | 52 | ## Post [/v2/posts/{slug}{?full,toggleBookmark}] 53 | A post contains meta(brief) or full information of a post with the slug specified. 54 | 55 | + Parameters 56 | + slug: `a-slug-of-a-post` (required) - Post slug 57 | + full: `true` (optional) - Whether to retrieve a post with full information 58 | + Default: `false` 59 | + toggleBookmark: `1` (integer, optional) - set 1 to fetch bookmark id as well 60 | 61 | ## Get a single post [GET] 62 | Get a single post with the given slug 63 | 64 | + Response 200 (application/json) 65 | 66 | + Attributes 67 | + status: success (required) 68 | + data (MetaOfPost, required) 69 | 70 | + Response 404 (application/json) 71 | 72 | + Attributes 73 | + status: fail (required) 74 | + data (required) 75 | + slug: Cannot find the post from the slug (required) 76 | 77 | + Response 500 (application/json) 78 | 79 | + Attributes 80 | + status: error (required) 81 | + message: Unexpected error. (required) 82 | 83 | + Response 504 (application/json) 84 | 85 | + Attributes 86 | + status: error (required) 87 | + message: Query upstream server timeout. (required) 88 | 89 | + Request with full=true 90 | + Parameters 91 | + full: true (boolean, optional) 92 | 93 | + Response 200 (application/json) 94 | 95 | + Body 96 | 97 | + Attributes 98 | + status: success (required) 99 | + data (FullPost, required) 100 | 101 | # Data Structures 102 | 103 | ## FullPost 104 | + include MetaOfPost 105 | + brief (paragraphs, required) 106 | + content (paragraphs, required) 107 | + copyright: copyrighted (required) 108 | + designers (array[author], fixed-type, required) 109 | + engineers (array[author], fixed-type, required) 110 | + extend_byline: extended lines (required) 111 | + leading_image_description: description (required) 112 | + og_title: og title (required) 113 | + photographers (array[author], fixed-type, required) 114 | + relateds (array, fixed-type, required) 115 | + 5edf118c3e631f0600198935 116 | + topic (required) 117 | + title: topic title (required) 118 | + `short_title`: topic short title (required) 119 | + slug: `the-slug-of-the-topic` (required) 120 | + state: published (required) 121 | + relateds (array, fixed-type, required) 122 | + 5edf118c3e631f0600198935 123 | + updated_at: `2020-06-8T16:00:00Z` (required) 124 | + writers (array[author], fixed-type, required) 125 | + hero_image_size: normal (required) 126 | + followups (array[followup]) 127 | + full: true (boolean, required) 128 | + leading_embedded paragraphs 129 | 130 | ## MetaOfPost 131 | + id: 5edf118c3e631f0600198935 (required) 132 | + style: article:v2:default (required) 133 | + slug: `a-slug-of-the-post` (required) 134 | + leading_image_portrait (image, required) 135 | + hero_image (image, required) 136 | + og_image (image, required) 137 | + og_description: post description (required) 138 | + title: post title (required) 139 | + subtitle: post subtitle (required) 140 | + category_set (array[category_set], fixed-type, required) 141 | + published_date: `2020-06-8T16:00:00Z` (required) 142 | + is_external: false (boolean, required) 143 | + tags (array[tag], fixed-type, required) 144 | + full: false (boolean, required) 145 | + bookmarkId: `119` -------------------------------------------------------------------------------- /doc/news/postcategory.apib: -------------------------------------------------------------------------------- 1 | # Group Postcategories 2 | 3 | ## Postcategory List [/v2/postcategories{?offset,limit}] 4 | A list contains information of the selected postcategories. 5 | 6 | ### Get a list of postcategories [GET] 7 | 8 | + Parameters 9 | + offset: `0` (integer, optional) - The number of tags to skip 10 | + Default: `0` 11 | + limit: `10` (integer, optional) - The maximum number of tags to return at a time 12 | + Default: `10` 13 | 14 | + Response 200 (application/json) 15 | 16 | + Attributes 17 | + status: success (required) 18 | + data (required) 19 | + records (array[Postcategory], fixed-type, required) 20 | 21 | + Response 500 (application/json) 22 | 23 | + Attributes 24 | + status: error (required) 25 | + message: Unexpected error. (required) 26 | 27 | + Response 504 (application/json) 28 | 29 | + Attributes 30 | + status: error (required) 31 | + message: Query upstream server timeout. (required) 32 | 33 | ## Postcategory [/v2/postcategories/{id}] 34 | A postcategory contains full information with the id specified. 35 | 36 | ### Get a single postcategory [GET] 37 | Get A single postcategory with given id. 38 | 39 | + Parameters 40 | + `id`: `5edf118c3e631f0600198935` (optional) - Postcategory id 41 | 42 | + Response 200 (application/json) 43 | 44 | + Attributes 45 | + status: success (required) 46 | + data (Postcategory, required) 47 | 48 | + Response 404 (application/json) 49 | 50 | + Attributes 51 | + status: fail (required) 52 | + data (required) 53 | + id: Cannot find the postcategory from the id (required) 54 | 55 | + Response 500 (application/json) 56 | 57 | + Attributes 58 | + status: error (required) 59 | + message: Unexpected error. (required) 60 | 61 | + Response 504 (application/json) 62 | 63 | + Attributes 64 | + status: error (required) 65 | + message: Query upstream server timeout. (required) 66 | 67 | # Data Structures 68 | 69 | ## Postcategory 70 | + id: 5edf118c3e631f0600198935 (required) 71 | + key: `5edf118c3e631f0600198935` (required) 72 | + name: `2016總統大選` (required) 73 | + sort_order: 1 (required) 74 | + subcategory: `[5edf118c3e631f0600198935]` 75 | -------------------------------------------------------------------------------- /doc/news/review.apib: -------------------------------------------------------------------------------- 1 | ## Data Structures 2 | 3 | ### PostReview 4 | + order: 2 (number, required) - The order of this review 5 | + post_id: 5edf118c3e631f0600198935 (string, required) - Unique id of the post 6 | + slug: `a-slug-of-the-post` (string, required) - Post slug 7 | + reviewWord: some review word (string) - The review word of the post 8 | + title: post title (string, required) - Post title 9 | + og_description: post description (string) - The og description of the post 10 | + og_image (image, required) - Post og images with rwd 11 | 12 | # Group Post Reviews 13 | 14 | ## Review List [/v2/post_reviews] 15 | A list contains information of the post reviews. 16 | 17 | ### Get post reviews [GET] 18 | 19 | + Request 20 | 21 | + Headers 22 | 23 | Content-Type: application/json 24 | Authorization: Bearer 25 | 26 | + Response 200 (application/json) 27 | 28 | + Attributes 29 | + status: success (string, required) - The status of the API request (e.g. "success", "error") 30 | + data (array[PostReview], fixed-type, required) 31 | 32 | + Response 401 33 | 34 | + Attributes 35 | + status: error (required) 36 | + message: Unauthorized - The access token is invalid or has expired 37 | 38 | + Response 403 39 | 40 | + Attributes 41 | + status: error (required) 42 | + message: Forbbiden - The request is not permitted to reach the resource 43 | 44 | + Response 500 45 | 46 | + Attributes 47 | + status: error (required) 48 | + message: Unexpected error. 49 | -------------------------------------------------------------------------------- /doc/news/tag.apib: -------------------------------------------------------------------------------- 1 | # Group Tags 2 | 3 | ## Tag List [/v2/tags{?latest_order,offset,limit}] 4 | A list contains information of the selected tags. 5 | 6 | ### Get a list of Tags [GET] 7 | 8 | + Parameters 9 | + `latest_order`: `1` (integer, optional) - Search for tags of latest_order greater than or equal to given latest_order 10 | + offset: `0` (integer, optional) - The number of tags to skip 11 | + Default: `0` 12 | + limit: `10` (integer, optional) - The maximum number of tags to return at a time 13 | + Default: `10` 14 | 15 | + Response 200 (application/json) 16 | + Attributes 17 | + status: success (required) 18 | + data (required) 19 | + records (array[Tag], fixed-type, required) 20 | 21 | + Response 500 (application/json) 22 | 23 | + Attributes 24 | + status: error (required) 25 | + message: Unexpected error. (required) 26 | 27 | + Response 504 (application/json) 28 | 29 | + Attributes 30 | + status: error (required) 31 | + message: Query upstream server timeout. (required) 32 | 33 | # Data Structures 34 | 35 | ## Tag 36 | + id: 5edf118c3e631f0600198935 (required) 37 | + key: `5edf118c3e631f0600198935` (required) 38 | + name: `2016總統大選` (required) 39 | + latest_order: 1 (number, required) 40 | -------------------------------------------------------------------------------- /doc/news/topic.apib: -------------------------------------------------------------------------------- 1 | # Group Topics 2 | 3 | ## Topic List [/topics{?sort,offset,limit}] 4 | A list contains meta(brief) information of the selected topics. 5 | 6 | ## Get a list of topics [GET] 7 | 8 | + Parameters 9 | + sort: `-published_date` (optional) - which field to sort by 10 | + Default: `-published_date` 11 | + Members 12 | + `published_date` - sort by published_date ascending 13 | + `-published_date` - sort by published_date descending 14 | + offset: `0` (integer, optional) - The number of posts to skip 15 | + Default: `0` 16 | + limit: `10` (integer, optional) - The maximum number of posts to return 17 | + Default: `10` 18 | 19 | + Response 200 (application/json) 20 | 21 | + Attributes 22 | + status: success (required) 23 | + data 24 | + meta (meta, fixed-type, required) 25 | + records (array[MetaOfTopic], fixed-type, required) 26 | 27 | + Response 500 (application/json) 28 | 29 | + Attributes 30 | + status: error (required) 31 | + message: Unexpected error. (required) 32 | 33 | + Response 504 (application/json) 34 | 35 | + Attributes 36 | + status: error (required) 37 | + message: Query upstream server timeout. (required) 38 | 39 | ## Topic [/v2/topics/{slug}{?full}] 40 | Contain meta(brief) or full information of a topic with the slug specified. 41 | 42 | + Parameters 43 | + slug: `a-slug-of-a-topic` (required) - Topic slug 44 | + full: `true` (optional) - Whether to retrieve a topic with full information 45 | + Default: `false` 46 | 47 | ### Get a single topic [GET] 48 | Get a single topic with the given slug 49 | 50 | + Response 200 (application/json) 51 | 52 | + Attributes 53 | + status: success (required) 54 | + data (MetaOfTopic, required) 55 | 56 | + Response 404 (application/json) 57 | 58 | + Attributes 59 | + status: fail (required) 60 | + data (required) 61 | + slug: Cannot find the topic from the slug (required) 62 | 63 | + Response 500 (application/json) 64 | 65 | + Attributes 66 | + status: error (required) 67 | + message: Unexpected error. (required) 68 | 69 | + Response 504 (application/json) 70 | 71 | + Attributes 72 | + status: error (required) 73 | + message: Query upstream server timeout. (required) 74 | 75 | + Request with full=true 76 | + Parameters 77 | + full: true (boolean, optional) 78 | 79 | + Response 200 (application/json) 80 | 81 | + Body 82 | 83 | + Attributes 84 | + status: success (required) 85 | + data (FullTopic, required) 86 | 87 | # Data Structures 88 | 89 | ## FullTopic 90 | + include MetaOfTopic 91 | + relateds_background: in-row 92 | + relateds_format: `#5E5E41` 93 | + title_position: center 94 | + lead_video (video, required) 95 | + headline: topic headline (required) 96 | + subtitle: topic subtitle (required) 97 | + description (paragraphs, required) 98 | + team_description (paragraphs, required) 99 | + og_title: topic og title (required) 100 | + full: true (boolean, required) 101 | 102 | ## MetaOfTopic 103 | + id: 5edf118c3e631f0600198935 (required) 104 | + slug: `a-slug-of-the-topic` (required) 105 | + title: topic title (required) 106 | + `short_title`: short title (required) 107 | + published_date: 2020-06-8T16:00:00Z (required) 108 | + og_description: topic description (required) 109 | + og_image (image, required) 110 | + leading_image (image, required) 111 | + leading_image_portrait (image, required) 112 | + relateds (array, fixed-type, required) 113 | + 5edf118c3e631f0600198935 114 | + full: false (boolean, required) 115 | -------------------------------------------------------------------------------- /doc/oauth.apib: -------------------------------------------------------------------------------- 1 | # Group Oauth Service 2 | 3 | ## Google oauth request [/v2/auth/google{?destination,onboarding}] 4 | Redirect a user request to google oauth server 5 | 6 | ### Redirect google request [GET] 7 | + Parameters 8 | + destination: https://www.twreporter.org 9 | + onboarding: https://accounts-twreporter.org/onboarding 10 | 11 | + Response 302 12 | 13 | ## Google oauth response [/v2/auth/google/callback{?state,code}] 14 | Process user information from google and grants identity token 15 | 16 | ### Response google callback [GET] 17 | + Parameters 18 | + state: `grqsh0n3OgO-0RCavx7NOASlCNyfoNU8k_Ty6_ZcrCM%3D` 19 | + code: `4/1ADU33t4EZbMkrnH7GNCbIF9eQtRgoGNmM3Wy6Ika8SQXtroi8nSHpqsDjvwda87xvQQ-0fbxFI5hl0-V_f37Kg` 20 | 21 | + Response 302 22 | 23 | + Headers 24 | 25 | Set-Cookie: id_token=; Domain=twreporter.org; Max-Age=15552000; HttpOnly; Secure 26 | 27 | ## Facebook oauth request [/v2/auth/facebook{?destination,onboarding}] 28 | Redirect a user request to facebook oauth server 29 | 30 | ### Redirect facebook request [GET] 31 | 32 | + Parameters 33 | + destination: https://www.twreporter.org 34 | + onboarding: https://accounts-twreporter.org/onboarding 35 | 36 | + Response 302 37 | 38 | ## Facebook oauth response [/v2/auth/facebook/callback{?state,code}] 39 | Process user information from facebook and grants identity token 40 | 41 | ### Response facebook callback [GET] 42 | + Parameters 43 | + state: `grqsh0n3OgO-0RCavx7NOASlCNyfoNU8k_Ty6_ZcrCM%3D` 44 | + code: `4/1ADU33t4EZbMkrnH7GNCbIF9eQtRgoGNmM3Wy6Ika8SQXtroi8nSHpqsDjvwda87xvQQ-0fbxFI5hl0-V_f37Kg` 45 | 46 | + Response 302 47 | 48 | + Headers 49 | 50 | Set-Cookie: id_token=; Domain=twreporter.org; Max-Age=15552000; HttpOnly; Secure 51 | 52 | -------------------------------------------------------------------------------- /doc/user-donation.apib: -------------------------------------------------------------------------------- 1 | ## Data Structures 2 | 3 | ### Donation 4 | + type: periodic (string, required) - Donation type; could be `prime` or `periodic` 5 | + created_at: `2020-06-8T16:00:00Z` (required) 6 | + amount: 1500 (number, required) - Donation amount 7 | + status: refunded (string, required) - Donation status in [`paying`, `paid`, `fail`, `refunded`, `to_pay`, `to_pay`, `invalid`] 8 | + order_number: twreporter-24031923864 (string, required) - Unique donation order number 9 | + pay_method: credit_card (string) 10 | + bin_code: 424242 (string) 11 | + card_last_four: 4242 (string) 12 | + is_anonymous: false (boolean) 13 | + card_type: 1 (string) 14 | + last_name: Lin (string) 15 | + first_name: IHan (string) 16 | + send_receipt: no_receipt (string, required) 17 | + receipt_header: Lin IHan (string) 18 | + address_country: Taiwan (string) 19 | + address_state: Taipei (string) 20 | + address_city: Da-an (string) 21 | + address_detail: NTU dorm #2 (string) 22 | + address_zip_code: 11055 (string) 23 | 24 | ### Payment 25 | + created_at: `2020-06-8T16:00:00Z` (required) 26 | + amount: 1500 (number, required) - Payment amount 27 | + status: refunded (string, required) - Payment status in [`paying`, `paid`, `fail`, `refunded`] 28 | + order_number: twreporter-24031923864 (string, required) - Unique payment order number 29 | 30 | # Group User Donation 31 | User donation resources of go-api for membership 32 | 33 | ## Donations of a user [/v1/users/{userID}/donations{?limit,offset}] 34 | 35 | ### Get user donations [GET] 36 | 37 | Get user donations with limit & offset 38 | 39 | + Parameters 40 | + userID: 123 (string) - The unique identifier of the user 41 | + offset: `0` (integer, optional) - The number of posts to skip 42 | + Default: `0` 43 | + limit: `10` (integer, optional) - The maximum number of posts to return 44 | + Default: `10` 45 | 46 | + Request 47 | 48 | + Headers 49 | 50 | Content-Type: application/json 51 | Authorization: Bearer 52 | 53 | + Response 200 (application/json) 54 | 55 | + Attributes 56 | + status: success (string, required) - The status of the API request (e.g. "success", "error") 57 | + meta (meta, fixed-type, required) 58 | + records (array[Donation], fixed-type, required) 59 | 60 | + Response 401 61 | 62 | + Attributes 63 | + status: error (required) 64 | + message: Unauthorized - The access token is invalid or has expired 65 | 66 | + Response 403 67 | 68 | + Attributes 69 | + status: error (required) 70 | + message: Forbbiden - The request is not permitted to reach the resource 71 | 72 | + Response 500 73 | 74 | + Attributes 75 | + status: error (required) 76 | + message: Unexpected error. 77 | 78 | ## Payments of a periodic donation [/v1/periodic-donations/orders/{orderNumber}/payments{?limit,offset}] 79 | 80 | ### Get payments list of periodic donation [GET] 81 | 82 | Get periodic donation payments with limit & offset 83 | 84 | + Parameters 85 | + orderNumber: `twreporter-171049483144563800020` (string) - The order number of the periodic donation 86 | + offset: `0` (integer, optional) - The number of posts to skip 87 | + Default: `0` 88 | + limit: `10` (integer, optional) - The maximum number of posts to return 89 | + Default: `10` 90 | 91 | + Request 92 | 93 | + Headers 94 | 95 | Content-Type: application/json 96 | Authorization: Bearer 97 | 98 | + Response 200 (application/json) 99 | 100 | + Attributes 101 | + status: success (string, required) - The status of the API request (e.g. "success", "error") 102 | + meta (meta, fixed-type, required) 103 | + records (array[Payment], fixed-type, required) 104 | 105 | + Response 401 106 | 107 | + Attributes 108 | + status: error (required) 109 | + message: Unauthorized - The access token is invalid or has expired 110 | 111 | + Response 403 112 | 113 | + Attributes 114 | + status: error (required) 115 | + message: Forbbiden - The request is not permitted to reach the resource 116 | 117 | + Response 404 118 | 119 | + Attributes 120 | + status: error (required) 121 | + message: Not Found - The order number not found or user id not match 122 | 123 | + Response 500 124 | 125 | + Attributes 126 | + status: error (required) 127 | + message: Unexpected error. 128 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Wait for mysql server to be ready 4 | sh -c "dockerize -wait tcp://$GOAPI_DB_MYSQL_ADDRESS:$GOAPI_DB_MYSQL_PORT -timeout 30s" 5 | 6 | # Upgrade MEMBERSHIP Database to latest version 7 | migrate -database "mysql://$GOAPI_DB_MYSQL_MEMBERSHIP_MIGRATE_USER:$GOAPI_DB_MYSQL_MEMBERSHIP_MIGRATE_PASSWORD@tcp($GOAPI_DB_MYSQL_ADDRESS:$GOAPI_DB_MYSQL_PORT)/$GOAPI_DB_MYSQL_NAME" -path $MIGRATION_DIR up 8 | 9 | # Execute pangolin 10 | exec "$@" 11 | -------------------------------------------------------------------------------- /globals/constants.go: -------------------------------------------------------------------------------- 1 | package globals 2 | 3 | const ( 4 | LocalhostPort = "8080" 5 | 6 | // environment 7 | DevelopmentEnvironment = "development" 8 | StagingEnvironment = "staging" 9 | ProductionEnvironment = "production" 10 | 11 | // client URLs 12 | MainSiteOrigin = "https://www.twreporter.org" 13 | MainSiteDevOrigin = "http://localhost:3000" 14 | MainSiteStagingOrigin = "https://staging.twreporter.org" 15 | SupportSiteOrigin = "https://support.twreporter.org" 16 | SupportSiteDevOrigin = "http://localhost:3000" 17 | SupportSiteStagingOrigin = "https://staging-support.twreporter.org" 18 | AccountsSiteOrigin = "https://accounts.twreporter.org" 19 | AccountsSiteDevOrigin = "http://localhost:3000" 20 | AccountsSiteStagingOrigin = "https://staging-accounts.twreporter.org" 21 | 22 | // route path 23 | SendOtpRoutePath = "mail/send_otp" 24 | SendActivationRoutePath = "mail/send_activation" 25 | SendAuthenticationRoutePath = "mail/send_authentication" 26 | SendSuccessDonationRoutePath = "mail/send_success_donation" 27 | SendRoleExplorerRoutePath = "mail/send_role_explorer" 28 | SendRoleActiontakerRoutePath = "mail/send_role_actiontaker" 29 | SendRoleTrailblazerRoutePath = "mail/send_role_trailblazer" 30 | SendRoleDowngradeRoutePath = "mail/send_role_downgrade" 31 | 32 | // controller name 33 | MembershipController = "membership_controller" 34 | NewsController = "news_controller" 35 | 36 | RegistrationTable = "registrations" 37 | 38 | DefaultOrderBy = "updated_at desc" 39 | DefaultLimit int = 10 40 | DefaultOffset int = 0 41 | DefaultActiveCode int = 2 42 | 43 | NewsLetter = "news_letter" 44 | 45 | Activate = "activate" 46 | 47 | // Deprecated once v1 news endpoints are removed 48 | // index page sections // 49 | LatestSection = "latest_section" 50 | EditorPicksSection = "editor_picks_section" 51 | LatestTopicSection = "latest_topic_section" 52 | ReviewsSection = "reviews_section" 53 | CategoriesSection = "categories_posts_section" 54 | TopicsSection = "topics_section" 55 | PhotoSection = "photos_section" 56 | InfographicSection = "infographics_section" 57 | 58 | // Deprecated once v1 news endpoints are removed 59 | // index page categories 60 | HumanRightsAndSociety = "human_rights_and_society" 61 | EnvironmentAndEducation = "environment_and_education" 62 | PoliticsAndEconomy = "politics_and_economy" 63 | CultureAndArt = "culture_and_art" 64 | International = "international" 65 | LivingAndMedicalCare = "living_and_medical_care" 66 | 67 | // table name 68 | TableUsersBookmarks = "users_bookmarks" 69 | TableBookmarks = "bookmarks" 70 | TablePayByPrimeDonations = "pay_by_prime_donations" 71 | TablePayByCardTokenDonations = "pay_by_card_token_donations" 72 | TablePayByOtherMethodDonations = "pay_by_other_method_donations" 73 | 74 | // oauth type 75 | GoogleOAuth = "Google" 76 | FacebookOAuth = "Facebook" 77 | 78 | // donation 79 | PeriodicDonationType = "periodic_donation" 80 | PrimeDonationType = "prime" 81 | TokenDonationType = "token" 82 | OthersDonationType = "others" 83 | 84 | // userType 85 | UserType = "user" 86 | 87 | // jwt prefix 88 | MailServiceJWTPrefix = "mail-service-jwt-" 89 | 90 | // custom context key 91 | AuthUserIDProperty = "auth-user-id" 92 | ) 93 | -------------------------------------------------------------------------------- /globals/globals.go: -------------------------------------------------------------------------------- 1 | package globals 2 | 3 | import ( 4 | "github.com/twreporter/go-api/configs" 5 | ) 6 | 7 | var ( 8 | // Conf is go-api config 9 | Conf configs.ConfYaml 10 | ) 11 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/twreporter/go-api 2 | 3 | go 1.12 4 | 5 | require ( 6 | cloud.google.com/go v0.52.0 // indirect 7 | github.com/algolia/algoliasearch-client-go/v3 v3.16.0 8 | github.com/auth0/go-jwt-middleware v0.0.0-20170425171159-5493cabe49f7 9 | github.com/aws/aws-sdk-go v1.34.28 10 | github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927 // indirect 11 | github.com/codegangsta/negroni v1.0.0 // indirect 12 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 13 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect 14 | github.com/gin-contrib/cors v0.0.0-20170708080947-567de1916927 15 | github.com/gin-contrib/sessions v0.0.3 16 | github.com/gin-gonic/gin v1.6.3 17 | github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8 18 | github.com/go-playground/validator/v10 v10.7.0 // indirect 19 | github.com/go-sql-driver/mysql v1.5.0 20 | github.com/gofrs/uuid v3.2.0+incompatible // indirect 21 | github.com/golang-migrate/migrate/v4 v4.6.1 22 | github.com/golang/protobuf v1.5.2 // indirect 23 | github.com/google/uuid v1.1.1 24 | github.com/gorilla/mux v1.7.2 // indirect 25 | github.com/gorilla/sessions v1.2.1 // indirect 26 | github.com/jinzhu/copier v0.0.0-20180308034124-7e38e58719c3 27 | github.com/jinzhu/gorm v1.9.2 28 | github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d // indirect 29 | github.com/jinzhu/now v1.0.1 // indirect 30 | github.com/json-iterator/go v1.1.11 // indirect 31 | github.com/kr/pretty v0.2.0 // indirect 32 | github.com/leodido/go-urn v1.2.1 // indirect 33 | github.com/lib/pq v1.1.1 // indirect 34 | github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2 // indirect 35 | github.com/mattn/go-isatty v0.0.13 // indirect 36 | github.com/pkg/errors v0.9.1 37 | github.com/sirupsen/logrus v1.4.2 38 | github.com/slack-go/slack v0.10.2 // indirect 39 | github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a // indirect 40 | github.com/spf13/viper v1.3.2 41 | github.com/stretchr/testify v1.6.1 42 | github.com/twreporter/go-mod-lib v0.0.0-20220330041607-4d484f68db8c 43 | github.com/twreporter/logformatter v0.0.0-20200211094126-60fe42618206 44 | github.com/ugorji/go v1.2.6 // indirect 45 | go.mongodb.org/mongo-driver v1.4.6 46 | golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 47 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d 48 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect 49 | golang.org/x/text v0.3.6 // indirect 50 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0 // indirect 51 | google.golang.org/genproto v0.0.0-20200211035748-55294c81d784 // indirect 52 | google.golang.org/grpc v1.27.1 // indirect 53 | google.golang.org/protobuf v1.27.1 // indirect 54 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 55 | gopkg.in/go-playground/validator.v8 v8.18.2 56 | gopkg.in/guregu/null.v3 v3.5.0 57 | gopkg.in/matryer/try.v1 v1.0.0-20150601225556-312d2599e12e 58 | gopkg.in/yaml.v2 v2.4.0 // indirect 59 | ) 60 | 61 | replace ( 62 | github.com/coreos/bbolt v1.3.4 => go.etcd.io/bbolt v1.3.4 63 | go.etcd.io/bbolt v1.3.4 => github.com/coreos/bbolt v1.3.4 64 | ) 65 | -------------------------------------------------------------------------------- /internal/member_cms/method.go: -------------------------------------------------------------------------------- 1 | package member_cms 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/twreporter/go-api/configs/constants" 12 | "github.com/twreporter/go-api/globals" 13 | ) 14 | 15 | type Session struct { 16 | token string 17 | expiredAt time.Time 18 | } 19 | 20 | const graphqlEndpoint = "/graphql" 21 | 22 | var client *Client 23 | var session Session 24 | 25 | func GetApiBaseUrl() (string, error) { 26 | url := globals.Conf.MemberCMS.Url 27 | if len(url) == 0 { 28 | return "", errors.New("member cms url not set in config.go") 29 | } 30 | return url, nil 31 | } 32 | 33 | func NewClient() error { 34 | if !globals.Conf.Features.MemberCMS { 35 | return errors.New("disable intergrating with member cms") 36 | } 37 | url, err := GetApiBaseUrl() 38 | if err != nil { 39 | return err 40 | } 41 | url = url + graphqlEndpoint 42 | 43 | client = newClient(url) 44 | if globals.Conf.Environment == "development" || globals.Conf.Environment == "staging" { 45 | client.Log = func(s string) { log.Println(s) } 46 | } 47 | 48 | // refresh token concurrently 49 | go refreshToken() 50 | 51 | return nil 52 | } 53 | 54 | func Query(req *Request) (interface{}, error) { 55 | var respData interface{} 56 | 57 | if !globals.Conf.Features.MemberCMS { 58 | return respData, errors.New("disable intergrating with member cms") 59 | } 60 | if err := getValidSession(); err != nil { 61 | return respData, err 62 | } 63 | 64 | cookie := getCookie() 65 | req.Header.Set("Cookie", cookie) 66 | req.Host = globals.Conf.MemberCMS.Host 67 | ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*constants.MemberCMSQueryTimeout) 68 | defer cancel() 69 | 70 | if err := client.Run(ctx, req, &respData); err != nil { 71 | return nil, err 72 | } 73 | return respData, nil 74 | } 75 | 76 | func AppendRequiredHeader(req *http.Request) error { 77 | if err := getValidSession(); err != nil { 78 | return err 79 | } 80 | 81 | cookie := getCookie() 82 | req.Header.Set("Cookie", cookie) 83 | req.Host = globals.Conf.MemberCMS.Host 84 | 85 | return nil 86 | } 87 | 88 | func refreshToken() error { 89 | var respData interface{} 90 | 91 | req := NewRequest(` 92 | mutation Mutation($email: String!, $password: String!) { 93 | authenticateSystemUserWithPassword(email: $email, password: $password) { 94 | ... on SystemUserAuthenticationWithPasswordSuccess { 95 | sessionToken 96 | } 97 | ... on SystemUserAuthenticationWithPasswordFailure { 98 | message 99 | } 100 | } 101 | } 102 | `) 103 | req.Var("email", globals.Conf.MemberCMS.Email) 104 | req.Var("password", globals.Conf.MemberCMS.Password) 105 | req.Header.Set("Cache-Control", "no-store") 106 | req.Host = globals.Conf.MemberCMS.Host 107 | 108 | if err := client.Run(context.Background(), req, &respData); err != nil { 109 | return err 110 | } 111 | token, err := getValueFromField(respData, "sessionToken") 112 | if err != nil { 113 | return err 114 | } 115 | session.token = token 116 | session.expiredAt = getExpiration() 117 | return nil 118 | } 119 | 120 | func getValueFromField(source interface{}, field string) (string, error) { 121 | var value string 122 | var err error 123 | 124 | m, ok := source.(map[string]interface{}) 125 | if !ok { 126 | return "", errors.New("type assertion failed") 127 | } 128 | for k, v := range m { 129 | if k == field { 130 | value = v.(string) 131 | break 132 | } 133 | value, err = getValueFromField(v, field) 134 | } 135 | return value, err 136 | } 137 | 138 | func getCookie() string { 139 | return fmt.Sprintf("keystonejs-session=%s", session.token) 140 | } 141 | 142 | // todo: get expiration from authenticate mutation response after member cms update auth api 143 | func getExpiration() time.Time { 144 | return time.Now().Add(time.Second * time.Duration(globals.Conf.MemberCMS.SessionMaxAge)) 145 | } 146 | 147 | func getValidSession() error { 148 | if session.token == "" || session.expiredAt.IsZero() || session.expiredAt.Before(time.Now()) { 149 | if err := refreshToken(); err != nil { 150 | return err 151 | } 152 | } 153 | 154 | return nil 155 | } 156 | -------------------------------------------------------------------------------- /internal/member_cms/receipt.go: -------------------------------------------------------------------------------- 1 | package member_cms 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | 10 | "github.com/twreporter/go-api/globals" 11 | ) 12 | 13 | const receiptEndpoint = "/receipt" 14 | 15 | func GetPrimeDonationReceiptRequest(receiptNumber string) (*http.Request, error) { 16 | if !globals.Conf.Features.MemberCMS { 17 | return nil, errors.New("disable intergrating with member cms") 18 | } 19 | if len(receiptNumber) == 0 { 20 | return nil, errors.New("receipt numner is required") 21 | } 22 | 23 | url, err := GetApiBaseUrl() 24 | if err != nil { 25 | return nil, err 26 | } 27 | url = fmt.Sprintf("%s%s/%s", url, receiptEndpoint, receiptNumber) 28 | req, err := http.NewRequest("GET", url, nil) 29 | if err != nil { 30 | return nil, err 31 | } 32 | req.Header.Set("Content-Type", "application/json") 33 | AppendRequiredHeader(req) 34 | 35 | return req, nil 36 | } 37 | 38 | func PostPrimeDonationReceipt(receiptNumber string, orderNumber string) error { 39 | if !globals.Conf.Features.MemberCMS { 40 | return errors.New("disable intergrating with member cms") 41 | } 42 | if len(receiptNumber) == 0 && len(orderNumber) == 0 { 43 | return errors.New("one of receipt number or order number should be provided") 44 | } 45 | 46 | url, err := GetApiBaseUrl() 47 | if err != nil { 48 | return err 49 | } 50 | url = url + receiptEndpoint 51 | 52 | payload := map[string]string{"receipt_number": receiptNumber, "order_number": orderNumber} 53 | jsonBody, err := json.Marshal(payload) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) 59 | if err != nil { 60 | return err 61 | } 62 | req.Header.Set("Content-Type", "application/json") 63 | AppendRequiredHeader(req) 64 | 65 | resp, err := http.DefaultClient.Do(req) 66 | fmt.Printf("\nresp:\n%+v\n", resp) 67 | if err != nil { 68 | return err 69 | } 70 | defer resp.Body.Close() 71 | 72 | return nil 73 | } 74 | 75 | func GetYearlyReceiptRequest(email string, year string) (*http.Request, error) { 76 | if !globals.Conf.Features.MemberCMS { 77 | return nil, errors.New("disable intergrating with member cms") 78 | } 79 | if len(email) == 0 { 80 | return nil, errors.New("email is required") 81 | } 82 | 83 | url, err := GetApiBaseUrl() 84 | if err != nil { 85 | return nil, err 86 | } 87 | url = fmt.Sprintf("%s%s/%s/%s", url, receiptEndpoint, email, year) 88 | req, err := http.NewRequest("GET", url, nil) 89 | if err != nil { 90 | return nil, err 91 | } 92 | req.Header.Set("Content-Type", "application/json") 93 | AppendRequiredHeader(req) 94 | 95 | return req, nil 96 | } 97 | -------------------------------------------------------------------------------- /internal/mongo/client.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/pkg/errors" 8 | "go.mongodb.org/mongo-driver/mongo" 9 | "go.mongodb.org/mongo-driver/mongo/options" 10 | ) 11 | 12 | func NewClient(ctx context.Context, opts ...*options.ClientOptions) (*mongo.Client, error) { 13 | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 14 | defer cancel() 15 | 16 | client, err := mongo.Connect(ctx, opts...) 17 | if err != nil { 18 | return nil, errors.Wrap(err, "Establishing a new connection to cluster occurs error:") 19 | } 20 | 21 | if err = client.Ping(ctx, nil); err != nil { 22 | return nil, errors.Wrap(err, "Connection to cluster does not response:") 23 | } 24 | return client, nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/mongo/operator.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | const ( 4 | // Define mongo query operator 5 | OpAnd = "$and" 6 | OpConcatArrays = "$concatArrays" 7 | OpEq = "$eq" 8 | OpExpr = "$expr" 9 | OpIn = "$in" 10 | OpLet = "$let" 11 | OpGte = "$gte" 12 | OpOr = "$or" 13 | OpReduce = "$reduce" 14 | OpExists = "$exists" 15 | OpNe = "$ne" 16 | OpCount = "$count" 17 | OpNot = "$not" 18 | OpSize = "$size" 19 | 20 | OrderAsc = 1 21 | OrderDesc = -1 22 | 23 | ElemMatch = "$elemMatch" 24 | 25 | // Define mongo pipeline stage 26 | StageAddFields = "$addFields" 27 | StageGroup = "$group" 28 | StageFilter = "$filter" 29 | StageLimit = "$limit" 30 | StageLookup = "$lookup" 31 | StageMatch = "$match" 32 | StageSkip = "$skip" 33 | StageSort = "$sort" 34 | StageUnwind = "$unwind" 35 | StageReplaceRoot = "$replaceRoot" 36 | StageProject = "$project" 37 | StageFacet = "$facet" 38 | 39 | // Define Meta fields for nested stages (e.g., lookup) 40 | MetaAs = "as" 41 | MetaCond = "cond" 42 | MetaForeignField = "foreignField" 43 | MetaFrom = "from" 44 | MetaIn = "in" 45 | MetaInitialValue = "initialValue" 46 | MetaInput = "input" 47 | MetaLocalField = "localField" 48 | MetaLet = "let" 49 | MetaPipeline = "pipeline" 50 | MetaVars = "vars" 51 | 52 | MetaPath = "path" 53 | MetaPreserveNullAndEmptyArrays = "preserveNullAndEmptyArrays" 54 | ) 55 | -------------------------------------------------------------------------------- /internal/news/algolia.go: -------------------------------------------------------------------------------- 1 | package news 2 | 3 | import "github.com/algolia/algoliasearch-client-go/v3/algolia/search" 4 | 5 | type AlgoliaSearcher interface { 6 | Search(query string, opts ...interface{}) (res search.QueryRes, err error) 7 | } 8 | -------------------------------------------------------------------------------- /internal/news/author.go: -------------------------------------------------------------------------------- 1 | package news 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/algolia/algoliasearch-client-go/v3/algolia/opt" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | // GetRankedAuthorIDs returns ranked author ID result by index search 12 | func GetRankedAuthorIDs(ctx context.Context, index AlgoliaSearcher, q *Query) ([]string, int64, error) { 13 | type authorIndex struct { 14 | ID string `json:"id"` 15 | Name string `json:"name"` 16 | } 17 | var indexes []authorIndex 18 | res, err := index.Search(q.Filter.Name, opt.Offset(q.Offset), opt.Length(q.Limit), ctx) 19 | if err != nil { 20 | // fallback 21 | return nil, 0, errors.WithStack(err) 22 | } 23 | rawRecords, err := json.Marshal(res.Hits) 24 | if err != nil { 25 | // fallback 26 | return nil, 0, errors.WithStack(err) 27 | } 28 | err = json.Unmarshal(rawRecords, &indexes) 29 | if err != nil { 30 | // fallback 31 | return nil, 0, errors.WithStack(err) 32 | } 33 | var authorIDs []string 34 | for _, index := range indexes { 35 | authorIDs = append(authorIDs, index.ID) 36 | } 37 | return authorIDs, int64(res.NbHits), nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/news/category.go: -------------------------------------------------------------------------------- 1 | package news 2 | 3 | type Category struct { 4 | ID string 5 | Name string 6 | } 7 | 8 | var ( 9 | HumanRightsAndSociety = Category{"5951db87507c6a0d00ab063c", "human_rights_and_society"} 10 | EnvironmentAndEducation = Category{"5951db9b507c6a0d00ab063d", "environment_and_education"} 11 | PoliticsAndEconomy = Category{"5951dbc2507c6a0d00ab0640", "politics_and_economy"} 12 | CultureAndArt = Category{"57175d923970a5e46ff854db", "culture_and_art"} 13 | International = Category{"5743d35a940ee41000e81f4a", "international"} 14 | LivingAndMedicalCare = Category{"59783ad89092de0d00b41691", "living_and_medical_care"} 15 | Photography = Category{"574d028748fa171000c45d48", "photography"} 16 | ) 17 | -------------------------------------------------------------------------------- /internal/news/category_set.go: -------------------------------------------------------------------------------- 1 | package news 2 | 3 | type CategorySet struct { 4 | Name string 5 | Key string 6 | } 7 | 8 | var ( 9 | World = CategorySet{"world", "63206383207bf7c5f871622c"} 10 | Humanrights = CategorySet{"humanrights", "63206383207bf7c5f8716234"} 11 | PoliticsAndSociety = CategorySet{"politics_and_society", "63206383207bf7c5f871623d"} 12 | Health = CategorySet{"health", "63206383207bf7c5f8716245"} 13 | Environment = CategorySet{"environment", "63206383207bf7c5f871624d"} 14 | Econ = CategorySet{"econ", "63206383207bf7c5f8716254"} 15 | Culture = CategorySet{"culture", "63206383207bf7c5f8716259"} 16 | Education = CategorySet{"education", "63206383207bf7c5f8716260"} 17 | Podcast = CategorySet{"podcast", "63206383207bf7c5f8716266"} 18 | Opinion = CategorySet{"opinion", "63206383207bf7c5f8716269"} 19 | ) 20 | -------------------------------------------------------------------------------- /internal/news/mongo_test.go: -------------------------------------------------------------------------------- 1 | package news 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "go.mongodb.org/mongo-driver/bson/primitive" 8 | ) 9 | 10 | func TestNewMongoQuery(t *testing.T) { 11 | oID := primitive.NewObjectID() 12 | cases := []struct { 13 | name string 14 | q Query 15 | want mongoQuery 16 | }{ 17 | { 18 | name: "Given valid hex string within string slice of filter field", 19 | q: Query{ 20 | Filter: Filter{ 21 | IDs: []string{oID.Hex()}, 22 | }, 23 | }, 24 | want: mongoQuery{ 25 | mongoFilter: mongoFilter{ 26 | IDs: []primitive.ObjectID{oID}, 27 | }, 28 | }, 29 | }, 30 | { 31 | name: "Given invalid hex string within string slice of filter field", 32 | q: Query{ 33 | Filter: Filter{ 34 | IDs: []string{"invalidhex"}, 35 | }, 36 | }, 37 | want: mongoQuery{}, 38 | }, 39 | } 40 | 41 | for _, tc := range cases { 42 | t.Run(tc.name, func(t *testing.T) { 43 | got := NewMongoQuery(&tc.q) 44 | if !reflect.DeepEqual(*got, tc.want) { 45 | t.Errorf("expected mongo query %+v, got %+v", tc.want, *got) 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/news/section.go: -------------------------------------------------------------------------------- 1 | package news 2 | 3 | const ( 4 | LatestSection = "latest_section" 5 | EditorPicksSection = "editor_picks_section" 6 | LatestTopicSection = "latest_topic_section" 7 | ReviewsSection = "reviews_section" 8 | CategoriesSection = "categories_posts_section" 9 | TopicsSection = "topics_section" 10 | PhotoSection = "photos_section" 11 | InfographicSection = "infographics_section" 12 | ) 13 | -------------------------------------------------------------------------------- /internal/query/query.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | // package query provides common query data type for go-api 4 | 5 | import "gopkg.in/guregu/null.v3" 6 | 7 | type Pagination struct { 8 | Offset int 9 | Limit int 10 | } 11 | 12 | type Order struct { 13 | IsAsc null.Bool 14 | } 15 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "time" 9 | 10 | "github.com/algolia/algoliasearch-client-go/v3/algolia/search" 11 | 12 | "github.com/pkg/errors" 13 | log "github.com/sirupsen/logrus" 14 | "github.com/twreporter/go-mod-lib/pkg/cloudpub" 15 | "github.com/twreporter/go-mod-lib/pkg/slack" 16 | f "github.com/twreporter/logformatter" 17 | "go.mongodb.org/mongo-driver/mongo/options" 18 | "go.mongodb.org/mongo-driver/mongo/readpref" 19 | 20 | "github.com/twreporter/go-api/configs" 21 | "github.com/twreporter/go-api/controllers" 22 | "github.com/twreporter/go-api/globals" 23 | member "github.com/twreporter/go-api/internal/member_cms" 24 | "github.com/twreporter/go-api/internal/mongo" 25 | "github.com/twreporter/go-api/routers" 26 | "github.com/twreporter/go-api/services" 27 | "github.com/twreporter/go-api/utils" 28 | ) 29 | 30 | func main() { 31 | var err error 32 | var cf *controllers.ControllerFactory 33 | 34 | defer func() { 35 | if err != nil { 36 | if globals.Conf.Environment == "development" { 37 | log.Errorf("%+v", err) 38 | } else { 39 | log.WithField("detail", err).Errorf("%s", f.FormatStack(err)) 40 | } 41 | } 42 | }() 43 | 44 | globals.Conf, err = configs.LoadConf("") 45 | if err != nil { 46 | err = errors.Wrap(err, "Fatal error config file") 47 | return 48 | } 49 | 50 | configLogger() 51 | 52 | // set up database connection 53 | log.Info("Connecting to MySQL cloud") 54 | db, err := utils.InitDB(10, 5) 55 | defer db.Close() 56 | if err != nil { 57 | return 58 | } 59 | 60 | log.Info("Connecting to MongoDB replica") 61 | session, err := utils.InitMongoDB() 62 | defer session.Close() 63 | if err != nil { 64 | return 65 | } 66 | 67 | log.Info("Connection to MongoDB with mongo-go-driver") 68 | ctx := context.Background() 69 | opts := options.Client() 70 | client, err := mongo.NewClient(ctx, opts.ApplyURI(globals.Conf.DB.Mongo.URL).SetReadPreference(readpref.Nearest())) 71 | 72 | log.Info("Connecting to Member CMS") 73 | if err := member.NewClient(); err != nil { 74 | log.Infof("connecting to member cms failed. err: %+v", err) 75 | } 76 | 77 | if err != nil { 78 | return 79 | } 80 | defer func() { 81 | client.Disconnect(ctx) 82 | }() 83 | 84 | // init cloudpub client 85 | pubConfig := &cloudpub.Config{ 86 | ProjectID: globals.Conf.Neticrm.ProjectID, 87 | Topic: globals.Conf.Neticrm.Topic, 88 | } 89 | cloudpub.NewPublisher(ctx, pubConfig) 90 | 91 | // init slack notify client 92 | slackConfig := &slack.Config{ 93 | Webhook: globals.Conf.Neticrm.SlackWebhook, 94 | } 95 | slack.NewClient(slackConfig) 96 | 97 | // mailSender := services.NewSMTPMailService() // use office365 to send mails 98 | mailSvc := services.NewAmazonMailService() // use Amazon SES to send mails 99 | 100 | sClient := search.NewClient(globals.Conf.Algolia.ApplicationID, globals.Conf.Algolia.APIKey) 101 | cf = controllers.NewControllerFactory(db, session, mailSvc, client, sClient.InitIndex("contacts-index-v3")) 102 | 103 | // set up the router 104 | router := routers.SetupRouter(cf) 105 | 106 | readTimeout := 5 * time.Second 107 | 108 | // Set writeTimeout bigger than 30 secs. 109 | // 30 secs is to ensure donation request is handled correctly. 110 | writeTimeout := 40 * time.Second 111 | s := &http.Server{ 112 | Addr: fmt.Sprintf(":%s", globals.LocalhostPort), 113 | Handler: router, 114 | ReadTimeout: readTimeout, 115 | WriteTimeout: writeTimeout, 116 | } 117 | 118 | if err = s.ListenAndServe(); err != nil { 119 | err = errors.Wrap(err, "Fail to start HTTP server") 120 | } 121 | return 122 | } 123 | 124 | func configLogger() { 125 | env := globals.Conf.Environment 126 | switch env { 127 | // production/staging environments writes the log into standard output 128 | // and delegates log collector (fluentd) in k8s cluster to export to 129 | // stackdriver sink. 130 | case "production", "staging": 131 | log.SetOutput(os.Stdout) 132 | log.SetFormatter(f.NewStackdriverFormatter("go-api", env)) 133 | // development environment reports the log location 134 | default: 135 | log.SetReportCaller(true) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /middlewares/cache-control.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | // SetCacheControl ... 8 | func SetCacheControl(cc string) gin.HandlerFunc { 9 | return func(c *gin.Context) { 10 | // TODO 11 | // Append Etag or Last-Modified on response header for validation 12 | c.Writer.Header().Set("Cache-Control", cc) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /middlewares/jwt.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | 10 | "github.com/twreporter/go-api/globals" 11 | "github.com/twreporter/go-api/utils" 12 | 13 | "github.com/auth0/go-jwt-middleware" 14 | "github.com/dgrijalva/jwt-go" 15 | "github.com/gin-gonic/gin" 16 | "github.com/gin-gonic/gin/binding" 17 | ) 18 | 19 | const authUserProperty = "app-auth-jwt" 20 | 21 | var jwtMiddleware = jwtmiddleware.New(jwtmiddleware.Options{ 22 | ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) { 23 | return []byte(globals.Conf.App.JwtSecret), nil 24 | }, 25 | UserProperty: authUserProperty, 26 | SigningMethod: jwt.SigningMethodHS256, 27 | ErrorHandler: func(w http.ResponseWriter, r *http.Request, err string) { 28 | var res = map[string]interface{}{ 29 | "status": "fail", 30 | "data": map[string]interface{}{ 31 | "req.Headers.Authorization": err, 32 | }, 33 | } 34 | var resByte, _ = json.Marshal(res) 35 | http.Error(w, string(resByte), http.StatusUnauthorized) 36 | }, 37 | }) 38 | 39 | func PassAuthUserID() gin.HandlerFunc { 40 | return func(c *gin.Context) { 41 | if c.Request.Header["Authorization"] == nil { 42 | return 43 | } 44 | if err := jwtMiddleware.CheckJWT(c.Writer, c.Request); err != nil { 45 | return 46 | } 47 | userProperty := c.Request.Context().Value(authUserProperty) 48 | claims := userProperty.(*jwt.Token).Claims.(jwt.MapClaims) 49 | // Set user_id with key "auth-user-id" in context to avoid hierarchy access 50 | newRequest := c.Request.WithContext(context.WithValue(c.Request.Context(), globals.AuthUserIDProperty, claims["user_id"])) 51 | *c.Request = *newRequest 52 | } 53 | } 54 | 55 | // ValidateAuthorization checks the jwt token in the Authorization header is valid or not 56 | func ValidateAuthorization() gin.HandlerFunc { 57 | return func(c *gin.Context) { 58 | const verifyRequired = true 59 | var err error 60 | var userProperty interface{} 61 | var claims jwt.MapClaims 62 | 63 | if err = jwtMiddleware.CheckJWT(c.Writer, c.Request); err != nil { 64 | c.Abort() 65 | return 66 | } 67 | 68 | userProperty = c.Request.Context().Value(authUserProperty) 69 | claims = userProperty.(*jwt.Token).Claims.(jwt.MapClaims) 70 | if !claims.VerifyAudience(globals.Conf.App.JwtAudience, verifyRequired) || 71 | !claims.VerifyIssuer(globals.Conf.App.JwtIssuer, verifyRequired) { 72 | c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ 73 | "status": "fail", 74 | "data": gin.H{ 75 | "req.Cookies.id_token": "aud or issuer claim is invalid", 76 | }, 77 | }) 78 | return 79 | } 80 | 81 | var newRequest *http.Request 82 | 83 | // Set user_id with key "auth-user-id" in context to avoid hierarchy access 84 | newRequest = c.Request.WithContext(context.WithValue(c.Request.Context(), globals.AuthUserIDProperty, claims["user_id"])) 85 | *c.Request = *newRequest 86 | } 87 | } 88 | 89 | // ValidateUserID checks claim userID in the jwt with :userID param in the request url. 90 | // if the two values are not the same, return the 401 response 91 | func ValidateUserID() gin.HandlerFunc { 92 | return func(c *gin.Context) { 93 | var ( 94 | userID string 95 | authUserID interface{} 96 | ) 97 | 98 | authUserID = c.Request.Context().Value(globals.AuthUserIDProperty) 99 | userID = c.Param("userID") 100 | if userID != fmt.Sprint(authUserID) { 101 | c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ 102 | "status": "fail", 103 | "data": gin.H{ 104 | "req.Headers.Authorization": "the request is not permitted to reach the resource", 105 | }, 106 | }) 107 | } 108 | } 109 | } 110 | 111 | func ValidateUserIDInReqBody() gin.HandlerFunc { 112 | return func(c *gin.Context) { 113 | var ( 114 | body = struct { 115 | UserID uint64 `json:"user_id" form:"user_id" binding:"required"` 116 | }{} 117 | err error 118 | authUserID interface{} 119 | ) 120 | 121 | // gin.Context.Bind does not support to bind `JSON` body multiple times 122 | // the alternative is to use gin.Context.ShouldBindBodyWith function to bind 123 | if err = c.ShouldBindBodyWith(&body, binding.JSON); err == nil { 124 | // omit intentionally 125 | } else if err = c.Bind(&body); err != nil { 126 | // bind other format rather than JSON 127 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"status": "fail", "data": gin.H{ 128 | "req.Body.user_id": err.Error(), 129 | }}) 130 | return 131 | } 132 | 133 | authUserID = c.Request.Context().Value(globals.AuthUserIDProperty) 134 | 135 | if fmt.Sprint(body.UserID) != fmt.Sprint(authUserID) { 136 | c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"status": "fail", "data": gin.H{ 137 | "req.Headers.Authorization": "the request is not permitted to reach the resource", 138 | }}) 139 | return 140 | } 141 | } 142 | } 143 | 144 | // ValidateAuthentication validates `req.Cookies.id_token` 145 | // if id_token, which is a JWT, is invalid, and then return 401 status code 146 | func ValidateAuthentication() gin.HandlerFunc { 147 | return func(c *gin.Context) { 148 | var tokenString string 149 | var err error 150 | var token *jwt.Token 151 | 152 | defer func() { 153 | if r := recover(); r != nil { 154 | c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ 155 | "status": "fail", 156 | "data": gin.H{ 157 | "req.Headers.Cookies.id_token": err.Error(), 158 | }, 159 | }) 160 | return 161 | } 162 | }() 163 | 164 | if tokenString, err = c.Cookie("id_token"); err != nil { 165 | panic(err) 166 | } 167 | 168 | if token, err = jwt.ParseWithClaims(tokenString, &utils.IDTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) { 169 | return []byte(globals.Conf.App.JwtSecret), nil 170 | }); err != nil { 171 | panic(err) 172 | } 173 | 174 | if !token.Valid { 175 | err = errors.New("id_token is invalid") 176 | panic(err) 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /middlewares/mail-service.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/auth0/go-jwt-middleware" 7 | "github.com/dgrijalva/jwt-go" 8 | "github.com/gin-gonic/gin" 9 | "github.com/twreporter/go-api/globals" 10 | ) 11 | 12 | const jwtUserPropertyForMailService = "mail-service-jwt" 13 | 14 | type JWTMiddleware interface { 15 | ValidateAuthorization() gin.HandlerFunc 16 | } 17 | 18 | type mailServiceMiddleware struct { 19 | JWTMiddleware *jwtmiddleware.JWTMiddleware 20 | } 21 | 22 | func (m mailServiceMiddleware) ValidateAuthorization() gin.HandlerFunc { 23 | return func(c *gin.Context) { 24 | if err := m.JWTMiddleware.CheckJWT(c.Writer, c.Request); err != nil { 25 | c.AbortWithStatus(http.StatusUnauthorized) 26 | } 27 | } 28 | } 29 | 30 | func GetMailServiceMiddleware() JWTMiddleware { 31 | return mailServiceMiddleware{ 32 | JWTMiddleware: jwtmiddleware.New(jwtmiddleware.Options{ 33 | ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) { 34 | return []byte(globals.MailServiceJWTPrefix + globals.Conf.App.JwtSecret), nil 35 | }, 36 | UserProperty: jwtUserPropertyForMailService, 37 | SigningMethod: jwt.SigningMethodHS256, 38 | }), 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /middlewares/recovery.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "os" 9 | "runtime" 10 | "strings" 11 | 12 | "github.com/gin-gonic/gin" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | func Recovery() gin.HandlerFunc { 17 | return func(c *gin.Context) { 18 | defer func() { 19 | if err := recover(); err != nil { 20 | // Make sure the client closed connection won't trigger 21 | var brokenPipe bool 22 | if ne, ok := err.(*net.OpError); ok { 23 | if se, ok := ne.Err.(*os.SyscallError); ok { 24 | if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || 25 | strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") { 26 | brokenPipe = true 27 | } 28 | } 29 | } 30 | 31 | log.WithField("detail", err).Errorf("%s", formatRecover(4)) 32 | 33 | if brokenPipe { 34 | c.Abort() 35 | } else { 36 | c.AbortWithStatus(http.StatusInternalServerError) 37 | } 38 | 39 | } 40 | }() 41 | c.Next() 42 | } 43 | } 44 | 45 | // Format panic source in the form of runtime.Stack. 46 | // "skip" stands for the number of the frames to skip before identifing the source of panic 47 | func formatRecover(skip int) (buffer []byte) { 48 | pc := make([]uintptr, 10) 49 | depth := runtime.Callers(skip, pc) 50 | 51 | buf := bytes.Buffer{} 52 | buf.WriteString(getGoroutineState() + "\n") 53 | var lines []string 54 | for i := 0; i < depth; i++ { 55 | fn := runtime.FuncForPC(pc[i]) 56 | if fn != nil { 57 | file, line := fn.FileLine(pc[i]) 58 | lines = append(lines, fmt.Sprintf("%s()\n\t%s:%d +%#x", fn.Name(), file, line, fn.Entry())) 59 | 60 | } 61 | } 62 | buf.WriteString(strings.Join(lines, "\n")) 63 | buffer = buf.Bytes() 64 | return 65 | } 66 | 67 | func getGoroutineState() string { 68 | stack := make([]byte, 64) 69 | stack = stack[:runtime.Stack(stack, false)] 70 | stack = stack[:bytes.Index(stack, []byte("\n"))] 71 | 72 | return string(stack) 73 | } 74 | -------------------------------------------------------------------------------- /migrations/000001_add_existing_table_schemas.down.sql: -------------------------------------------------------------------------------- 1 | SET FOREIGN_KEY_CHECKS=0; 2 | 3 | DROP TABLE IF EXISTS `bookmarks`; 4 | DROP TABLE IF EXISTS `users`; 5 | DROP TABLE IF EXISTS `o_auth_accounts`; 6 | DROP TABLE IF EXISTS `services`; 7 | DROP TABLE IF EXISTS `registrations`; 8 | DROP TABLE IF EXISTS `reporter_accounts`; 9 | DROP TABLE IF EXISTS `users_bookmarks`; 10 | DROP TABLE IF EXISTS `web_push_subs`; 11 | DROP TABLE IF EXISTS `pay_by_prime_donations`; 12 | DROP TABLE IF EXISTS `pay_by_other_method_donations`; 13 | DROP TABLE IF EXISTS `periodic_donations`; 14 | DROP TABLE IF EXISTS `pay_by_card_token_donations`; 15 | 16 | SET FOREIGN_KEY_CHECKS=1; 17 | -------------------------------------------------------------------------------- /migrations/000002_add_linepay_info.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pay_by_prime_donations` 2 | DROP `linepay_method`, 3 | DROP`linepay_point`; 4 | -------------------------------------------------------------------------------- /migrations/000002_add_linepay_info.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pay_by_prime_donations` 2 | ADD `linepay_method` enum('CREDIT_CARD', 'BALANCE', 'POINT') DEFAULT NULL, 3 | ADD `linepay_point` int DEFAULT NULL AFTER `is_anonymous`; 4 | -------------------------------------------------------------------------------- /migrations/000003_update-send-receipt-default-value.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pay_by_prime_donations` ALTER `send_receipt` SET DEFAULT 'yearly'; 2 | ALTER TABLE `periodic_donations` ALTER `send_receipt` SET DEFAULT 'yearly'; 3 | -------------------------------------------------------------------------------- /migrations/000003_update-send-receipt-default-value.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pay_by_prime_donations` ALTER `send_receipt` SET DEFAULT 'no'; 2 | ALTER TABLE `periodic_donations` ALTER `send_receipt` SET DEFAULT 'no'; 3 | -------------------------------------------------------------------------------- /migrations/000004_update_payment_status_refunded.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pay_by_prime_donations` MODIFY `status` enum('paying', 'paid', 'fail'); 2 | ALTER TABLE `pay_by_card_token_donations` MODIFY `status` enum('paying', 'paid', 'fail'); 3 | -------------------------------------------------------------------------------- /migrations/000004_update_payment_status_refunded.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pay_by_prime_donations` MODIFY `status` enum('paying', 'paid', 'fail', 'refunded'); 2 | ALTER TABLE `pay_by_card_token_donations` MODIFY `status` enum('paying', 'paid', 'fail', 'refunded'); 3 | -------------------------------------------------------------------------------- /migrations/000005_increase_bank_result_msg_size.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pay_by_prime_donations` MODIFY `bank_result_msg` varchar(50); 2 | ALTER TABLE `pay_by_card_token_donations` MODIFY `bank_result_msg` varchar(50); 3 | -------------------------------------------------------------------------------- /migrations/000005_increase_bank_result_msg_size.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pay_by_prime_donations` MODIFY `bank_result_msg` varchar(128); 2 | ALTER TABLE `pay_by_card_token_donations` MODIFY `bank_result_msg` varchar(128); 3 | -------------------------------------------------------------------------------- /migrations/000006_add_receipt_header.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pay_by_prime_donations` DROP `receipt_header`; 2 | ALTER TABLE `periodic_donations` DROP `receipt_header`; 3 | ALTER TABLE `pay_by_card_token_donations` DROP `receipt_header`; 4 | ALTER TABLE `pay_by_other_method_donations` DROP `receipt_header`; 5 | -------------------------------------------------------------------------------- /migrations/000006_add_receipt_header.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pay_by_prime_donations` ADD `receipt_header` varchar(128) DEFAULT NULL; 2 | ALTER TABLE `periodic_donations` ADD `receipt_header` varchar(128) DEFAULT NULL; 3 | ALTER TABLE `pay_by_card_token_donations` ADD `receipt_header` varchar(128) DEFAULT NULL; 4 | ALTER TABLE `pay_by_other_method_donations` ADD `receipt_header` varchar(128) DEFAULT NULL; 5 | -------------------------------------------------------------------------------- /migrations/000007_add_donation_redesign_table_schemas.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `users` 2 | DROP `nickname`, 3 | DROP `title`, 4 | DROP `legal_name`, 5 | DROP `age_range`, 6 | DROP `read_preference`, 7 | DROP `words_for_twreporter`; 8 | 9 | ALTER TABLE `pay_by_prime_donations` 10 | DROP `receipt_security_id`, 11 | DROP `receipt_email`, 12 | DROP `receipt_address_country`, 13 | DROP `receipt_address_state`, 14 | DROP `receipt_address_city`, 15 | DROP `receipt_address_detail`, 16 | DROP `receipt_address_zip_code`, 17 | DROP `auto_tax_deduction`, 18 | DROP `cardholder_last_name`, 19 | DROP `cardholder_first_name`, 20 | DROP `cardholder_security_id`, 21 | DROP `cardholder_gender`, 22 | DROP `cardholder_nickname`, 23 | DROP `cardholder_title`, 24 | DROP `cardholder_legal_name`, 25 | DROP `cardholder_address_country`, 26 | DROP `cardholder_address_state`, 27 | DROP `cardholder_address_city`, 28 | DROP `cardholder_address_detail`, 29 | DROP `cardholder_address_zip_code`, 30 | DROP `cardholder_age_range`, 31 | DROP `cardholder_read_preference`, 32 | DROP `cardholder_words_for_twreporter`; 33 | 34 | ALTER TABLE `periodic_donations` 35 | DROP `pay_method`, 36 | DROP `receipt_security_id`, 37 | DROP `receipt_email`, 38 | DROP `receipt_address_country`, 39 | DROP `receipt_address_state`, 40 | DROP `receipt_address_city`, 41 | DROP `receipt_address_detail`, 42 | DROP `receipt_address_zip_code`, 43 | DROP `auto_tax_deduction`, 44 | DROP `cardholder_last_name`, 45 | DROP `cardholder_first_name`, 46 | DROP `cardholder_security_id`, 47 | DROP `cardholder_gender`, 48 | DROP `cardholder_nickname`, 49 | DROP `cardholder_title`, 50 | DROP `cardholder_legal_name`, 51 | DROP `cardholder_address_country`, 52 | DROP `cardholder_address_state`, 53 | DROP `cardholder_address_city`, 54 | DROP `cardholder_address_detail`, 55 | DROP `cardholder_address_zip_code`, 56 | DROP `cardholder_age_range`, 57 | DROP `cardholder_read_preference`, 58 | DROP `cardholder_words_for_twreporter`; 59 | -------------------------------------------------------------------------------- /migrations/000007_add_donation_redesign_table_schemas.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `users` 2 | ADD `nickname` varchar(50) DEFAULT NULL, 3 | ADD `title` varchar(30) DEFAULT NULL, 4 | ADD `legal_name` varchar(30) DEFAULT NULL, 5 | ADD `age_range` enum('less_than_18', '18_to_24', '25_to_34', '35_to_44', '45_to_54', '55_to_64', 'above_65') DEFAULT NULL, 6 | ADD `read_preference` set('international', 'cross_straits', 'human_right', 'society', 'environment', 'education', 'politics', 'economy', 'culture', 'art', 'life', 'health', 'sport', 'all') DEFAULT NULL, 7 | ADD `words_for_twreporter` varchar(255) DEFAULT NULL; 8 | 9 | ALTER TABLE `pay_by_prime_donations` 10 | ADD `receipt_security_id` varchar(20) DEFAULT NULL, 11 | ADD `receipt_email` varchar(100) DEFAULT NULL, 12 | ADD `receipt_address_country` varchar(45) DEFAULT NULL, 13 | ADD `receipt_address_state` varchar(45) DEFAULT NULL, 14 | ADD `receipt_address_city` varchar(45) DEFAULT NULL, 15 | ADD `receipt_address_detail` varchar(255) DEFAULT NULL, 16 | ADD `receipt_address_zip_code` varchar(10) DEFAULT NULL, 17 | ADD `auto_tax_deduction` tinyint(1) DEFAULT NULL, 18 | ADD `cardholder_last_name` varchar(30) DEFAULT NULL, 19 | ADD `cardholder_first_name` varchar(30) DEFAULT NULL, 20 | ADD `cardholder_security_id` varchar(20) DEFAULT NULL, 21 | ADD `cardholder_gender` varchar(2) DEFAULT NULL, 22 | ADD `cardholder_nickname` varchar(50) DEFAULT NULL, 23 | ADD `cardholder_title` varchar(30) DEFAULT NULL, 24 | ADD `cardholder_legal_name` varchar(30) DEFAULT NULL, 25 | ADD `cardholder_address_country` varchar(45) DEFAULT NULL, 26 | ADD `cardholder_address_state` varchar(45) DEFAULT NULL, 27 | ADD `cardholder_address_city` varchar(45) DEFAULT NULL, 28 | ADD `cardholder_address_detail` varchar(255) DEFAULT NULL, 29 | ADD `cardholder_address_zip_code` varchar(10) DEFAULT NULL, 30 | ADD `cardholder_age_range` enum('less_than_18', '18_to_24', '25_to_34', '35_to_44', '45_to_54', '55_to_64', 'above_65') DEFAULT NULL, 31 | ADD `cardholder_read_preference` set('international', 'cross_straits', 'human_right', 'society', 'environment', 'education', 'politics', 'economy', 'culture', 'art', 'life', 'health', 'sport', 'all') DEFAULT NULL, 32 | ADD `cardholder_words_for_twreporter` varchar(255) DEFAULT NULL; 33 | 34 | ALTER TABLE `periodic_donations` 35 | ADD `pay_method` enum('credit_card', 'line', 'apple', 'google', 'samsung') DEFAULT NULL, 36 | ADD `receipt_security_id` varchar(20) DEFAULT NULL, 37 | ADD `receipt_email` varchar(100) DEFAULT NULL, 38 | ADD `receipt_address_country` varchar(45) DEFAULT NULL, 39 | ADD `receipt_address_state` varchar(45) DEFAULT NULL, 40 | ADD `receipt_address_city` varchar(45) DEFAULT NULL, 41 | ADD `receipt_address_detail` varchar(255) DEFAULT NULL, 42 | ADD `receipt_address_zip_code` varchar(10) DEFAULT NULL, 43 | ADD `auto_tax_deduction` tinyint(1) DEFAULT NULL, 44 | ADD `cardholder_last_name` varchar(30) DEFAULT NULL, 45 | ADD `cardholder_first_name` varchar(30) DEFAULT NULL, 46 | ADD `cardholder_security_id` varchar(20) DEFAULT NULL, 47 | ADD `cardholder_gender` varchar(2) DEFAULT NULL, 48 | ADD `cardholder_nickname` varchar(50) DEFAULT NULL, 49 | ADD `cardholder_title` varchar(30) DEFAULT NULL, 50 | ADD `cardholder_legal_name` varchar(30) DEFAULT NULL, 51 | ADD `cardholder_address_country` varchar(45) DEFAULT NULL, 52 | ADD `cardholder_address_state` varchar(45) DEFAULT NULL, 53 | ADD `cardholder_address_city` varchar(45) DEFAULT NULL, 54 | ADD `cardholder_address_detail` varchar(255) DEFAULT NULL, 55 | ADD `cardholder_address_zip_code` varchar(10) DEFAULT NULL, 56 | ADD `cardholder_age_range` enum('less_than_18', '18_to_24', '25_to_34', '35_to_44', '45_to_54', '55_to_64', 'above_65') DEFAULT NULL, 57 | ADD `cardholder_read_preference` set('international', 'cross_straits', 'human_right', 'society', 'environment', 'education', 'politics', 'economy', 'culture', 'art', 'life', 'health', 'sport', 'all') DEFAULT NULL, 58 | ADD `cardholder_words_for_twreporter` varchar(255) DEFAULT NULL; 59 | -------------------------------------------------------------------------------- /migrations/000008_update_donation_redesign_table_schemas.down.sql: -------------------------------------------------------------------------------- 1 | UPDATE `pay_by_prime_donations` 2 | SET `send_receipt` = CASE `send_receipt` 3 | WHEN 'no_receipt' THEN 'no' 4 | WHEN 'paperback_receipt_by_year' THEN 'yearly' 5 | WHEN 'paperback_receipt_by_month' THEN 'monthly' 6 | END, 7 | `notes` = `cardholder_words_for_twreporter`, 8 | `cardholder_national_id` = `cardholder_security_id`, 9 | `cardholder_zip_code` = `cardholder_address_zip_code`, 10 | `cardholder_address` = CONCAT(`cardholder_address_country`, `cardholder_address_state`, `cardholder_address_city`, `cardholder_address_detail`); 11 | ALTER TABLE `pay_by_prime_donations` MODIFY `send_receipt` enum('yearly', 'monthly', 'no') DEFAULT 'yearly'; 12 | 13 | UPDATE `periodic_donations` 14 | SET `send_receipt` = CASE `send_receipt` 15 | WHEN 'no_receipt' THEN 'no' 16 | WHEN 'paperback_receipt_by_year' THEN 'yearly' 17 | WHEN 'paperback_receipt_by_month' THEN 'monthly' 18 | END, 19 | `notes` = `cardholder_words_for_twreporter`, 20 | `cardholder_national_id` = `cardholder_security_id`, 21 | `cardholder_zip_code` = `cardholder_address_zip_code`, 22 | `cardholder_address` = CONCAT(`cardholder_address_country`, `cardholder_address_state`, `cardholder_address_city`, `cardholder_address_detail`); 23 | ALTER TABLE `periodic_donations` MODIFY `send_receipt` enum('yearly', 'monthly', 'no') DEFAULT 'yearly'; 24 | -------------------------------------------------------------------------------- /migrations/000008_update_donation_redesign_table_schemas.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pay_by_prime_donations` MODIFY `send_receipt` enum('yearly', 'monthly', 'no', 'no_receipt', 'digital_receipt_by_month', 'digital_receipt_by_year', 'paperback_receipt_by_month', 'paperback_receipt_by_year') DEFAULT 'no_receipt'; 2 | UPDATE `pay_by_prime_donations` 3 | SET `send_receipt` = CASE `send_receipt` 4 | WHEN 'no' THEN 'no_receipt' 5 | WHEN 'yearly' THEN 'paperback_receipt_by_year' 6 | WHEN 'monthly' THEN 'paperback_receipt_by_month' 7 | END, 8 | `cardholder_words_for_twreporter` = `notes`, 9 | `cardholder_security_id` = `cardholder_national_id`, 10 | `cardholder_address_zip_code` = `cardholder_zip_code`, 11 | `cardholder_address_detail` = `cardholder_address`; 12 | 13 | ALTER TABLE `periodic_donations` MODIFY `send_receipt` enum('yearly', 'monthly', 'no', 'no_receipt', 'digital_receipt_by_month', 'digital_receipt_by_year', 'paperback_receipt_by_month', 'paperback_receipt_by_year') DEFAULT 'no_receipt'; 14 | UPDATE `periodic_donations` 15 | SET `send_receipt` = CASE `send_receipt` 16 | WHEN 'no' THEN 'no_receipt' 17 | WHEN 'yearly' THEN 'paperback_receipt_by_year' 18 | WHEN 'monthly' THEN 'paperback_receipt_by_month' 19 | END, 20 | `cardholder_words_for_twreporter` = `notes`, 21 | `cardholder_security_id` = `cardholder_national_id`, 22 | `cardholder_address_zip_code` = `cardholder_zip_code`, 23 | `cardholder_address_detail` = `cardholder_address`; 24 | -------------------------------------------------------------------------------- /migrations/000009_add_mailjob_tables_schemas.down.sql: -------------------------------------------------------------------------------- 1 | SET FOREIGN_KEY_CHECKS = 0; 2 | 3 | DROP TABLE IF EXISTS `users_mailgroups`; 4 | DROP TABLE IF EXISTS `jobs_mailchimp`; 5 | 6 | SET FOREIGN_KEY_CHECKS = 1; -------------------------------------------------------------------------------- /migrations/000009_add_mailjob_tables_schemas.up.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- Table structure for table `jobs_mailchimp` 3 | -- 4 | 5 | /*!40101 SET @saved_cs_client = @@character_set_client */; 6 | /*!40101 SET character_set_client = utf8 */; 7 | 8 | CREATE TABLE IF NOT EXISTS `jobs_mailchimp` ( 9 | `id` int (10) unsigned NOT NULL AUTO_INCREMENT, 10 | `receiver` varchar(255) NOT NULL COMMENT 'email address of the subscriber', 11 | `interests` varchar(255) NOT NULL COMMENT 'JSON array for interest IDs', 12 | `state` varchar(10) NOT NULL DEFAULT 'new' COMMENT 'new / processing / fail', 13 | `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, 14 | `updated_at` timestamp NULL DEFAULT NULL, 15 | PRIMARY KEY (`id`) 16 | ) ENGINE = InnoDB DEFAULT CHARSET = utf8; 17 | 18 | /*!40101 SET character_set_client = @saved_cs_client */; 19 | 20 | -- 21 | -- Table structure for table `users_mailgroup` 22 | -- 23 | 24 | /*!40101 SET @saved_cs_client = @@character_set_client */; 25 | /*!40101 SET character_set_client = utf8 */; 26 | 27 | CREATE TABLE `users_mailgroups` ( 28 | `user_id` int(10) unsigned NOT NULL, 29 | `mailgroup_id` varchar(255) NOT NULL COMMENT 'interest ID from MailChimp', 30 | `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, 31 | PRIMARY KEY (`user_id`,`mailgroup_id`), 32 | CONSTRAINT `fk_users_mailgroup_users_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION 33 | ) ENGINE = InnoDB DEFAULT CHARSET = utf8; 34 | 35 | /*!40101 SET character_set_client = @saved_cs_client */; -------------------------------------------------------------------------------- /migrations/000010_create_roles_table.down.sql: -------------------------------------------------------------------------------- 1 | SET FOREIGN_KEY_CHECKS=0; 2 | 3 | DROP TABLE IF EXISTS `roles`; 4 | 5 | SET FOREIGN_KEY_CHECKS=1; 6 | -------------------------------------------------------------------------------- /migrations/000010_create_roles_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `roles` ( 2 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 3 | `name` varchar(50) NOT NULL, 4 | `name_en` varchar(50) NOT NULL, 5 | `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, 6 | `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 7 | `deleted_at` timestamp NULL DEFAULT NULL, 8 | PRIMARY KEY (`id`) 9 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 10 | 11 | INSERT INTO `roles` (`name`, `name_en`) VALUES ('探索者', 'explorer'); 12 | INSERT INTO `roles` (`name`, `name_en`) VALUES ('行動者', 'action taker'); 13 | INSERT INTO `roles` (`name`, `name_en`) VALUES ('開創者', 'trailblazer'); 14 | -------------------------------------------------------------------------------- /migrations/000011_create_users_roles_table.down.sql: -------------------------------------------------------------------------------- 1 | SET FOREIGN_KEY_CHECKS=0; 2 | 3 | DROP TABLE IF EXISTS `users_roles`; 4 | 5 | SET FOREIGN_KEY_CHECKS=1; 6 | -------------------------------------------------------------------------------- /migrations/000011_create_users_roles_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `users_roles` ( 2 | `user_id` INT(10) UNSIGNED NOT NULL, 3 | `role_id` INT(10) UNSIGNED NOT NULL, 4 | `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, 5 | `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 6 | `deleted_at` TIMESTAMP NULL DEFAULT NULL, 7 | PRIMARY KEY (`user_id`, `role_id`), 8 | INDEX `role_id_idx` (`role_id` ASC), 9 | CONSTRAINT `user_id` 10 | FOREIGN KEY (`user_id`) 11 | REFERENCES `users` (`id`) 12 | ON DELETE CASCADE 13 | ON UPDATE NO ACTION, 14 | CONSTRAINT `role_id` 15 | FOREIGN KEY (`role_id`) 16 | REFERENCES `roles` (`id`) 17 | ON DELETE CASCADE 18 | ON UPDATE NO ACTION 19 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 20 | -------------------------------------------------------------------------------- /migrations/000012_add_users_activated.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `users` 2 | DROP COLUMN `activated`; 3 | -------------------------------------------------------------------------------- /migrations/000012_add_users_activated.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `users` 2 | ADD COLUMN `activated` TIMESTAMP NULL DEFAULT NULL COMMENT 'null - deactivated; timestamp - activated;'; 3 | -------------------------------------------------------------------------------- /migrations/000013_add_roles_key.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `roles` 2 | DROP COLUMN `key`; 3 | -------------------------------------------------------------------------------- /migrations/000013_add_roles_key.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `roles` 2 | ADD COLUMN `key` varchar(50) NOT NULL AFTER `name_en`; 3 | 4 | UPDATE `roles` SET `key` = 'explorer' WHERE (`name_en` = 'explorer'); 5 | UPDATE `roles` SET `key` = 'action_taker' WHERE (`name_en` = 'action taker'); 6 | UPDATE `roles` SET `key` = 'trailblazer' WHERE (`name_en` = 'trailblazer'); -------------------------------------------------------------------------------- /migrations/000014_add_user_source_and_multiple_roles.down.sql: -------------------------------------------------------------------------------- 1 | -- drop column 2 | ALTER TABLE `users` DROP `source`; 3 | ALTER TABLE `roles` DROP `weight`; 4 | ALTER TABLE `users_roles` DROP `expired_at`; 5 | 6 | -- remove added roles 7 | DELETE FROM `roles` WHERE `key` = 'action_taker_ntch'; 8 | DELETE FROM `roles` WHERE `key` = 'trailblazer_ntch'; 9 | -------------------------------------------------------------------------------- /migrations/000014_add_user_source_and_multiple_roles.up.sql: -------------------------------------------------------------------------------- 1 | -- add new columns 2 | ALTER TABLE `users` ADD `source` set('ntch') DEFAULT NULL; 3 | ALTER TABLE `roles` ADD `weight` int(10) DEFAULT NULL; 4 | ALTER TABLE `users_roles` ADD `expired_at` timestamp NULL DEFAULT NULL; 5 | 6 | -- add new roles 7 | INSERT INTO `roles` (`key`, `name`, `name_en`) VALUES ('action_taker_ntch', '行動者', 'action taker'); 8 | INSERT INTO `roles` (`key`, `name`, `name_en`) VALUES ('trailblazer_ntch', '開創者', 'trailblazer'); 9 | 10 | -- update roles.weight value 11 | UPDATE `roles` SET `weight` = 1 WHERE (`key` = 'explorer'); 12 | UPDATE `roles` SET `weight` = 5 WHERE (`key` = 'action_taker_ntch'); 13 | UPDATE `roles` SET `weight` = 9 WHERE (`key` = 'action_taker'); 14 | UPDATE `roles` SET `weight` = 13 WHERE (`key` = 'trailblazer_ntch'); 15 | UPDATE `roles` SET `weight` = 17 WHERE (`key` = 'trailblazer'); 16 | -------------------------------------------------------------------------------- /migrations/000015_add_user_reading_count_time.down.sql: -------------------------------------------------------------------------------- 1 | -- drop column 2 | ALTER TABLE `users` DROP `agree_data_collection`; 3 | ALTER TABLE `users` DROP `read_posts_count`; 4 | ALTER TABLE `users` DROP `read_posts_sec`; 5 | 6 | -- drop tables 7 | DROP TABLE IF EXISTS `users_posts_reading_counts`; 8 | DROP TABLE IF EXISTS `users_posts_reading_times`; 9 | -------------------------------------------------------------------------------- /migrations/000015_add_user_reading_count_time.up.sql: -------------------------------------------------------------------------------- 1 | -- add new columns 2 | ALTER TABLE `users` ADD `agree_data_collection` tinyint(1) DEFAULT 1; 3 | ALTER TABLE `users` ADD `read_posts_count` int(10) unsigned DEFAULT 0; 4 | ALTER TABLE `users` ADD `read_posts_sec` int(10) unsigned DEFAULT 0; 5 | 6 | -- add new tables 7 | CREATE TABLE IF NOT EXISTS `users_posts_reading_counts` ( 8 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 9 | `created_at` timestamp NULL DEFAULT NULL, 10 | `updated_at` timestamp NULL DEFAULT NULL, 11 | `deleted_at` timestamp NULL DEFAULT NULL, 12 | `user_id` int(10) unsigned NOT NULL, 13 | `post_id` varchar(50) NOT NULL, 14 | PRIMARY KEY (`id`), 15 | KEY `fk_users_read_posts_users1_idx` (`user_id`), 16 | CONSTRAINT `fk_users_read_posts_users1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION 17 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 18 | 19 | CREATE TABLE IF NOT EXISTS `users_posts_reading_times` ( 20 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 21 | `created_at` timestamp NULL DEFAULT NULL, 22 | `updated_at` timestamp NULL DEFAULT NULL, 23 | `deleted_at` timestamp NULL DEFAULT NULL, 24 | `seconds` int(10) NOT NULL, 25 | `user_id` int(10) unsigned NOT NULL, 26 | `post_id` varchar(50) NOT NULL, 27 | PRIMARY KEY (`id`), 28 | KEY `fk_users_posts_seconds_users1_idx` (`user_id`), 29 | CONSTRAINT `fk_users_posts_seconds_users1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION 30 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 31 | -------------------------------------------------------------------------------- /migrations/000016_add_user_reading_footprint.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS `users_posts_reading_footprints`; 2 | -------------------------------------------------------------------------------- /migrations/000016_add_user_reading_footprint.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `users_posts_reading_footprints` ( 2 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 3 | `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 4 | `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | `deleted_at` timestamp NULL DEFAULT NULL, 6 | `user_id` int(10) unsigned NOT NULL, 7 | `post_id` varchar(50) NOT NULL, 8 | PRIMARY KEY (`id`), 9 | KEY `fk_users_read_posts_users3_idx` (`user_id`), 10 | CONSTRAINT `fk_users_read_posts_users3` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION 11 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 12 | -------------------------------------------------------------------------------- /migrations/000017_increase_bookmarks_slug_size.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `bookmarks` MODIFY `slug` varchar(100); -------------------------------------------------------------------------------- /migrations/000017_increase_bookmarks_slug_size.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `bookmarks` MODIFY `slug` varchar(500); -------------------------------------------------------------------------------- /migrations/000018_update_users_bookmarks_schema.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `users_bookmarks` DROP `post_id`; 2 | ALTER TABLE `bookmarks` DROP `post_id`; 3 | -------------------------------------------------------------------------------- /migrations/000018_update_users_bookmarks_schema.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `users_bookmarks` ADD `post_id` varchar(50); 2 | ALTER TABLE `bookmarks` ADD `post_id` varchar(50); 3 | -------------------------------------------------------------------------------- /migrations/000019_change_ntch_roles.down.sql: -------------------------------------------------------------------------------- 1 | -- remove added roles 2 | DELETE FROM `roles` WHERE `key` = 'trailblazer_ntch_3'; 3 | DELETE FROM `roles` WHERE `key` = 'trailblazer_ntch_12'; 4 | -------------------------------------------------------------------------------- /migrations/000019_change_ntch_roles.up.sql: -------------------------------------------------------------------------------- 1 | -- add new roles for ntch policy change 2 | INSERT INTO `roles` (`key`, `name`, `name_en`, `weight`) VALUES ('trailblazer_ntch_3', '開創者', 'trailblazer', 14); 3 | INSERT INTO `roles` (`key`, `name`, `name_en`, `weight`) VALUES ('trailblazer_ntch_12', '開創者', 'trailblazer', 15); 4 | -------------------------------------------------------------------------------- /migrations/000020_add_receipt_no_column.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pay_by_prime_donations` 2 | DROP COLUMN `receipt_number`; 3 | 4 | ALTER TABLE `pay_by_card_token_donations` 5 | DROP COLUMN `receipt_number`; -------------------------------------------------------------------------------- /migrations/000020_add_receipt_no_column.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `pay_by_prime_donations` 2 | ADD COLUMN `receipt_number` varchar(13) DEFAULT NULL; 3 | 4 | ALTER TABLE `pay_by_card_token_donations` 5 | ADD COLUMN `receipt_number` varchar(13) DEFAULT NULL; -------------------------------------------------------------------------------- /migrations/000021_add_receipt_serial_number_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS `receipt_serial_numbers`; -------------------------------------------------------------------------------- /migrations/000021_add_receipt_serial_number_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `receipt_serial_numbers` ( 2 | `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 3 | `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 4 | `serial_number` int(10) unsigned NOT NULL, 5 | `YYYYMM` varchar(6) NOT NULL, 6 | PRIMARY KEY (`YYYYMM`) 7 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 8 | -------------------------------------------------------------------------------- /migrations/000022_add_name_to_users_table.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `users` 2 | DROP COLUMN `name`; -------------------------------------------------------------------------------- /migrations/000022_add_name_to_users_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `users` 2 | ADD COLUMN `name` VARCHAR(191) DEFAULT NULL; -------------------------------------------------------------------------------- /models/bookmark.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | //"database/sql" 5 | "time" 6 | ) 7 | 8 | // Bookmark this is bookmarks table description 9 | type Bookmark struct { 10 | ID uint `gorm:"primary_key" json:"id"` 11 | CreatedAt time.Time `json:"created_at"` 12 | UpdatedAt time.Time `json:"updated_at"` 13 | DeletedAt *time.Time `json:"deleted_at"` 14 | Users []User `gorm:"many2many:users_bookmarks;"` 15 | Slug string `gorm:"size:100;not null" json:"slug" form:"slug" binding:"required"` 16 | Title string `gorm:"size:100;not null" json:"title" form:"title" binding:"required"` 17 | Desc string `gorm:"size:250" json:"desc" form:"desc"` 18 | Host string `gorm:"size:100;not null;" json:"host" form:"host" binding:"required"` 19 | Category string `gorm:"size:20" json:"category" form:"category"` 20 | IsExternal bool `gorm:"default:0" json:"is_external" form:"is_external"` 21 | Thumbnail string `gorm:"size:512" json:"thumbnail" form:"thumbnail" binding:"required"` 22 | Authors string `gorm:"size:250" json:"authors" form:"authors"` 23 | PubDate uint `gorm:"not null;default:0" json:"published_date" form:"published_date"` 24 | PostID string `gorm:"size:50;not null" json:"post_id" form:"post_id"` 25 | } 26 | 27 | type UserBookmark struct { 28 | AddedAt time.Time `json:"added_at" db:"users_bookmarks.added_at"` 29 | PostID string `json:"post_id" db:"users_bookmarks.post_id"` 30 | ID uint `json:"id" db:"bookmarks.id"` 31 | Slug string `json:"slug" db:"bookmarks.slug"` 32 | Title string `json:"title" db:"bookmarks.title"` 33 | Desc string `json:"desc" db:"bookmarks.desc"` 34 | Host string `json:"host" db:"bookmarks.host"` 35 | Category string `json:"category" db:"bookmarks.category"` 36 | IsExternal bool `json:"is_external" db:"bookmarks.is_external"` 37 | Thumbnail string `json:"thumbnail" db:"bookmarks.thumbnail"` 38 | Authors string `json:"authors" db:"bookmarks.authors"` 39 | PubDate uint `json:"published_date" db:"bookmarks.published_date"` 40 | } 41 | -------------------------------------------------------------------------------- /models/gin-response.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type MetaOfResponse struct { 4 | Total int `json:"total"` 5 | Offset int `json:"offset"` 6 | Limit int `json:"limit"` 7 | } 8 | -------------------------------------------------------------------------------- /models/model_user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "gopkg.in/guregu/null.v3" 7 | ) 8 | 9 | // User ... 10 | type User struct { 11 | ID uint `gorm:"primary_key" json:"id"` 12 | CreatedAt time.Time `json:"created_at"` 13 | UpdatedAt time.Time `json:"updated_at"` 14 | DeletedAt *time.Time `json:"deleted_at"` 15 | OAuthAccounts []OAuthAccount `gorm:"ForeignKey:UserID"` // a user has multiple oauth accounts // 16 | ReporterAccount ReporterAccount `gorm:"ForeignKey:UserID"` 17 | Bookmarks []Bookmark `gorm:"many2many:users_bookmarks;"` 18 | MailGroups []UsersMailgroups `gorm:"one2many:users_mailgroups;"` 19 | Email null.String `gorm:"size:100" json:"email"` 20 | FirstName null.String `gorm:"size:50" json:"firstname"` 21 | LastName null.String `gorm:"size:50" json:"lastname"` 22 | Nickname null.String `gorm:"size:50" json:"nickname"` 23 | SecurityID null.String `gorm:"size:20" json:"security_id"` 24 | PassportID null.String `gorm:"size:30" json:"passport_id"` 25 | Title null.String `gorm:"size:30" json:"title"` 26 | LegalName null.String `gorm:"size:50" json:"legal_name"` 27 | City null.String `gorm:"size:45" json:"city"` 28 | State null.String `gorm:"size:45" json:"state"` 29 | Country null.String `gorm:"size:45" json:"country"` 30 | Zip null.String `gorm:"size:20" json:"zip"` 31 | Address null.String `json:"address"` 32 | Phone null.String `gorm:"size:20" json:"phone"` 33 | Privilege int `gorm:"type:int(5);not null" json:"privilege"` 34 | RegistrationDate null.Time `json:"registration_date"` 35 | Birthday null.Time `json:"birthday"` 36 | Gender null.String `gorm:"size:2" json:"gender"` // e.g., "M", "F", "X, "U" 37 | AgeRange null.String `gorm:"type:ENUM('less_than_18', '18_to_24', '25_to_34', '35_to_44', '45_to_54', '55_to_64', 'above_65')" json:"age_range"` 38 | Education null.String `gorm:"size:20" json:"education"` // e.g., "High School" 39 | EnableEmail int `gorm:"type:int(5);size:2" json:"enable_email"` 40 | ReadPreference null.String `gorm:"type:SET('international', 'cross_straits', 'human_right', 'society', 'environment', 'education', 'politics', 'economy', 'culture', 'art', 'life', 'health', 'sport', 'all')" json:"read_preference"` // e.g. "international, art, sport" 41 | WordsForTwreporter null.String `gorm:"size:255" json:"words_for_twreporter"` 42 | Roles []Role `gorm:"many2many:users_roles" json:"roles"` 43 | Activated null.Time `json:"activated"` 44 | Source null.String `gorm:"type:SET('ntch')" json:"source"` 45 | AgreeDataCollection bool `gorm:"type:tinyint(1);default:1" json:"agree_data_collection"` 46 | ReadPostsCount int `gorm:"type:int(10);unsigned" json:"read_posts_count"` 47 | ReadPostsSec int `gorm:"type:int(10);unsigned" json:"read_posts_sec"` 48 | } 49 | 50 | // Role represents a user role 51 | type Role struct { 52 | ID string `json:"id"` 53 | Name string `json:"name"` 54 | NameEn string `json:"name_en"` 55 | Key string `json:"key"` 56 | CreatedAt time.Time `json:"created_at"` 57 | UpdatedAt time.Time `json:"updated_at"` 58 | Weight int `json:"weight"` 59 | } 60 | 61 | // OAuthAccount ... 62 | type OAuthAccount struct { 63 | ID uint `gorm:"primary_key" json:"id"` 64 | CreatedAt time.Time `json:"created_at"` 65 | UpdatedAt time.Time `json:"updated_at"` 66 | DeletedAt *time.Time `json:"deleted_at"` 67 | UserID uint `json:"user_id"` 68 | Type string `gorm:"size:10" json:"type"` // Facebook / Google ... 69 | AId null.String `gorm:"not null" json:"a_id"` // user ID returned by OAuth services 70 | Email null.String `gorm:"size:100" json:"email"` 71 | Name null.String `gorm:"size:80" json:"name"` 72 | FirstName null.String `gorm:"size:50" json:"firstname"` 73 | LastName null.String `gorm:"size:50" json:"lastname"` 74 | Gender null.String `gorm:"size:20" json:"gender"` 75 | Picture null.String `json:"picture"` // user profile photo url 76 | Birthday null.String `json:"birthday"` 77 | } 78 | 79 | // ReporterAccount ... 80 | type ReporterAccount struct { 81 | UserID uint `json:"user_id"` 82 | CreatedAt time.Time `json:"created_at"` 83 | UpdatedAt time.Time `json:"updated_at"` 84 | DeletedAt *time.Time `json:"deleted_at"` 85 | ID uint `gorm:"primary_key" json:"id"` 86 | Email string `gorm:"size:100;unique_index;not null" json:"email"` 87 | ActivateToken string `gorm:"size:50" json:"activate_token"` 88 | ActExpTime time.Time `json:"-"` 89 | } 90 | -------------------------------------------------------------------------------- /models/oauth.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // AuthenticatedResponse defines the user info fields 4 | type AuthenticatedResponse struct { 5 | ID uint `json:"id"` 6 | Privilege int `json:"privilege"` 7 | FirstName string `json:"firstname"` 8 | LastName string `json:"lastname"` 9 | Email string `json:"email"` 10 | Jwt string `json:"jwt"` 11 | } 12 | -------------------------------------------------------------------------------- /models/registration.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Registration this is bookmakrs table description 8 | type Registration struct { 9 | CreatedAt time.Time `json:"created_at"` 10 | UpdatedAt time.Time `json:"updated_at"` 11 | DeletedAt *time.Time `json:"deleted_at"` 12 | Service Service 13 | ServiceID uint `gorm:"primary_key" json:"service_id"` 14 | Email string `gorm:"primary_key;size:100" json:"email"` 15 | User User 16 | UserID uint `gorm:"default:0" json:"user_id"` 17 | Active bool `gorm:"default:0" json:"active"` 18 | ActivateToken string `gorm:"size:20" json:"active_token"` 19 | } 20 | 21 | // RegistrationJSON this is POST data in json format 22 | type RegistrationJSON struct { 23 | Email string `json:"email" binding:"required"` 24 | UserID string `json:"uid"` 25 | Active bool `json:"active"` 26 | ActivateToken string `json:"active_token"` 27 | } 28 | -------------------------------------------------------------------------------- /models/service.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Service this is service table description 8 | type Service struct { 9 | ID uint `gorm:"primary_key" json:"id"` 10 | CreatedAt time.Time `json:"created_at"` 11 | UpdatedAt time.Time `json:"updated_at"` 12 | DeletedAt *time.Time `json:"deleted_at"` 13 | Name string `gorm:"size:100;unique_index;not null" json:"name"` 14 | } 15 | 16 | // ServiceJSON ... 17 | type ServiceJSON struct { 18 | ID uint `json:"id"` 19 | Name string `json:"name" binding:"required"` 20 | } 21 | -------------------------------------------------------------------------------- /models/subscription.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // TODO add foreign key to bind web push subscription with user later 8 | // WebPushSubscription - a data model which is used by storage to communicate with persistent database 9 | type WebPushSubscription struct { 10 | ID uint `gorm:"primary_key" json:"id"` 11 | CreatedAt time.Time `json:"created_at"` 12 | UpdatedAt time.Time `json:"updated_at"` 13 | Endpoint string `gorm:"unique" json:"endpoint"` 14 | Crc32Endpoint uint32 `gorm:"index;" json:"crc32_endpoint"` 15 | Keys string `json:"-"` 16 | ExpirationTime *time.Time `json:"expiration_time"` 17 | UserID *uint `json:"user_id"` 18 | } 19 | 20 | // SetExpirationTime - set the pointer of expireTime into WebPushSubscription struct. 21 | // The reason why we set the pointer, not the value, into struct 22 | // is because we want to remain the NULL(nil) value if expireTime is not provided. 23 | func (wpSub *WebPushSubscription) SetExpirationTime(expireTime int64) { 24 | _expireTime := time.Unix(expireTime, 0) 25 | wpSub.ExpirationTime = &_expireTime 26 | } 27 | 28 | // SetUserID - set the pointer of userID into WebPushSubscription struct 29 | func (wpSub *WebPushSubscription) SetUserID(userID uint) { 30 | wpSub.UserID = &userID 31 | } 32 | 33 | // set WebPushSubscription's table name to be `web_push_subs` 34 | func (WebPushSubscription) TableName() string { 35 | return "web_push_subs" 36 | } 37 | -------------------------------------------------------------------------------- /models/user_preferences.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type UserPreference struct { 8 | ReadPreference []string `json:"read_preference"` 9 | Maillist []string `json:"maillist"` 10 | } 11 | 12 | type UsersMailgroups struct { 13 | UserID int 14 | MailgroupID string 15 | CreatedAt time.Time 16 | } 17 | -------------------------------------------------------------------------------- /models/users_bookmarks.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/jinzhu/gorm" 9 | ) 10 | 11 | // UsersBookmarks users and bookmarks many-to-many table 12 | type UsersBookmarks struct { 13 | gorm.JoinTableHandler 14 | UserID int 15 | BookmarkID int 16 | CreatedAt time.Time 17 | PostID string 18 | } 19 | 20 | // Add - implement gorm.JoinTableHandlerInterface Add method 21 | // Use gorm to create the record automatically populated with CreatedAt 22 | func (*UsersBookmarks) Add(handler gorm.JoinTableHandlerInterface, db *gorm.DB, foreignValue interface{}, associationValue interface{}) error { 23 | foreignPrimaryKey, _ := strconv.Atoi(fmt.Sprint(db.NewScope(foreignValue).PrimaryKeyValue())) 24 | associationPrimaryKey, _ := strconv.Atoi(fmt.Sprint(db.NewScope(associationValue).PrimaryKeyValue())) 25 | 26 | postIDField, ok := db.NewScope(associationValue).FieldByName("post_id") 27 | var postID string 28 | if ok != false { 29 | postID = postIDField.Field.String() 30 | } 31 | 32 | return db.Create(&UsersBookmarks{ 33 | UserID: foreignPrimaryKey, 34 | BookmarkID: associationPrimaryKey, 35 | CreatedAt: time.Now(), 36 | PostID: postID, 37 | }).Error 38 | } 39 | -------------------------------------------------------------------------------- /models/users_posts.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // UsersPostsReadingCount: users and reading posts count are one-to-many table 8 | type UsersPostsReadingCount struct { 9 | UserID int 10 | PostID string 11 | CreatedAt time.Time 12 | UpdatedAt time.Time 13 | DeletedAt *time.Time 14 | } 15 | 16 | // UsersPostsReadingTime: users and reading posts time are one-to-many table 17 | type UsersPostsReadingTime struct { 18 | UserID int 19 | PostID string 20 | CreatedAt time.Time 21 | UpdatedAt time.Time 22 | DeletedAt *time.Time 23 | Seconds int 24 | } 25 | 26 | // UsersPostsReadingFootprint: users and reading posts footprint are one-to-many table 27 | type UsersPostsReadingFootprint struct { 28 | UserID int 29 | PostID string 30 | CreatedAt time.Time 31 | UpdatedAt time.Time 32 | DeletedAt *time.Time 33 | } 34 | -------------------------------------------------------------------------------- /storage/bookmark.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/pkg/errors" 8 | 9 | "github.com/twreporter/go-api/models" 10 | ) 11 | 12 | var ( 13 | bookmarksStr = "Bookmarks" 14 | ) 15 | 16 | // GetABookmarkBySlug ... 17 | func (g *GormStorage) GetABookmarkBySlug(slug string) (models.Bookmark, error) { 18 | var bookmark models.Bookmark 19 | err := g.db.First(&bookmark, "slug = ?", slug).Error 20 | if err != nil { 21 | return bookmark, errors.Wrap(err, fmt.Sprintf("get bookmark(slug: '%s') occurs error", slug)) 22 | } 23 | 24 | return bookmark, nil 25 | } 26 | 27 | // GetABookmarkByID ... 28 | func (g *GormStorage) GetABookmarkByID(id string) (models.Bookmark, error) { 29 | var bookmark models.Bookmark 30 | err := g.db.First(&bookmark, "id = ?", id).Error 31 | if err != nil { 32 | return bookmark, errors.Wrap(err, fmt.Sprintf("get bookmark(id: '%s') occurs error", id)) 33 | } 34 | 35 | return bookmark, nil 36 | } 37 | 38 | // GetABookmarkOfAUser get a bookmark of a user 39 | func (g *GormStorage) GetABookmarkOfAUser(userID string, slug string, host string) (models.Bookmark, error) { 40 | var bookmark models.Bookmark 41 | err := g.db.Where("id in (?)", g.db.Table("users_bookmarks").Select("bookmark_id").Where("user_id = ?", userID).QueryExpr()).Where("slug = ? and host = ?", slug, host).First(&bookmark).Error 42 | 43 | if err != nil { 44 | return bookmark, errors.Wrap(err, fmt.Sprintf("get bookmark(slug: '%s', host: '%s') from user(id: '%s') occurs error", slug, host, userID)) 45 | } 46 | 47 | return bookmark, nil 48 | } 49 | 50 | // GetBookmarksOfAUser lists bookmarks of the user 51 | func (g *GormStorage) GetBookmarksOfAUser(id string, limit, offset int) ([]models.UserBookmark, int, error) { 52 | var bookmarks []models.UserBookmark 53 | 54 | // The reason I write the raw sql statement, not use gorm association(see the following commented code), 55 | // err = g.db.Model(&user).Limit(limit).Offset(offset).Order("created_at desc").Related(&bookmarks, bookmarksStr).Error 56 | // is because I need to sort/limit/offset the records according to `users_bookmarks`.`created_at`. 57 | err := g.db.Raw("SELECT `users_bookmarks`.created_at AS added_at, `bookmarks`.* FROM `bookmarks` INNER JOIN `users_bookmarks` ON `users_bookmarks`.`bookmark_id` = `bookmarks`.`id` WHERE `bookmarks`.deleted_at IS NULL AND ((`users_bookmarks`.`user_id` IN (?))) ORDER BY added_at desc LIMIT ? OFFSET ?", id, limit, offset).Scan(&bookmarks).Error 58 | 59 | if err != nil { 60 | return bookmarks, 0, errors.Wrap(err, fmt.Sprintf("get bookmarks of the user(id: %s) with conditions(limit: %d, offset: %d) occurs error", id, limit, offset)) 61 | } 62 | 63 | userID, _ := strconv.Atoi(id) 64 | total := g.db.Model(models.User{ID: uint(userID)}).Association(bookmarksStr).Count() 65 | 66 | return bookmarks, total, nil 67 | } 68 | 69 | // CreateABookmarkOfAUser this func will create a bookmark and build the relationship between the bookmark and the user 70 | func (g *GormStorage) CreateABookmarkOfAUser(userID string, bookmark models.Bookmark) (models.Bookmark, error) { 71 | var _bookmark = bookmark 72 | 73 | user, err := g.GetUserByID(userID) 74 | if err != nil { 75 | return _bookmark, errors.WithStack(err) 76 | } 77 | 78 | // get first matched record, or create a new one 79 | err = g.db.Where("slug = ? AND host = ?", bookmark.Slug, bookmark.Host).FirstOrCreate(&_bookmark).Error 80 | 81 | if err != nil { 82 | return _bookmark, errors.Wrap(err, fmt.Sprintf("create a bookmark(%#v) occurs error", bookmark)) 83 | } 84 | 85 | err = g.db.Model(&user).Association(bookmarksStr).Append(_bookmark).Error 86 | if err != nil { 87 | return _bookmark, errors.Wrap(err, fmt.Sprintf("append the bookmark(%#v) to the user(id: %s) occurs error", bookmark, userID)) 88 | } 89 | 90 | return _bookmark, nil 91 | } 92 | 93 | // DeleteABookmarkOfAUser this func will delete the relationship between the user and the bookmark 94 | func (g *GormStorage) DeleteABookmarkOfAUser(userID, bookmarkID string) error { 95 | user, err := g.GetUserByID(userID) 96 | if err != nil { 97 | return errors.WithStack(err) 98 | } 99 | 100 | bookmark, err := g.GetABookmarkByID(bookmarkID) 101 | if err != nil { 102 | return errors.WithStack(err) 103 | } 104 | 105 | // The reason why here find before delete is to make sure it will return error if record is not found 106 | err = g.db.Model(&user).Association(bookmarksStr).Find(&bookmark).Delete(bookmark).Error 107 | if err != nil { 108 | return errors.Wrap(err, fmt.Sprintf("delete bookmark(id: %s) from user(id: %s) occurs error", bookmarkID, userID)) 109 | } 110 | 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /storage/errors.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "github.com/globalsign/mgo" 5 | "github.com/go-sql-driver/mysql" 6 | "github.com/jinzhu/gorm" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | // ErrRecordNotFound record not found error, happens when haven't find any matched data when looking up with a struct 11 | var ErrRecordNotFound = gorm.ErrRecordNotFound 12 | 13 | // ErrMgoNotFound record not found error when accessing MongoDB 14 | var ErrMgoNotFound = mgo.ErrNotFound 15 | 16 | func IsNotFound(err error) bool { 17 | cause := errors.Cause(err) 18 | 19 | switch cause { 20 | case ErrRecordNotFound: 21 | return true 22 | case ErrMgoNotFound: 23 | return true 24 | default: 25 | // omit intentionally 26 | } 27 | return false 28 | } 29 | 30 | func IsConflict(err error) bool { 31 | // ErrDuplicateEntry record is already existed in MySQL database 32 | var ErrDuplicateEntry uint16 = 1062 33 | // ErrMgoDuplicateEntry record is already existed in MongoDB 34 | var ErrMgoDuplicateEntry = 11000 35 | 36 | cause := errors.Cause(err) 37 | 38 | switch e := cause.(type) { 39 | case *mysql.MySQLError: 40 | return e.Number == ErrDuplicateEntry 41 | case *mgo.LastError: 42 | return e.Code == ErrMgoDuplicateEntry 43 | default: 44 | // omit intentionally 45 | } 46 | return false 47 | } 48 | -------------------------------------------------------------------------------- /storage/mailgroup.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | // CreateMaillistOfUser this func will accept maillist string array as input, delete all entry of the user in users_mailgroup, and insert each input entry into users_mailgroup table 10 | func (gs *GormStorage) CreateMaillistOfUser(uid string, maillist []string) error { 11 | // delete all entry of the user in users_mailgroup 12 | err := gs.db.Exec("DELETE FROM users_mailgroups WHERE user_id = ?", uid).Error 13 | if err != nil { 14 | return errors.Wrap(err, "delete existing user mailgroup error") 15 | } 16 | 17 | // insert new entry into users_mailgroup 18 | for _, list := range maillist { 19 | err = gs.db.Exec("INSERT INTO users_mailgroups (user_id, mailgroup_id) VALUES (?, ?) ON DUPLICATE KEY UPDATE mailgroup_id = ?", uid, list, list).Error 20 | if err != nil { 21 | return errors.Wrap(err, "insert user mailgroup error") 22 | } 23 | } 24 | 25 | // Get the user's email from the user table 26 | var userEmail []string 27 | err = gs.db.Raw("SELECT email FROM users WHERE id = ?", uid).Pluck("email", &userEmail).Error 28 | if err != nil { 29 | return errors.Wrap(err, "get user email error") 30 | } 31 | 32 | // Convert maillist array to comma-separated string 33 | maillistStr := strings.Join(maillist, ",") 34 | 35 | // Insert new entry into jobs_mailchimp table 36 | err = gs.db.Exec("INSERT INTO jobs_mailchimp (receiver, interests, state) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE interests = ?", userEmail[0], maillistStr, "new", maillistStr).Error 37 | if err != nil { 38 | return errors.Wrap(err, "insert user mailgroup error") 39 | } 40 | 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /storage/membership.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jinzhu/gorm" 7 | "github.com/pkg/errors" 8 | "gopkg.in/guregu/null.v3" 9 | 10 | "github.com/twreporter/go-api/models" 11 | ) 12 | 13 | // MembershipStorage defines the methods we need to implement, 14 | // in order to fulfill the functionalities a membership system needs. 15 | // Such as, let user signup, login w/o oauth, CRUD bookmarks. 16 | type MembershipStorage interface { 17 | /** Close DB Connection **/ 18 | Close() error 19 | 20 | /** Default CRUD **/ 21 | Create(interface{}) error 22 | Get(uint, interface{}) error 23 | GetByConditions(map[string]interface{}, interface{}) error 24 | UpdateByConditions(map[string]interface{}, interface{}) (error, int64) 25 | Delete(uint, interface{}) error 26 | 27 | /** User methods **/ 28 | GetUserByID(string) (models.User, error) 29 | GetUserByEmail(string) (models.User, error) 30 | GetOAuthData(null.String, string) (models.OAuthAccount, error) 31 | GetUserDataByOAuth(models.OAuthAccount) (models.User, error) 32 | GetReporterAccountData(string) (models.ReporterAccount, error) 33 | GetUserDataByReporterAccount(models.ReporterAccount) (models.User, error) 34 | InsertOAuthAccount(models.OAuthAccount) error 35 | InsertReporterAccount(models.ReporterAccount) error 36 | InsertUserByOAuth(models.OAuthAccount) (models.User, error) 37 | InsertUserByReporterAccount(models.ReporterAccount) (models.User, error) 38 | UpdateOAuthData(models.OAuthAccount) (models.OAuthAccount, error) 39 | UpdateReporterAccount(models.ReporterAccount) error 40 | CreateMaillistOfUser(string, []string) error 41 | UpdateReadPreferenceOfUser(string, []string) error 42 | UpdateUser(models.User) error 43 | AssignRoleToUser(models.User, string) error 44 | GetRoles(models.User) ([]models.Role, error) 45 | HasRole(user models.User, roleKey string) (bool, error) 46 | IsTrailblazer(email string) (bool, error) 47 | 48 | /** Bookmark methods **/ 49 | GetABookmarkBySlug(string) (models.Bookmark, error) 50 | GetABookmarkByID(string) (models.Bookmark, error) 51 | GetABookmarkOfAUser(string, string, string) (models.Bookmark, error) 52 | GetBookmarksOfAUser(string, int, int) ([]models.UserBookmark, int, error) 53 | CreateABookmarkOfAUser(string, models.Bookmark) (models.Bookmark, error) 54 | DeleteABookmarkOfAUser(string, string) error 55 | 56 | /** Web Push Subscription methods **/ 57 | CreateAWebPushSubscription(models.WebPushSubscription) error 58 | GetAWebPushSubscription(uint32, string) (models.WebPushSubscription, error) 59 | 60 | /** Donation methods **/ 61 | CreateAPeriodicDonation(*models.PeriodicDonation, *models.PayByCardTokenDonation) error 62 | DeleteAPeriodicDonation(uint, models.PayByCardTokenDonation) error 63 | UpdatePeriodicAndCardTokenDonationInTRX(uint, models.PeriodicDonation, models.PayByCardTokenDonation) error 64 | GetDonationsOfAUser(string, int, int) ([]models.GeneralDonation, int, error) 65 | GetPaymentsOfAPeriodicDonation(uint, int, int) ([]models.Payment, int, error) 66 | GenerateReceiptSerialNumber(uint, null.Time) (string, error) 67 | } 68 | 69 | // NewGormStorage initializes the storage connected to MySQL database by gorm library 70 | func NewGormStorage(db *gorm.DB) *GormStorage { 71 | return &GormStorage{db} 72 | } 73 | 74 | // GormStorage implements MembershipStorage interface 75 | type GormStorage struct { 76 | db *gorm.DB 77 | } 78 | 79 | // Close quits the DB connection gracefully 80 | func (gs *GormStorage) Close() error { 81 | err := gs.db.Close() 82 | if err != nil { 83 | return err 84 | } 85 | return nil 86 | } 87 | 88 | // Get method of MembershipStorage interface 89 | func (gs *GormStorage) Get(id uint, m interface{}) error { 90 | err := gs.db.Where("id = ?", id).Find(m).Error 91 | 92 | if err != nil { 93 | return errors.Wrap(err, fmt.Sprintf("can not get the record(id: %d)", id)) 94 | } 95 | 96 | return nil 97 | } 98 | 99 | // GetByConditions method of MembershipStorage interface 100 | func (gs *GormStorage) GetByConditions(cond map[string]interface{}, m interface{}) error { 101 | err := gs.db.Where(cond).Find(m).Error 102 | 103 | if err != nil { 104 | return errors.Wrap(err, fmt.Sprintf("can not get the record(where: %v)", cond)) 105 | } 106 | 107 | return nil 108 | } 109 | 110 | // UpdateByConditions method of MembershipStorage interface 111 | func (gs *GormStorage) UpdateByConditions(cond map[string]interface{}, m interface{}) (err error, rowsAffected int64) { 112 | // caution: 113 | // it will perform batch updates if cond is zero value and primary key of m is zero value 114 | updates := gs.db.Model(m).Where(cond).Updates(m) 115 | err = updates.Error 116 | 117 | if err != nil { 118 | return errors.Wrap(err, fmt.Sprintf("can not update the record(where: %v)", cond)), 0 119 | } 120 | 121 | rowsAffected = updates.RowsAffected 122 | 123 | return nil, rowsAffected 124 | } 125 | 126 | // Delete method of MembershipStorage interface 127 | func (gs *GormStorage) Delete(id uint, m interface{}) error { 128 | return nil 129 | } 130 | 131 | // Create method of MembershipStorage interface 132 | func (gs *GormStorage) Create(m interface{}) error { 133 | err := gs.db.Create(m).Error 134 | 135 | if nil != err { 136 | return errors.Wrap(err, fmt.Sprintf("can not create the record(%#v)", m)) 137 | } 138 | 139 | return nil 140 | } 141 | -------------------------------------------------------------------------------- /storage/service.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | 6 | "github.com/twreporter/go-api/models" 7 | ) 8 | 9 | // GetService ... 10 | func (g *GormStorage) GetService(name string) (models.Service, error) { 11 | var s models.Service 12 | 13 | err := g.db.First(&s, "name = ?", name).Error 14 | if err != nil { 15 | return s, errors.Wrap(err, "storage.service.get_svc") 16 | } 17 | 18 | return s, err 19 | } 20 | 21 | // CreateService this func will create a service record 22 | func (g *GormStorage) CreateService(json models.ServiceJSON) (models.Service, error) { 23 | service := models.Service{ 24 | Name: json.Name, 25 | } 26 | 27 | err := g.db.Create(&service).Error 28 | if err != nil { 29 | return service, errors.Wrap(err, "storage.service.create_svc") 30 | } 31 | 32 | return service, err 33 | } 34 | 35 | // UpdateService this func will update the record in the stroage 36 | func (g *GormStorage) UpdateService(name string, json models.ServiceJSON) (models.Service, error) { 37 | var s models.Service 38 | 39 | err := g.db.Where("name = ?", name).FirstOrCreate(&s).Error 40 | if err != nil { 41 | return s, errors.Wrap(err, "storage.service.update_svc") 42 | } 43 | 44 | s.Name = json.Name 45 | 46 | err = g.db.Save(&s).Error 47 | if err != nil { 48 | return s, errors.Wrap(err, "storage.service.update_svc") 49 | } 50 | 51 | return s, err 52 | } 53 | 54 | // DeleteService this func will delete the record in the stroage 55 | func (g *GormStorage) DeleteService(name string) error { 56 | if g.db.Where("name = ?", name).Delete(&models.Service{}).RowsAffected == 0 { 57 | return errors.Wrap(ErrRecordNotFound, "storage.service.delete_svc") 58 | } 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /storage/subscription.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pkg/errors" 7 | 8 | "github.com/twreporter/go-api/models" 9 | ) 10 | 11 | // CreateAWebPushSubscription - create a record in the persistent database, 12 | // return error if fails. 13 | func (g *GormStorage) CreateAWebPushSubscription(wpSub models.WebPushSubscription) error { 14 | err := g.db.Create(&wpSub).Error 15 | if err != nil { 16 | return errors.Wrap(err, fmt.Sprintf("creating a web push subscription(%#v) occurs error", wpSub)) 17 | } 18 | 19 | return nil 20 | } 21 | 22 | // GetAWebPushSubscription - read a record from persistent database according to its crc32(endpoint) and endpoint value 23 | func (g *GormStorage) GetAWebPushSubscription(crc32Endpoint uint32, endpoint string) (models.WebPushSubscription, error) { 24 | var wpSub models.WebPushSubscription 25 | var err error 26 | 27 | if err = g.db.Find(&wpSub, "crc32_endpoint = ? AND endpoint = ?", crc32Endpoint, endpoint).Error; err != nil { 28 | return wpSub, errors.Wrap(err, fmt.Sprintf("getting a web push subscription(endpoint: %s) occurs error", endpoint)) 29 | } 30 | 31 | return wpSub, nil 32 | } 33 | -------------------------------------------------------------------------------- /template/authenticate.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 37 | 38 | 39 | 40 | 41 | 42 | 98 | 99 | 100 |
43 | 44 | 45 | 46 | 47 | 93 | 94 | 95 |
48 |
49 | 50 |

51 | 親愛的贊助者 您好:
52 | 請在 15 分鐘內點擊下方按鈕完成信箱驗證。
53 | 點擊後,將會引導您繼續進行付款資訊填寫。
54 |

55 |
56 |
57 | 58 | 完成驗證 59 | 60 |
61 |
62 | 63 |

64 | 若無法透過上方按鈕完成驗證,請複製以下網址到您的瀏覽器:
65 | {{.Href}}
66 | 《報導者》 敬上
67 |

68 |
69 |
70 |
71 | 72 |
73 |

74 | *本信件由系統自動發出,請勿直接回覆!* 75 |

76 |

77 | 若您有任何疑問或需要服務之處,歡迎透過下列方式聯繫我們,謝謝: 78 |

79 |
80 |
81 | 客服信箱:events@twreporter.org 82 |
83 |
84 | 聯絡電話:02-25363030(周一~五 09:00~18:00) 85 |
86 |
87 |
88 | 89 |
90 |
91 |
92 |
96 | 97 |
101 | 102 | 103 | -------------------------------------------------------------------------------- /template/signin.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 37 | 38 | 39 | 40 | 41 | 42 | 100 | 101 | 102 |
43 | 44 | 45 | 46 | 47 | 95 | 96 | 97 |
48 |

49 | 歡迎登入《報導者》,開啟新聞小革命 50 |

51 |
52 | 53 |

54 | 55 | 親愛的讀者 您好:
56 | 歡迎登入《報導者》網站,請在15分鐘內點擊下方按鈕完成登入。
57 |

58 |
59 |
60 | 61 | 登入《報導者》帳號 62 | 63 |
64 |
65 | 66 |

67 | 若無法透過上方按鈕完成登入,請複製以下網址到您的瀏覽器:
68 | {{.Href}}
69 | 《報導者》 敬上
70 |

71 |
72 |
73 |
74 | 75 |
76 | 77 |

78 | *本信件由系統自動發出,請勿直接回覆!* 79 |

80 |

81 | 若您有任何疑問或需要服務之處,歡迎透過下列方式聯繫我們,謝謝: 82 |

83 |
84 |
客服信箱:events@twreporter.org 85 |
86 |
聯絡電話:02-25363030(周一~五 09:00~18:00) 87 |
88 |
89 |
90 | 91 |
92 |
93 |
94 |
98 | 99 |
103 | 104 | 105 | -------------------------------------------------------------------------------- /tests/controller_account_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/twreporter/go-api/models" 13 | "github.com/twreporter/go-api/storage" 14 | ) 15 | 16 | func TestSignIn(t *testing.T) { 17 | // TODO: Test for SignInV2 18 | } 19 | 20 | func TestActivate(t *testing.T) { 21 | var resp *httptest.ResponseRecorder 22 | 23 | // START - test activate endpoint // 24 | user := getReporterAccount(Globs.Defaults.Account) 25 | 26 | // Renew token for v2 endpoint validation 27 | activateToken := "Activate_Token_2" 28 | expTime := time.Now().Add(time.Duration(15) * time.Minute) 29 | 30 | as := storage.NewGormStorage(Globs.GormDB) 31 | if err := as.UpdateReporterAccount(models.ReporterAccount{ 32 | ID: user.ID, 33 | ActivateToken: activateToken, 34 | ActExpTime: expTime, 35 | }); nil != err { 36 | fmt.Println(err.Error()) 37 | } 38 | 39 | // START - test activate endpoint v2// 40 | 41 | // test activate 42 | resp = serveHTTP("GET", fmt.Sprintf("/v2/auth/activate?email=%v&token=%v", Globs.Defaults.Account, activateToken), "", "", "") 43 | fmt.Print(resp.Body) 44 | 45 | // validate status code 46 | assert.Equal(t, http.StatusTemporaryRedirect, resp.Code) 47 | cookies := resp.Result().Cookies() 48 | 49 | cookieMap := make(map[string]http.Cookie) 50 | for _, cookie := range cookies { 51 | cookieMap[cookie.Name] = *cookie 52 | } 53 | // validate Set-Cookie header 54 | assert.Contains(t, cookieMap, "id_token") 55 | 56 | // test activate fails 57 | resp = serveHTTP("GET", fmt.Sprintf("/v2/auth/activate?email=%v&token=%v", Globs.Defaults.Account, ""), "", "", "") 58 | assert.Equal(t, http.StatusTemporaryRedirect, resp.Code) 59 | // END - test activate endpoint v2// 60 | 61 | } 62 | -------------------------------------------------------------------------------- /tests/controller_subscriptions_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/twreporter/go-api/models" 12 | ) 13 | 14 | type wpSubResponse struct { 15 | Status string `json:"status"` 16 | Data models.WebPushSubscription `json:"data"` 17 | } 18 | 19 | const defaultWebPushEndpoint = "https://fcm.googleapis.com/fcm/send/f4Stnx6WC5s:APA91bFGo-JD8bDwezv1fx3RRyBVq6XxOkYIo8_7vCAJ3HFHLppKAV6GNmOIZLH0YeC2lM_Ifs9GkLK8Vi_8ASEYLBC1aU9nJy2rZSUfH7DE0AqIIbLrs93SdEdkwr5uL6skLPMjJsRQ" 20 | 21 | var isSetUp = false 22 | 23 | // setDefaultWebPushSubscription - set up default records in web_push_subscriptions table 24 | func setDefaultWebPushSubscription() { 25 | var path = "/v1/web-push/subscriptions" 26 | var webPush = webPushSubscriptionPostBody{ 27 | Endpoint: defaultWebPushEndpoint, 28 | Keys: "{\"p256dh\":\"BDmY8OGe-LfW0ENPIADvmdZMo3GfX2J2yqURpsDOn5tT8lQV-VVHyhRUgzjnmx_RRoobwdLULdBr26oULtLML3w\",\"auth\":\"P_AJ9QSqcgM-KJi_GRN3fQ\"}", 29 | ExpirationTime: "1526959900", 30 | UserID: "1", 31 | } 32 | 33 | webPushJSON, _ := json.Marshal(webPush) 34 | serveHTTP("POST", path, string(webPushJSON), "application/json", "") 35 | } 36 | 37 | func setUpBeforeSubcriptionsTest() { 38 | if !isSetUp { 39 | setDefaultWebPushSubscription() 40 | } 41 | 42 | isSetUp = true 43 | } 44 | 45 | func TestIsWebPushSubscribed(t *testing.T) { 46 | var resp *httptest.ResponseRecorder 47 | var path string 48 | 49 | // set up before testing 50 | setUpBeforeSubcriptionsTest() 51 | 52 | path = fmt.Sprintf("/v1/web-push/subscriptions?endpoint=%v", defaultWebPushEndpoint) 53 | 54 | /** START - Read a web push subscription successfully **/ 55 | 56 | resp = serveHTTP("GET", path, "", "", "") 57 | assert.Equal(t, resp.Code, 200) 58 | 59 | body, _ := ioutil.ReadAll(resp.Result().Body) 60 | 61 | res := wpSubResponse{} 62 | json.Unmarshal(body, &res) 63 | 64 | assert.Equal(t, defaultWebPushEndpoint, res.Data.Endpoint) 65 | 66 | /** END - Read a web push subscription successfully **/ 67 | 68 | /** START - Fail to read a web push subscription **/ 69 | 70 | // Situation 1: Endpoint query param is not provided 71 | path = "/v1/web-push/subscriptions?endpoint=" 72 | resp = serveHTTP("GET", path, "", "", "") 73 | assert.Equal(t, resp.Code, 404) 74 | 75 | // Situation 2: Endpoint is provided, but database does not have it 76 | path = "/v1/web-push/subscriptions?endpoint=http://web-push.subscriptions/endpoint-is-not-in-the-db" 77 | resp = serveHTTP("GET", path, "", "", "") 78 | assert.Equal(t, resp.Code, 404) 79 | 80 | /** END - Fail to read a web push subscription **/ 81 | } 82 | 83 | func TestSubscribeWebPush(t *testing.T) { 84 | var resp *httptest.ResponseRecorder 85 | var path = "/v1/web-push/subscriptions" 86 | var webPush webPushSubscriptionPostBody 87 | var webPushByteArray []byte 88 | 89 | // set up before testing 90 | setUpBeforeSubcriptionsTest() 91 | 92 | /** START - Add a web push subscription successfully **/ 93 | webPush = webPushSubscriptionPostBody{ 94 | Endpoint: "http://web-push.subscriptions/new.endpoint.to.subscribe", 95 | Keys: "{\"p256dh\":\"test-p256dh\",\"auth\":\"test-auth\"}", 96 | ExpirationTime: "1526959900", 97 | UserID: "1", 98 | } 99 | 100 | webPushByteArray, _ = json.Marshal(webPush) 101 | resp = serveHTTP("POST", path, string(webPushByteArray), "application/json", "") 102 | assert.Equal(t, resp.Code, 201) 103 | /** END - Add a web push subscription successfully **/ 104 | 105 | /** START - Fail to add a web push subscription **/ 106 | 107 | // Situation 1: POST Body is not fully provided, lack of `keys` 108 | webPush = webPushSubscriptionPostBody{ 109 | Endpoint: "http://web-push.subscriptions/another.endpoint.to.subscribe", 110 | ExpirationTime: "1526959900", 111 | UserID: "1", 112 | } 113 | 114 | webPushByteArray, _ = json.Marshal(webPush) 115 | resp = serveHTTP("POST", path, string(webPushByteArray), "application/json", "") 116 | assert.Equal(t, resp.Code, 400) 117 | 118 | // Situation 2: Endpoint is already subscribed 119 | webPush = webPushSubscriptionPostBody{ 120 | Endpoint: defaultWebPushEndpoint, 121 | Keys: "{\"p256dh\":\"test-p256dh\",\"auth\":\"test-auth\"}", 122 | ExpirationTime: "1526959900", 123 | UserID: "1", 124 | } 125 | 126 | webPushByteArray, _ = json.Marshal(webPush) 127 | resp = serveHTTP("POST", path, string(webPushByteArray), "application/json", "") 128 | assert.Equal(t, resp.Code, 409) 129 | 130 | /** END - Fail to add a web push subscription **/ 131 | } 132 | -------------------------------------------------------------------------------- /tests/controller_user_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | "io/ioutil" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/twreporter/go-api/globals" 12 | "github.com/twreporter/go-api/models" 13 | "github.com/twreporter/go-api/storage" 14 | ) 15 | 16 | type ( 17 | userData struct { 18 | ID uint `json:"id"` 19 | AgreeDataCollection bool `json:"agree_data_collection"` 20 | ReadPostsCount int `json:"read_posts_count"` 21 | ReadPostsSec int `json:"read_posts_sec"` 22 | } 23 | 24 | responseBodyUser struct { 25 | Status string `json:"status"` 26 | Data userData `json:"data"` 27 | } 28 | ) 29 | 30 | func TestGetUser_Success(t *testing.T) { 31 | var resBody responseBodyUser 32 | // Mocking user 33 | var user models.User = getUser(Globs.Defaults.Account) 34 | jwt := generateIDToken(user) 35 | as := storage.NewGormStorage(Globs.GormDB) 36 | if err := as.UpdateUser(models.User{ 37 | ID: user.ID, 38 | AgreeDataCollection: true, 39 | ReadPostsCount: 19, 40 | ReadPostsSec: 3360, 41 | }); nil != err { 42 | fmt.Println(err.Error()) 43 | } 44 | updatedUser := getUser(Globs.Defaults.Account) 45 | 46 | // Send request to test GetUser function 47 | response := serveHTTP(http.MethodGet, fmt.Sprintf("/v2/users/%d", user.ID), "", "", fmt.Sprintf("Bearer %v", jwt)) 48 | fmt.Print(response.Body) 49 | resBodyInBytes, _ := ioutil.ReadAll(response.Result().Body) 50 | json.Unmarshal(resBodyInBytes, &resBody) 51 | assert.Equal(t, http.StatusOK, response.Code) 52 | assert.Equal(t, updatedUser.AgreeDataCollection, resBody.Data.AgreeDataCollection) 53 | assert.Equal(t, updatedUser.ReadPostsCount, resBody.Data.ReadPostsCount) 54 | assert.Equal(t, updatedUser.ReadPostsSec, resBody.Data.ReadPostsSec) 55 | } 56 | 57 | func TestSetUser_Success(t *testing.T) { 58 | // Mocking user 59 | var user models.User = getUser(Globs.Defaults.Account) 60 | jwt := generateIDToken(user) 61 | 62 | var InterestIDsKeys []string 63 | 64 | for k := range globals.Conf.Mailchimp.InterestIDs { 65 | InterestIDsKeys = append(InterestIDsKeys, k) 66 | } 67 | 68 | // Mocking preferences 69 | preferences := models.UserPreference{ 70 | ReadPreference: []string{"international", "cross_straits"}, 71 | Maillist: InterestIDsKeys, 72 | } 73 | payload, _ := json.Marshal(preferences) 74 | 75 | // Send request to test SetUser function 76 | response := serveHTTP(http.MethodPost, fmt.Sprintf("/v2/users/%d", user.ID), string(payload), "application/json", fmt.Sprintf("Bearer %v", jwt)) 77 | 78 | assert.Equal(t, http.StatusCreated, response.Code) 79 | } 80 | 81 | func TestSetUser_InvalidMaillist(t *testing.T) { 82 | // Mocking user 83 | var user models.User = getUser(Globs.Defaults.Account) 84 | jwt := generateIDToken(user) 85 | 86 | // Mocking preferences 87 | preferences := models.UserPreference{ 88 | ReadPreference: []string{"international", "cross_straits"}, 89 | Maillist: []string{"maillist1", "maillist2", "maillist5"}, 90 | } 91 | payload, _ := json.Marshal(preferences) 92 | 93 | // Send request to test SetUser function 94 | response := serveHTTP(http.MethodPost, fmt.Sprintf("/v2/users/%d", user.ID), string(payload), "application/json", fmt.Sprintf("Bearer %v", jwt)) 95 | 96 | assert.Equal(t, http.StatusBadRequest, response.Code) 97 | } 98 | -------------------------------------------------------------------------------- /tests/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "net" 9 | "net/http" 10 | "net/http/httptest" 11 | "os" 12 | "testing" 13 | "time" 14 | 15 | "github.com/algolia/algoliasearch-client-go/v3/algolia/search" 16 | 17 | "github.com/gin-gonic/gin" 18 | "github.com/globalsign/mgo" 19 | "github.com/jinzhu/gorm" 20 | "github.com/stretchr/testify/assert" 21 | "github.com/twreporter/go-api/configs" 22 | "github.com/twreporter/go-api/controllers" 23 | "github.com/twreporter/go-api/globals" 24 | "github.com/twreporter/go-api/models" 25 | "github.com/twreporter/go-api/routers" 26 | "github.com/twreporter/go-api/storage" 27 | "github.com/twreporter/go-api/utils" 28 | "github.com/twreporter/go-api/internal/news" 29 | mongodriver "go.mongodb.org/mongo-driver/mongo" 30 | ) 31 | 32 | var Globs globalVariables 33 | 34 | var testMongoClient *mongodriver.Client 35 | 36 | func init() { 37 | var defaults = defaultVariables{ 38 | Account: "developer@twreporter.org", 39 | Service: "default_service", 40 | Token: "default_token", 41 | 42 | ErrorEmailAddress: "error@twreporter.org", 43 | } 44 | 45 | Globs = globalVariables{ 46 | Defaults: defaults, 47 | } 48 | } 49 | 50 | func requestWithBody(method, path, body string) (req *http.Request) { 51 | req, _ = http.NewRequest(method, path, bytes.NewBufferString(body)) 52 | return 53 | } 54 | 55 | func generateIDToken(user models.User) (jwt string) { 56 | jwt, _ = utils.RetrieveV2IDToken(user.ID, user.Email.ValueOrZero(), user.FirstName.ValueOrZero(), user.LastName.ValueOrZero(), 3600) 57 | return 58 | } 59 | 60 | func getReporterAccount(email string) (ra models.ReporterAccount) { 61 | as := storage.NewGormStorage(Globs.GormDB) 62 | ra, _ = as.GetReporterAccountData(email) 63 | return ra 64 | } 65 | 66 | func createUser(email string) models.User { 67 | as := storage.NewGormStorage(Globs.GormDB) 68 | 69 | ra := models.ReporterAccount{ 70 | Email: email, 71 | ActivateToken: Globs.Defaults.Token, 72 | ActExpTime: time.Now().Add(time.Duration(15) * time.Minute), 73 | } 74 | 75 | user, _ := as.InsertUserByReporterAccount(ra) 76 | 77 | return user 78 | } 79 | 80 | func deleteUser(user models.User) { 81 | db := Globs.GormDB 82 | 83 | // Remove corresponding reporter account 84 | db.Unscoped().Delete(user.ReporterAccount) 85 | db.Unscoped().Delete(user) 86 | } 87 | 88 | func getUser(email string) (user models.User) { 89 | as := storage.NewGormStorage(Globs.GormDB) 90 | user, _ = as.GetUserByEmail(email) 91 | return 92 | } 93 | 94 | // future work: migrate to news.Post for general use 95 | func createPost(post news.MetaOfFootprint) (error) { 96 | db := testMongoClient 97 | 98 | _, err := db.Database(globals.Conf.DB.Mongo.DBname).Collection(news.ColPosts).InsertOne(context.Background(), post) 99 | if err != nil { 100 | return err 101 | } 102 | return nil 103 | } 104 | 105 | func serveHTTP(method, path, body, contentType, authorization string) (resp *httptest.ResponseRecorder) { 106 | var req *http.Request 107 | 108 | req = requestWithBody(method, path, body) 109 | 110 | if contentType != "" { 111 | req.Header.Add("Content-Type", contentType) 112 | } 113 | 114 | if authorization != "" { 115 | req.Header.Add("Authorization", authorization) 116 | } 117 | 118 | resp = httptest.NewRecorder() 119 | Globs.GinEngine.ServeHTTP(resp, req) 120 | 121 | return 122 | } 123 | 124 | func serveHTTPWithCookies(method, path, body, contentType, authorization string, cookies ...http.Cookie) (resp *httptest.ResponseRecorder) { 125 | var req *http.Request 126 | 127 | req = requestWithBody(method, path, body) 128 | 129 | if contentType != "" { 130 | req.Header.Add("Content-Type", contentType) 131 | } 132 | 133 | if authorization != "" { 134 | req.Header.Add("Authorization", authorization) 135 | } 136 | 137 | for _, cookie := range cookies { 138 | req.AddCookie(&cookie) 139 | } 140 | 141 | resp = httptest.NewRecorder() 142 | Globs.GinEngine.ServeHTTP(resp, req) 143 | 144 | return 145 | } 146 | 147 | type mockMailStrategy struct{} 148 | 149 | func (s mockMailStrategy) Send(to, subject, body string) error { 150 | if to == Globs.Defaults.ErrorEmailAddress { 151 | return errors.New("mail service works abnormally") 152 | } 153 | return nil 154 | } 155 | 156 | type mockIndexSearcher struct{} 157 | 158 | func (mockIndexSearcher) Search(query string, opts ...interface{}) (res search.QueryRes, err error) { 159 | return search.QueryRes{}, errors.New("no index search support during test") 160 | } 161 | 162 | func setupGinServer(gormDB *gorm.DB, mgoDB *mgo.Session, client *mongodriver.Client) *gin.Engine { 163 | mailSvc := mockMailStrategy{} 164 | searcher := mockIndexSearcher{} 165 | cf := controllers.NewControllerFactory(gormDB, mgoDB, mailSvc, client, searcher) 166 | engine := routers.SetupRouter(cf) 167 | return engine 168 | } 169 | 170 | func TestPing(t *testing.T) { 171 | req, _ := http.NewRequest("GET", "/v1/ping", nil) 172 | resp := httptest.NewRecorder() 173 | Globs.GinEngine.ServeHTTP(resp, req) 174 | assert.Equal(t, resp.Code, 200) 175 | } 176 | 177 | func TestMain(m *testing.M) { 178 | var err error 179 | var l net.Listener 180 | 181 | fmt.Println("load default config") 182 | if globals.Conf, err = configs.LoadDefaultConf(); err != nil { 183 | panic(fmt.Sprintf("Can not load default config, but got err=%+v", err)) 184 | } 185 | 186 | // set up DB environment 187 | gormDB, mgoDB, client := setUpDBEnvironment() 188 | 189 | Globs.GormDB = gormDB 190 | Globs.MgoDB = mgoDB 191 | testMongoClient = client 192 | 193 | // set up gin server 194 | engine := setupGinServer(gormDB, mgoDB, client) 195 | 196 | Globs.GinEngine = engine 197 | 198 | defer Globs.GormDB.Close() 199 | defer Globs.MgoDB.Close() 200 | defer func() { testMongoClient.Disconnect(context.Background()) }() 201 | 202 | // start server for testing 203 | // the reason why we start the server 204 | // is because we send HTTP request internally between controllers 205 | ts := httptest.NewUnstartedServer(engine) 206 | if l, err = net.Listen("tcp", "127.0.0.1:8080"); err != nil { 207 | panic(err) 208 | } 209 | ts.Listener = l 210 | ts.Start() 211 | defer ts.Close() 212 | 213 | retCode := m.Run() 214 | os.Exit(retCode) 215 | } 216 | -------------------------------------------------------------------------------- /tests/pre_test_environment_setup.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "go.mongodb.org/mongo-driver/mongo/options" 10 | 11 | "github.com/globalsign/mgo" 12 | "github.com/globalsign/mgo/bson" 13 | "github.com/jinzhu/gorm" 14 | mongodriver "go.mongodb.org/mongo-driver/mongo" 15 | 16 | "github.com/twreporter/go-api/globals" 17 | "github.com/twreporter/go-api/internal/mongo" 18 | "github.com/twreporter/go-api/models" 19 | "github.com/twreporter/go-api/storage" 20 | "github.com/twreporter/go-api/utils" 21 | ) 22 | 23 | const ( 24 | mgoDBName = "mgo" 25 | 26 | // collections name 27 | mgoPostCol = "posts" 28 | mgoTopicCol = "topics" 29 | mgoImgCol = "images" 30 | mgoVideoCol = "videos" 31 | mgoTagCol = "tags" 32 | mgoCategoriesCol = "postcategories" 33 | mgoThemeCol = "themes" 34 | 35 | testMongoDB = "testdb" 36 | ) 37 | 38 | func runMySQLMigration(gormDB *gorm.DB) { 39 | // Make sure there is no existing schemas 40 | dropStmt := []byte(` 41 | SET FOREIGN_KEY_CHECKS = 0; 42 | SET GROUP_CONCAT_MAX_LEN=32768; 43 | SET @tables = NULL; 44 | SELECT GROUP_CONCAT(table_schema, '.', table_name) INTO @tables 45 | FROM information_schema.tables 46 | WHERE table_schema = (SELECT DATABASE()); 47 | SELECT IFNULL(@tables,"dummy") INTO @tables; 48 | 49 | SET @tables = CONCAT("DROP TABLE IF EXISTS ", @tables); 50 | PREPARE stmt FROM @tables; 51 | EXECUTE stmt; 52 | DEALLOCATE PREPARE stmt; 53 | SET FOREIGN_KEY_CHECKS = 1; 54 | `) 55 | gormDB.Exec(string(dropStmt)) 56 | 57 | m, err := utils.GetMigrateInstance(gormDB.DB()) 58 | 59 | if nil != err { 60 | panic("unable to get migrate instance: " + err.Error()) 61 | } 62 | 63 | // Upgrade to the latest migrations 64 | if err = m.Up(); err != nil { 65 | panic("unable to migrate mysql: " + err.Error()) 66 | } 67 | 68 | } 69 | 70 | func runMgoMigration(mgoDB *mgo.Session) { 71 | err := mgoDB.DB(mgoDBName).DropDatabase() 72 | if err != nil { 73 | panic(fmt.Sprint("Can not drop mongo gorm database")) 74 | } 75 | 76 | mgoDB.DB(mgoDBName).Run(bson.D{{Name: "create", Value: mgoPostCol}}, nil) 77 | mgoDB.DB(mgoDBName).Run(bson.D{{Name: "create", Value: mgoTopicCol}}, nil) 78 | mgoDB.DB(mgoDBName).Run(bson.D{{Name: "create", Value: mgoImgCol}}, nil) 79 | mgoDB.DB(mgoDBName).Run(bson.D{{Name: "create", Value: mgoVideoCol}}, nil) 80 | mgoDB.DB(mgoDBName).Run(bson.D{{Name: "create", Value: mgoTagCol}}, nil) 81 | mgoDB.DB(mgoDBName).Run(bson.D{{Name: "create", Value: mgoCategoriesCol}}, nil) 82 | } 83 | 84 | func setGormDefaultRecords(gormDB *gorm.DB) { 85 | // Set an active reporter account 86 | ms := storage.NewGormStorage(gormDB) 87 | 88 | ra := models.ReporterAccount{ 89 | Email: Globs.Defaults.Account, 90 | ActivateToken: Globs.Defaults.Token, 91 | ActExpTime: time.Now().Add(time.Duration(15) * time.Minute), 92 | } 93 | _, _ = ms.InsertUserByReporterAccount(ra) 94 | 95 | ms.CreateService(models.ServiceJSON{Name: Globs.Defaults.Service}) 96 | } 97 | 98 | func setMgoDefaultRecords(mgoDB *mgo.Session) { 99 | 100 | } 101 | 102 | func openGormConnection() (db *gorm.DB, err error) { 103 | // CREATE USER 'gorm'@'localhost' IDENTIFIED BY 'gorm'; 104 | // CREATE DATABASE gorm; 105 | // GRANT ALL ON gorm.* TO 'gorm'@'localhost'; 106 | dbhost := os.Getenv("GORM_DBADDRESS") 107 | if dbhost != "" { 108 | dbhost = fmt.Sprintf("tcp(%v)", dbhost) 109 | } else { 110 | dbhost = "tcp(127.0.0.1:3306)" 111 | } 112 | db, err = gorm.Open("mysql", fmt.Sprintf("gorm:gorm@%v/gorm?charset=utf8mb4,utf8&parseTime=True&multiStatements=true", dbhost)) 113 | 114 | if os.Getenv("DEBUG") == "true" { 115 | db.LogMode(true) 116 | } 117 | 118 | db.DB().SetMaxIdleConns(10) 119 | 120 | return 121 | } 122 | 123 | func openMongoConnection() (session *mgo.Session, client *mongodriver.Client, err error) { 124 | dbhost := os.Getenv("MGO_DBADDRESS") 125 | if dbhost == "" { 126 | dbhost = "localhost" 127 | } 128 | session, err = mgo.Dial(dbhost) 129 | if err != nil { 130 | return 131 | } 132 | 133 | // set settings 134 | globals.Conf.DB.Mongo.DBname = mgoDBName 135 | 136 | client, err = mongo.NewClient(context.Background(), options.Client().ApplyURI(fmt.Sprintf("mongodb://localhost:27017/%s", testMongoDB))) 137 | return 138 | } 139 | 140 | func setUpDBEnvironment() (*gorm.DB, *mgo.Session, *mongodriver.Client) { 141 | var err error 142 | var gormDB *gorm.DB 143 | var mgoDB *mgo.Session 144 | var client *mongodriver.Client 145 | 146 | // Create DB connections 147 | if gormDB, err = openGormConnection(); err != nil { 148 | panic(fmt.Sprintf("No error should happen when connecting to test database, but got err=%+v", err)) 149 | } 150 | 151 | gormDB.SetJoinTableHandler(&models.User{}, globals.TableBookmarks, &models.UsersBookmarks{}) 152 | 153 | // Create Mongo DB connections 154 | if mgoDB, client, err = openMongoConnection(); err != nil { 155 | panic(fmt.Sprintf("No error should happen when connecting to mongo database, but got err=%+v", err)) 156 | } 157 | 158 | // set up tables in gorm DB 159 | runMySQLMigration(gormDB) 160 | 161 | // set up default records in gorm DB 162 | setGormDefaultRecords(gormDB) 163 | 164 | // set up collections in mongoDB 165 | runMgoMigration(mgoDB) 166 | 167 | // cleanup collections in mongo testdb (used by mongo-go-driver) 168 | client.Database(testMongoDB).Drop(context.Background()) 169 | 170 | // set up default collections in mongoDB 171 | setMgoDefaultRecords(mgoDB) 172 | 173 | return gormDB, mgoDB, client 174 | } 175 | -------------------------------------------------------------------------------- /tests/structs.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/globalsign/mgo" 6 | "github.com/jinzhu/gorm" 7 | ) 8 | 9 | type defaultVariables struct { 10 | Account string 11 | Service string 12 | Token string 13 | 14 | ErrorEmailAddress string 15 | } 16 | 17 | type globalVariables struct { 18 | Defaults defaultVariables 19 | GinEngine *gin.Engine 20 | GormDB *gorm.DB 21 | MgoDB *mgo.Session 22 | } 23 | 24 | type webPushSubscriptionPostBody struct { 25 | Endpoint string `json:"endpoint"` 26 | Keys string `json:"keys"` 27 | ExpirationTime string `json:"expiration_time"` 28 | UserID string `json:"user_id"` 29 | } 30 | -------------------------------------------------------------------------------- /utils/db.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/globalsign/mgo" 11 | "github.com/golang-migrate/migrate/v4" 12 | "github.com/golang-migrate/migrate/v4/database/mysql" 13 | _ "github.com/golang-migrate/migrate/v4/source/file" 14 | "github.com/jinzhu/gorm" 15 | "github.com/pkg/errors" 16 | log "github.com/sirupsen/logrus" 17 | "go.mongodb.org/mongo-driver/mongo" 18 | "go.mongodb.org/mongo-driver/mongo/options" 19 | "go.mongodb.org/mongo-driver/mongo/readpref" 20 | "gopkg.in/matryer/try.v1" 21 | 22 | "github.com/twreporter/go-api/globals" 23 | "github.com/twreporter/go-api/models" 24 | ) 25 | 26 | // InitDB initiates the MySQL database connection 27 | func InitDB(attempts, retryMaxDelay int) (*gorm.DB, error) { 28 | var db *gorm.DB 29 | err := try.Do(func(attempt int) (bool, error) { 30 | var err error 31 | var config = globals.Conf.DB.MySQL 32 | 33 | // connect to MySQL database 34 | var endpoint = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4,utf8&parseTime=true", config.User, config.Password, config.Address, config.Port, config.Name) 35 | log.Debug("connect to mysql ", endpoint) 36 | db, err = gorm.Open("mysql", endpoint) 37 | 38 | if err != nil { 39 | time.Sleep(time.Duration(retryMaxDelay) * time.Second) 40 | } 41 | 42 | return attempt < attempts, errors.WithStack(err) 43 | }) 44 | 45 | if err != nil { 46 | return nil, errors.Wrap(err, "Please check the MySQL database connection: ") 47 | } 48 | 49 | db.SetJoinTableHandler(&models.User{}, globals.TableBookmarks, &models.UsersBookmarks{}) 50 | 51 | //db.LogMode(true) 52 | 53 | return db, nil 54 | } 55 | 56 | // InitMongoDB initiates the Mongo DB connection 57 | func InitMongoDB() (*mgo.Session, error) { 58 | var timeout = globals.Conf.DB.Mongo.Timeout 59 | // Set connection timeout 60 | session, err := mgo.DialWithTimeout(globals.Conf.DB.Mongo.URL, time.Duration(timeout)*time.Second) 61 | log.Debug("connect to mongodb ", globals.Conf.DB.Mongo.URL) 62 | 63 | if err != nil { 64 | return nil, errors.Wrap(err, "Establishing a new session to the mongo occurs error: ") 65 | } 66 | 67 | // Set operation timeout 68 | session.SetSyncTimeout(time.Duration(timeout) * time.Second) 69 | 70 | // Set socket timeout to 3 mins 71 | session.SetSocketTimeout(3 * time.Minute) 72 | 73 | // As our mongo cluster comprises cost-effective solution(Replica set arbiter), 74 | // use Nearest read concern(https://docs.mongodb.com/manual/core/read-preference/#nearest) 75 | // to distribute the read load acorss primary and secondary node evenly. 76 | session.SetMode(mgo.Nearest, true) 77 | 78 | return session, nil 79 | } 80 | 81 | func InitMongoDBV2() (*mongo.Client, error) { 82 | clientOpts := options.Client().ApplyURI(globals.Conf.DB.Mongo.URL) 83 | clientOpts = clientOpts.SetReadPreference(readpref.Nearest()) 84 | 85 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 86 | defer cancel() 87 | 88 | client, err := mongo.Connect(ctx, clientOpts) 89 | if err != nil { 90 | return nil, errors.Wrap(err, "Establishing a new connection to cluster occurs error:") 91 | } 92 | 93 | if err = client.Ping(ctx, nil); err != nil { 94 | return nil, errors.Wrap(err, "Connection to cluster does not response:") 95 | } 96 | return client, nil 97 | } 98 | 99 | // Get the migrate instance for operating migration 100 | func GetMigrateInstance(dbInstance *sql.DB) (*migrate.Migrate, error) { 101 | const migrateMysqlDriver = "mysql" 102 | const migrateSourceDriver = "file" 103 | var migrateSourceDir string = filepath.Join(GetProjectRoot(), "migrations") 104 | 105 | driver, _ := mysql.WithInstance(dbInstance, &mysql.Config{}) 106 | 107 | sourceUrl := fmt.Sprintf("%s://%s", migrateSourceDriver, migrateSourceDir) 108 | m, err := migrate.NewWithDatabaseInstance(sourceUrl, migrateMysqlDriver, driver) 109 | 110 | return m, errors.WithStack(err) 111 | } 112 | -------------------------------------------------------------------------------- /utils/helpers.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "gopkg.in/guregu/null.v3" 5 | 6 | "github.com/twreporter/go-api/configs/constants" 7 | ) 8 | 9 | // GetGender format the gender string 10 | func GetGender(s string) null.String { 11 | var gender string 12 | switch s { 13 | case "": 14 | gender = s 15 | case "male": 16 | gender = constants.GenderMale 17 | case "female": 18 | gender = constants.GenderFemale 19 | default: 20 | // Other gender 21 | gender = constants.GenderOthers 22 | } 23 | return null.StringFrom(gender) 24 | } 25 | -------------------------------------------------------------------------------- /utils/mocks/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "AppSettings": { 3 | "Path": "http://testtest.twreporter.org:8080", 4 | "Version": "v1", 5 | "Token": "", 6 | "Expiration": 168 7 | }, 8 | "EmailSettings": { 9 | "SMTPUsername": "", 10 | "SMTPPassword": "", 11 | "SMTPServer": "smtp.office365.com", 12 | "SMTPServerOwner": "office360", 13 | "SMTPPort": "587", 14 | "ConnectionSecurity": "STARTTLS", 15 | "FeedbackName": "報導者 The Reporter", 16 | "FeedbackEmail": "contact@twreporter.org" 17 | }, 18 | "DBSettings": { 19 | "Name": "test_membership", 20 | "User": "", 21 | "Password": "", 22 | "Address": "127.0.0.1", 23 | "Port": "3306" 24 | }, 25 | "OauthSettings": { 26 | "FacebookSettings": { 27 | "Id": "", 28 | "Secret": "", 29 | "Url": "http://testtest.twreporter.org:8080/v1/auth/facebook/callback", 30 | "Statestr": "" 31 | } 32 | }, 33 | "EncryptSettings": { 34 | "Salt": "@#$%" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /utils/mocks/mal-form-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "AppSettings": { 3 | } 4 | -------------------------------------------------------------------------------- /utils/token.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/dgrijalva/jwt-go" 7 | "github.com/pkg/errors" 8 | 9 | "github.com/twreporter/go-api/globals" 10 | ) 11 | 12 | type AuthTokenType int 13 | 14 | const ( 15 | AuthV1Token AuthTokenType = iota + 1 16 | 17 | AuthV2IDToken 18 | AuthV2AccessToken 19 | ) 20 | 21 | const ( 22 | IDTokenSubject = "ID_TOKEN" 23 | AccessTokenSubject = "ACCESS_TOKEN" 24 | ) 25 | 26 | // ReporterJWTClaims JWT claims we used 27 | type ReporterJWTClaims struct { 28 | UserID uint `json:"user_id"` 29 | Email string `json:"email"` 30 | jwt.StandardClaims 31 | } 32 | 33 | // IDToken 34 | type IDTokenJWTClaims struct { 35 | UserID uint `json:"user_id"` 36 | Email string `json:"email"` 37 | FirstName string `json:"first_name"` 38 | LastName string `json:"last_name"` 39 | jwt.StandardClaims 40 | } 41 | 42 | type AccessTokenJWTClaims struct { 43 | UserID uint `json:"user_id"` 44 | Email string `json:"email"` 45 | Roles []map[string]interface{} `json:"roles"` 46 | Activated *time.Time `json:"activated"` 47 | jwt.StandardClaims 48 | } 49 | 50 | func (idc IDTokenJWTClaims) Valid() error { 51 | const verifyRequired = true 52 | var err error 53 | 54 | // Validate expiration date 55 | if err = idc.StandardClaims.Valid(); nil != err { 56 | return err 57 | } 58 | 59 | if IDTokenSubject != idc.StandardClaims.Subject { 60 | errMsg := "Invalid subject" 61 | err = *(jwt.NewValidationError(errMsg, jwt.ValidationErrorClaimsInvalid)) 62 | return err 63 | } 64 | 65 | if !idc.VerifyAudience(globals.Conf.App.JwtAudience, verifyRequired) { 66 | errMsg := "Invalid audience" 67 | err = *(jwt.NewValidationError(errMsg, jwt.ValidationErrorClaimsInvalid)) 68 | return err 69 | } 70 | 71 | if !idc.VerifyIssuer(globals.Conf.App.JwtIssuer, verifyRequired) { 72 | errMsg := "Invalid issuer" 73 | err = *(jwt.NewValidationError(errMsg, jwt.ValidationErrorClaimsInvalid)) 74 | return err 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func RetrieveV2IDToken(userID uint, email, firstName, lastName string, expiration int) (string, error) { 81 | claims := IDTokenJWTClaims{ 82 | userID, 83 | email, 84 | firstName, 85 | lastName, 86 | jwt.StandardClaims{ 87 | IssuedAt: time.Now().Unix(), 88 | ExpiresAt: time.Now().Add(time.Second * time.Duration(expiration)).Unix(), 89 | Issuer: globals.Conf.App.JwtIssuer, 90 | Audience: globals.Conf.App.JwtAudience, 91 | Subject: IDTokenSubject, 92 | }, 93 | } 94 | return genToken(claims, globals.Conf.App.JwtSecret) 95 | } 96 | 97 | func RetrieveV2AccessToken(userID uint, email string, roles []map[string]interface{}, activated *time.Time, expiration int) (string, error) { 98 | claims := AccessTokenJWTClaims{ 99 | userID, 100 | email, 101 | roles, 102 | activated, 103 | jwt.StandardClaims{ 104 | IssuedAt: time.Now().Unix(), 105 | ExpiresAt: time.Now().Add(time.Second * time.Duration(expiration)).Unix(), 106 | Issuer: globals.Conf.App.JwtIssuer, 107 | Audience: globals.Conf.App.JwtAudience, 108 | Subject: AccessTokenSubject, 109 | }, 110 | } 111 | return genToken(claims, globals.Conf.App.JwtSecret) 112 | } 113 | 114 | // RetrieveMailServiceAccessToken generate JWT for mail service validation 115 | func RetrieveMailServiceAccessToken(expiration int) (string, error) { 116 | var secret = globals.MailServiceJWTPrefix + globals.Conf.App.JwtSecret 117 | var claims = jwt.StandardClaims{ 118 | IssuedAt: time.Now().Unix(), 119 | ExpiresAt: time.Now().Add(time.Second * time.Duration(expiration)).Unix(), 120 | Issuer: globals.Conf.App.JwtIssuer, 121 | Audience: globals.Conf.App.JwtAudience, 122 | Subject: AccessTokenSubject, 123 | } 124 | 125 | return genToken(claims, secret) 126 | } 127 | 128 | // genToken - generate jwt token according to user's info 129 | func genToken(claims jwt.Claims, secret string) (string, error) { 130 | const errorWhere = "RetrieveToken" 131 | var err error 132 | var token *jwt.Token 133 | var tokenString string 134 | 135 | // create the token 136 | token = jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 137 | 138 | /* Sign the token with our secret */ 139 | tokenString, err = token.SignedString([]byte(secret)) 140 | 141 | if err != nil { 142 | return "", errors.Wrap(err, "internal server error: fail to generate token") 143 | } 144 | return tokenString, nil 145 | } 146 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "fmt" 7 | "reflect" 8 | "runtime" 9 | "strings" 10 | 11 | "github.com/pkg/errors" 12 | "golang.org/x/crypto/scrypt" 13 | 14 | "github.com/twreporter/go-api/globals" 15 | ) 16 | 17 | // GenerateRandomBytes returns securely generated random bytes. 18 | // It will return an error if the system's secure random 19 | // number generator fails to function correctly, in which 20 | // case the caller should not continue. 21 | func GenerateRandomBytes(n int) ([]byte, error) { 22 | b := make([]byte, n) 23 | _, err := rand.Read(b) 24 | // Note that err == nil only if we read len(b) bytes. 25 | if err != nil { 26 | return nil, errors.WithStack(err) 27 | } 28 | 29 | return b, nil 30 | } 31 | 32 | // GenerateRandomString returns a URL-safe, base64 encoded 33 | // securely generated random string. 34 | func GenerateRandomString(s int) (string, error) { 35 | b, err := GenerateRandomBytes(s) 36 | return base64.URLEncoding.EncodeToString(b), err 37 | } 38 | 39 | // GenerateEncryptedPassword returns encryptedly 40 | // securely generated string. 41 | func GenerateEncryptedPassword(password []byte) (string, error) { 42 | salt := []byte(globals.Conf.Encrypt.Salt) 43 | key, err := scrypt.Key(password, salt, 16384, 8, 1, 32) 44 | return fmt.Sprintf("%x", key), errors.WithStack(err) 45 | } 46 | 47 | // GetProjectRoot returns absolute path of current project root. 48 | func GetProjectRoot() string { 49 | type emptyStruct struct{} 50 | const rootPkg = "main" 51 | 52 | // use the reflect package to retrieve current package path 53 | // [go module name]/[package name] 54 | // i.e. github.com/twreporter/go-api/utils 55 | pkg := reflect.TypeOf(emptyStruct{}).PkgPath() 56 | pkgWithoutModPrefix := string([]byte(pkg)[strings.LastIndex(pkg, "/")+1:]) 57 | 58 | // Get the file name of current function 59 | _, file, _, _ := runtime.Caller(0) 60 | 61 | root := "" 62 | if pkgWithoutModPrefix == rootPkg { 63 | // If package lies in main package, 64 | // then the file must be within project root 65 | root = string([]byte(file)[:strings.LastIndex(file, "/")]) 66 | } else { 67 | // If package lies in any sub package, 68 | // then the project root is the prefix of the current file name. 69 | root = string([]byte(file)[:strings.Index(file, pkgWithoutModPrefix)-1]) 70 | } 71 | 72 | return root 73 | } 74 | --------------------------------------------------------------------------------