├── .circleci └── config.yml ├── .fleet └── settings.json ├── .gitattributes ├── .github └── dependabot.yml ├── .gitignore ├── .infra ├── .gitignore ├── .nvmrc ├── Pulumi.prod.yaml ├── Pulumi.yaml ├── index.ts ├── package-lock.json ├── package.json └── tsconfig.json ├── Dockerfile ├── LICENSE ├── README.md ├── bsa.go ├── campaigns.go ├── campaigns_test.go ├── db.go ├── docker-compose.yml ├── ethicalads.go ├── geolocation.go ├── go.mod ├── go.sum ├── health_test.go ├── ip2location ├── IP2LOCATION-LITE-DB1.BIN └── README_LITE.TXT ├── main.go ├── migrations ├── 10_ad_experience_level.down.sql ├── 10_ad_experience_level.up.sql ├── 11_user_experience_levels.down.sql ├── 11_user_experience_levels.up.sql ├── 1_initialize.down.sql ├── 1_initialize.up.sql ├── 2_probability.down.sql ├── 2_probability.up.sql ├── 3_geo.down.sql ├── 3_geo.up.sql ├── 4_segment.down.sql ├── 4_segment.up.sql ├── 5_ad_segment.down.sql ├── 5_ad_segment.up.sql ├── 6_user_tag.down.sql ├── 6_user_tag.up.sql ├── 7_ad_tags.down.sql ├── 7_ad_tags.up.sql ├── 8_ad_goal.down.sql ├── 8_ad_goal.up.sql ├── 9_ad_budget.down.sql └── 9_ad_budget.up.sql ├── serve_ad_test.go ├── serve_toilet_test.go ├── userExperienceLevels.go ├── userExperienceLevels_test.go ├── userTags.go ├── userTags_test.go └── utils.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | gcp-gcr: circleci/gcp-gcr@0.16.2 4 | gcp-cli: circleci/gcp-cli@3.1.1 5 | pulumi: pulumi/pulumi@2.0.0 6 | slack: circleci/slack@4.12.1 7 | 8 | jobs: 9 | build: 10 | docker: 11 | - image: cimg/go:1.23 12 | - image: mysql:5.7 13 | environment: 14 | MYSQL_DATABASE: test 15 | MYSQL_ROOT_PASSWORD: 12345 16 | steps: 17 | - checkout 18 | - restore_cache: 19 | key: dependency-cache-{{ checksum "go.sum" }} 20 | - run: 21 | name: Setup Environment Variables 22 | command: | 23 | echo 'export GOPROXY=https://${GOPROXY_USER}:${GOPROXY_PASS}@${GOPROXY_HOST},direct' >> $BASH_ENV 24 | echo 'export IMAGE_NAME=gcr.io/${GOOGLE_PROJECT_ID}/${CIRCLE_PROJECT_REPONAME}' >> $BASH_ENV 25 | echo 'export IMAGE_TAG=${CIRCLE_SHA1}' >> $BASH_ENV 26 | - run: 27 | name: Vendor 28 | command: if [ ! -d ./vendor ]; then go mod vendor; fi 29 | - run: 30 | name: Build 31 | command: go build -o main 32 | environment: 33 | PROJECT_ROOT: $CIRCLE_PROJECT_REPONAME 34 | CGO_ENABLED: 0 35 | GOOS: linux 36 | - run: 37 | name: Wait for MySQL 38 | command: | 39 | for i in `seq 1 10`; 40 | do 41 | nc -z localhost 3306 && echo Success && exit 0 42 | echo -n . 43 | sleep 1 44 | done 45 | echo Failed waiting for Mysql && exit 1 46 | - run: 47 | name: Create service account 48 | command: echo $GCLOUD_SERVICE_KEY > key.json 49 | - run: 50 | name: Test 51 | command: go test -p 1 52 | environment: 53 | PROJECT_ROOT: $CIRCLE_PROJECT_REPONAME 54 | GCLOUD_PROJECT: daily-ops 55 | GOOGLE_APPLICATION_CREDENTIALS: ./key.json 56 | DB_CONNECTION_STRING: root:12345@(localhost:3306)/test 57 | CAMPAIGNS_COUNT: 1 58 | - persist_to_workspace: 59 | root: . 60 | paths: 61 | - main 62 | build_and_push_docker: 63 | machine: true 64 | steps: 65 | - checkout 66 | - attach_workspace: 67 | at: . 68 | - gcp-gcr/build-image: 69 | image: $CIRCLE_PROJECT_REPONAME 70 | tag: $CIRCLE_SHA1 71 | - gcp-gcr/gcr-auth 72 | - gcp-gcr/push-image: 73 | image: $CIRCLE_PROJECT_REPONAME 74 | tag: $CIRCLE_SHA1 75 | pulumi_preview: 76 | docker: 77 | - image: circleci/node:16 78 | steps: 79 | - checkout 80 | - pulumi/login 81 | - run: 82 | name: Install dependencies 83 | command: npm i 84 | working_directory: .infra 85 | - run: 86 | name: Pulumi preview 87 | command: pulumi preview --suppress-outputs --stack dailydotdev/prod -c tag=$CIRCLE_SHA1 88 | working_directory: .infra 89 | pulumi_up: 90 | circleci_ip_ranges: true 91 | docker: 92 | - image: circleci/node:16 93 | environment: 94 | USE_GKE_GCLOUD_AUTH_PLUGIN: 'True' 95 | steps: 96 | - checkout 97 | - gcp-cli/install 98 | - gcp-cli/setup 99 | - run: 100 | name: Install GKE Auth plugin 101 | command: gcloud components install gke-gcloud-auth-plugin 102 | - pulumi/login 103 | - run: 104 | name: Install dependencies 105 | command: npm i 106 | working_directory: .infra 107 | - run: 108 | name: Pulumi refresh 109 | command: pulumi refresh -y --suppress-outputs --stack dailydotdev/prod 110 | working_directory: .infra 111 | - run: 112 | name: Pulumi up 113 | command: pulumi up -y --suppress-outputs --stack dailydotdev/prod -c tag=$CIRCLE_SHA1 114 | working_directory: .infra 115 | workflows: 116 | build: 117 | jobs: 118 | - build: 119 | context: GCR 120 | - build_and_push_docker: 121 | requires: 122 | - build 123 | context: GCR 124 | - pulumi_preview: 125 | context: PROD 126 | filters: 127 | branches: 128 | ignore: 129 | - /pull\/[0-9]+/ 130 | - master 131 | - pulumi_up: 132 | requires: 133 | - build_and_push_docker 134 | context: PROD 135 | filters: 136 | branches: 137 | only: 138 | - master 139 | - gcp-gcr/add-image-tag: 140 | requires: 141 | - build_and_push_docker 142 | context: GCR 143 | image: $CIRCLE_PROJECT_REPONAME 144 | source-tag: $CIRCLE_SHA1 145 | target-tag: latest 146 | filters: 147 | branches: 148 | only: 149 | - master 150 | -------------------------------------------------------------------------------- /.fleet/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.root": "${env:GOROOT}", 3 | "go.path": "${env:GOPATH}", 4 | "go.modules": "true" 5 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | helm/values/** filter=git-crypt diff=git-crypt 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | tmp/ 3 | local.sh 4 | test.sh 5 | 6 | ### Go template 7 | # Binaries for programs and plugins 8 | *.exe 9 | *.exe~ 10 | *.dll 11 | *.so 12 | *.dylib 13 | 14 | # Test binary, build with `go test -c` 15 | *.test 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | /vendor/ 20 | /main 21 | -------------------------------------------------------------------------------- /.infra/.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /node_modules/ 3 | -------------------------------------------------------------------------------- /.infra/.nvmrc: -------------------------------------------------------------------------------- 1 | 16 -------------------------------------------------------------------------------- /.infra/Pulumi.prod.yaml: -------------------------------------------------------------------------------- 1 | config: 2 | gcp:project: devkit-prod 3 | gcp:region: us-central1 4 | monetization:env: 5 | campaignsCount: 4 6 | dbConnectionString: 7 | secure: AAABAMW6DYRChthaLArzst4Az4M9MiDgF4NFo60j1bUetzXohSEhsid/70/aIR6qUHXJqvJC2hFsgrpPblYCWaucEkpV2O5ji5kQ7aCrnIKkmESt7KHAoCepleBRa4TNX0U= 8 | ethicaladsToken: 9 | secure: AAABACL1XnobfoyPngc4Sd2Lhg9YopSpB7FjV1Y7WuWRDViV45a+6SXTYUI1B2VfrKcss/8M+TQ70Sue/0JJzPGulXWEPjAa 10 | gcloudProject: devkit-prod 11 | migrationsSource: 12 | secure: AAABAHqEBR85/0LveSBxrmIDVYOIGKXKf7K0m1Dy/2JBWp8RBLfOWkwNUBaW7BC6ZRXktuvdrWW6ToqIcnwQVnCJd3AQcyopCaTIvNZZZhqz0Bbrbks3QdXMQ8FjNYNqlmO4Fr8wW1NQgZ5KtihnF/Kf1+8fVUCQS6ASfB9bBCE3Gl9BYA== 13 | monetization:k8s: 14 | namespace: daily 15 | -------------------------------------------------------------------------------- /.infra/Pulumi.yaml: -------------------------------------------------------------------------------- 1 | name: monetization 2 | runtime: nodejs 3 | description: Infrastructure for daily-monetization 4 | -------------------------------------------------------------------------------- /.infra/index.ts: -------------------------------------------------------------------------------- 1 | import * as gcp from '@pulumi/gcp'; 2 | import { 3 | config, 4 | createServiceAccountAndGrantRoles, 5 | getImageTag, 6 | createPubSubCronJobs, 7 | deployApplicationSuite 8 | } from '@dailydotdev/pulumi-common'; 9 | import {Input} from '@pulumi/pulumi'; 10 | 11 | const imageTag = getImageTag(); 12 | const name = 'monetization'; 13 | 14 | const {serviceAccount} = createServiceAccountAndGrantRoles( 15 | `${name}-sa`, 16 | name, 17 | `daily-${name}`, 18 | [ 19 | {name: 'trace', role: 'roles/cloudtrace.agent'}, 20 | {name: 'secret', role: 'roles/secretmanager.secretAccessor'}, 21 | {name: 'subscriber', role: 'roles/pubsub.subscriber'}, 22 | ], 23 | ); 24 | 25 | const {namespace} = config.requireObject<{ namespace: string }>('k8s'); 26 | 27 | const envVars = config.requireObject>('env'); 28 | 29 | const image = `gcr.io/daily-ops/daily-${name}:${imageTag}`; 30 | 31 | const apiLimits: Input<{ 32 | [key: string]: Input; 33 | }> = { 34 | cpu: '0.1', 35 | memory: '96Mi', 36 | }; 37 | 38 | const bgLimits: Input<{ 39 | [key: string]: Input; 40 | }> = { 41 | cpu: '0.2', 42 | memory: '64Mi', 43 | }; 44 | 45 | const probe = { 46 | httpGet: {path: '/health', port: 'http'}, 47 | initialDelaySeconds: 5, 48 | }; 49 | 50 | const jobs = createPubSubCronJobs(name, [{ 51 | name: 'delete-old-tags', 52 | schedule: '6 10 * * 0', 53 | topic: 'delete-old-tags', 54 | }]); 55 | 56 | new gcp.pubsub.Subscription(`${name}-sub-delete-old-tags`, { 57 | topic: 'delete-old-tags', 58 | name: `${name}-delete-old-tags`, 59 | labels: {app: name}, 60 | retryPolicy: { 61 | minimumBackoff: '1s', 62 | maximumBackoff: '60s', 63 | }, 64 | expirationPolicy: { 65 | ttl: '', 66 | }, 67 | }, {dependsOn: jobs}); 68 | 69 | new gcp.pubsub.Subscription(`${name}-sub-views`, { 70 | topic: 'views', 71 | name: `${name}-views`, 72 | labels: {app: name}, 73 | retryPolicy: { 74 | minimumBackoff: '1s', 75 | maximumBackoff: '60s', 76 | }, 77 | }); 78 | 79 | // api.v1.user-created 80 | new gcp.pubsub.Subscription(`${name}-sub-user-created`, { 81 | topic: 'api.v1.user-created', 82 | name: `${name}-user-created`, 83 | labels: {app: name}, 84 | retryPolicy: { 85 | minimumBackoff: '1s', 86 | maximumBackoff: '60s', 87 | }, 88 | }); 89 | 90 | // user-updated 91 | new gcp.pubsub.Subscription(`${name}-sub-user-updated`, { 92 | topic: 'user-updated', 93 | name: `${name}-user-updated`, 94 | labels: {app: name}, 95 | retryPolicy: { 96 | minimumBackoff: '1s', 97 | maximumBackoff: '60s', 98 | }, 99 | }); 100 | 101 | // user-updated 102 | new gcp.pubsub.Subscription(`${name}-sub-user-deleted`, { 103 | topic: 'user-deleted', 104 | name: `${name}-user-deleted`, 105 | labels: {app: name}, 106 | retryPolicy: { 107 | minimumBackoff: '1s', 108 | maximumBackoff: '60s', 109 | }, 110 | }); 111 | 112 | // 113 | new gcp.pubsub.Subscription(`${name}-sub-new-ad`, { 114 | topic: 'ad-image-processed', 115 | name: `${name}-new-ad`, 116 | labels: {app: name}, 117 | retryPolicy: { 118 | minimumBackoff: '1s', 119 | maximumBackoff: '60s', 120 | }, 121 | expirationPolicy: { 122 | ttl: '', 123 | }, 124 | }); 125 | 126 | deployApplicationSuite({ 127 | name, 128 | namespace, 129 | image, 130 | imageTag, 131 | serviceAccount, 132 | secrets: envVars, 133 | migration: { 134 | args: ['/main', 'migrate'] 135 | }, 136 | apps: [{ 137 | port: 3000, 138 | env: [{name: 'PORT', value: '3000'}, {name: 'ENV', value: 'PROD'}], 139 | maxReplicas: 10, 140 | limits: apiLimits, 141 | readinessProbe: probe, 142 | metric: {type: 'memory_cpu', cpu: 70}, 143 | createService: true, 144 | }, { 145 | nameSuffix: 'bg', 146 | args: ['/main', 'background'], 147 | minReplicas: 1, 148 | maxReplicas: 4, 149 | limits: bgLimits, 150 | metric: {type: 'pubsub', labels: {app: name}, targetAverageValue: 20}, 151 | }], 152 | }); 153 | -------------------------------------------------------------------------------- /.infra/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monetization", 3 | "devDependencies": { 4 | "@types/node": "^15.12.4" 5 | }, 6 | "dependencies": { 7 | "@dailydotdev/pulumi-common": "^1.7.1", 8 | "@pulumi/gcp": "^6.30.0", 9 | "@pulumi/kubernetes": "^3.15.2", 10 | "@pulumi/pulumi": "^3.35.3" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.infra/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "outDir": "bin", 5 | "target": "es2016", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "experimentalDecorators": true, 10 | "pretty": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noImplicitReturns": true, 13 | "forceConsistentCasingInFileNames": true 14 | }, 15 | "files": [ 16 | "index.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM binxio/gcp-get-secret 2 | 3 | FROM alpine 4 | RUN apk update && \ 5 | apk add ca-certificates && \ 6 | update-ca-certificates && \ 7 | rm -rf /var/cache/apk/* 8 | 9 | EXPOSE 9090 10 | 11 | COPY --from=0 /gcp-get-secret /usr/local/bin/ 12 | 13 | ADD ip2location /ip2location 14 | ADD main / 15 | ENTRYPOINT ["/usr/local/bin/gcp-get-secret"] 16 | CMD ["/main"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Daily Monetization

3 | Serve ads from different providers 4 |
5 |
6 |

7 | 8 | Build Status 9 | 10 | 11 | License 12 | 13 | 14 | StackShare 15 | 16 |

17 | 18 | The monetization service serves ads to the daily.dev applications. 19 | It combines the Carbon ads and Ethical Ads networks along with our in-house ads system. 20 | We also have fallback ads for cases where no network can provide a paid impression. 21 | 22 | ## Stack 23 | 24 | * Go v1.16 25 | * Go mod for managing dependencies. 26 | * `net/http` as the web framework 27 | * MySQL as a database 28 | 29 | ## Want to Help? 30 | 31 | So you want to contribute to Daily Monetization and make an impact, we are glad to hear it. :heart_eyes: 32 | 33 | Before you proceed we have a few guidelines for contribution that will make everything much easier. 34 | We would appreciate if you dedicate the time and read them carefully: 35 | https://github.com/dailydotdev/.github/blob/master/CONTRIBUTING.md 36 | -------------------------------------------------------------------------------- /bsa.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | type BsaAd struct { 11 | Ad 12 | Pixel []string 13 | ReferralLink string 14 | TagLine string 15 | BackgroundColor string 16 | } 17 | 18 | type BsaResponse struct { 19 | Ads []map[string]interface{} 20 | } 21 | 22 | var hystrixBsa = "BSA" 23 | 24 | func sendBsaRequest(r *http.Request, propertyId string) (BsaResponse, error) { 25 | var res BsaResponse 26 | ua := r.UserAgent() 27 | ip := getIpAddress(r) 28 | //ip = "208.98.185.89" 29 | req, _ := http.NewRequest("GET", "https://srv.buysellads.com/ads/"+propertyId+".json?segment=placement:dailynowco&forwardedip="+ip+"&useragent="+url.QueryEscape(ua), nil) 30 | req = req.WithContext(r.Context()) 31 | 32 | err := getJsonHystrix(hystrixBsa, req, &res, false) 33 | if err != nil { 34 | return BsaResponse{}, err 35 | } 36 | 37 | return res, nil 38 | } 39 | 40 | var fetchBsa = func(r *http.Request, propertyId string) (*BsaAd, error) { 41 | res, err := sendBsaRequest(r, propertyId) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | ads := res.Ads 47 | for _, ad := range ads { 48 | if _, ok := ad["statlink"]; ok { 49 | retAd := BsaAd{} 50 | retAd.Description, _ = ad["description"].(string) 51 | if len(retAd.Description) == 0 { 52 | retAd.Description, _ = ad["title"].(string) 53 | } 54 | retAd.Image, _ = ad["smallImage"].(string) 55 | if len(retAd.Image) == 0 { 56 | retAd.Image, _ = ad["image"].(string) 57 | } 58 | retAd.Link, _ = ad["statlink"].(string) 59 | // Prepend https: to the link if it's missing 60 | if !strings.HasPrefix(retAd.Link, "https:") { 61 | retAd.Link = fmt.Sprintf("https:%s", retAd.Link) 62 | } 63 | retAd.ReferralLink, _ = ad["ad_via_link"].(string) 64 | retAd.Source = "Carbon" 65 | retAd.Company, _ = ad["company"].(string) 66 | if len(retAd.Company) == 0 { 67 | retAd.Company = retAd.Source 68 | } 69 | retAd.TagLine, _ = ad["companyTagline"].(string) 70 | retAd.BackgroundColor, _ = ad["backgroundColor"].(string) 71 | retAd.ProviderId = "carbon" 72 | if pixel, ok := ad["pixel"].(string); ok { 73 | retAd.Pixel = strings.Split(pixel, "||") 74 | for index := range retAd.Pixel { 75 | retAd.Pixel[index] = strings.Replace(retAd.Pixel[index], "[timestamp]", ad["timestamp"].(string), -1) 76 | } 77 | } else { 78 | retAd.Pixel = []string{} 79 | } 80 | return &retAd, nil 81 | } 82 | } 83 | 84 | return nil, nil 85 | } 86 | -------------------------------------------------------------------------------- /campaigns.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "regexp" 7 | "time" 8 | 9 | "github.com/afex/hystrix-go/hystrix" 10 | ) 11 | 12 | type Ad struct { 13 | Description string 14 | Image string 15 | Link string 16 | Source string 17 | Company string 18 | ProviderId string 19 | } 20 | 21 | type CampaignAd struct { 22 | Ad 23 | Id string 24 | Placeholder string 25 | Ratio float32 26 | Probability float32 `json:"-"` 27 | Fallback bool `json:"-"` 28 | Geo string `json:"-"` 29 | IsTagTargeted bool `json:"-"` 30 | IsExpTargeted bool `json:"-"` 31 | } 32 | 33 | type ScheduledCampaignAd struct { 34 | CampaignAd 35 | Start time.Time 36 | End time.Time 37 | } 38 | 39 | var cloudinaryRegex = regexp.MustCompile(`(?:res\.cloudinary\.com\/daily-now|daily-now-res\.cloudinary\.com)`) 40 | 41 | func mapCloudinaryUrl(url string) string { 42 | return cloudinaryRegex.ReplaceAllString(url, "media.daily.dev") 43 | } 44 | 45 | var addCampaign = func(ctx context.Context, camp ScheduledCampaignAd) error { 46 | return hystrix.DoC(ctx, hystrixDb, 47 | func(ctx context.Context) error { 48 | _, err := addCampStmt.ExecContext(ctx, camp.Id, camp.Description, camp.Link, camp.Image, camp.Ratio, camp.Placeholder, camp.Source, camp.Company, camp.Probability, camp.Fallback, camp.Geo, camp.Start, camp.End) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | }, nil) 55 | } 56 | 57 | var fetchCampaigns = func(ctx context.Context, timestamp time.Time, userId string) ([]CampaignAd, error) { 58 | output := make(chan []CampaignAd, 1) 59 | errors := hystrix.GoC(ctx, hystrixDb, 60 | func(ctx context.Context) error { 61 | rows, err := campStmt.QueryContext(ctx, userId, userId, timestamp, timestamp) 62 | if err != nil { 63 | return err 64 | } 65 | defer rows.Close() 66 | 67 | var res []CampaignAd 68 | for rows.Next() { 69 | var camp CampaignAd 70 | var geo sql.NullString 71 | err = rows.Scan(&camp.Id, &camp.Description, &camp.Link, &camp.Image, &camp.Ratio, &camp.Placeholder, &camp.Source, &camp.Company, &camp.Probability, &camp.Fallback, &geo, &camp.IsTagTargeted, &camp.IsExpTargeted) 72 | if err != nil { 73 | return err 74 | } 75 | camp.Image = mapCloudinaryUrl(camp.Image) 76 | if geo.Valid && len(geo.String) > 0 { 77 | camp.Geo = geo.String 78 | if !camp.Fallback { 79 | if camp.IsTagTargeted || camp.IsExpTargeted { 80 | camp.ProviderId = "direct-combined" 81 | } else { 82 | camp.ProviderId = "direct-geo" 83 | } 84 | } 85 | } else { 86 | camp.Geo = "" 87 | if !camp.Fallback { 88 | if camp.IsTagTargeted || camp.IsExpTargeted { 89 | camp.ProviderId = "direct-keywords" 90 | } else { 91 | camp.ProviderId = "direct" 92 | } 93 | } 94 | } 95 | res = append(res, camp) 96 | } 97 | err = rows.Err() 98 | if err != nil { 99 | return err 100 | } 101 | 102 | output <- res 103 | return nil 104 | }, nil) 105 | 106 | select { 107 | case out := <-output: 108 | return out, nil 109 | case err := <-errors: 110 | return nil, err 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /campaigns_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | var camp = CampaignAd{ 11 | Placeholder: "placholder", 12 | Ratio: 0.5, 13 | Id: "id", 14 | Probability: 1, 15 | Fallback: true, 16 | Ad: Ad{ 17 | Source: "source", 18 | Image: "image", 19 | Link: "http://link.com", 20 | Description: "desc", 21 | Company: "company", 22 | }, 23 | } 24 | 25 | func TestAddAndFetchCampaigns(t *testing.T) { 26 | migrateDatabase() 27 | initializeDatabase() 28 | defer tearDatabase() 29 | defer dropDatabase() 30 | 31 | err := addCampaign(context.Background(), ScheduledCampaignAd{ 32 | CampaignAd: camp, 33 | Start: time.Now().Add(time.Hour * -1), 34 | End: time.Now().Add(time.Hour), 35 | }) 36 | assert.Nil(t, err) 37 | err = addOrUpdateUserTags(context.Background(), "1", []string{"javascript"}) 38 | assert.Nil(t, err) 39 | 40 | var res []CampaignAd 41 | res, err = fetchCampaigns(context.Background(), time.Now(), "1") 42 | assert.Nil(t, err) 43 | assert.Equal(t, []CampaignAd{camp}, res) 44 | } 45 | 46 | func TestFetchExpiredCampaigns(t *testing.T) { 47 | migrateDatabase() 48 | initializeDatabase() 49 | defer tearDatabase() 50 | defer dropDatabase() 51 | 52 | err := addCampaign(context.Background(), ScheduledCampaignAd{ 53 | CampaignAd: camp, 54 | Start: time.Now().Add(time.Hour * -2), 55 | End: time.Now().Add(time.Hour * -1), 56 | }) 57 | assert.Nil(t, err) 58 | 59 | var res []CampaignAd 60 | res, err = fetchCampaigns(context.Background(), time.Now(), "1") 61 | assert.Nil(t, err) 62 | assert.Equal(t, []CampaignAd(nil), res) 63 | } 64 | 65 | func TestFetchCampaignsWithTags(t *testing.T) { 66 | migrateDatabase() 67 | initializeDatabase() 68 | defer tearDatabase() 69 | defer dropDatabase() 70 | 71 | err := addCampaign(context.Background(), ScheduledCampaignAd{ 72 | CampaignAd: camp, 73 | Start: time.Now().Add(time.Hour * -1), 74 | End: time.Now().Add(time.Hour), 75 | }) 76 | assert.Nil(t, err) 77 | err = addCampaign(context.Background(), ScheduledCampaignAd{ 78 | CampaignAd: CampaignAd{ 79 | Placeholder: "placholder", 80 | Ratio: 0.5, 81 | Id: "id2", 82 | Probability: 1, 83 | Fallback: true, 84 | Ad: Ad{ 85 | Source: "source", 86 | Image: "image", 87 | Link: "http://link.com", 88 | Description: "desc", 89 | Company: "company", 90 | }, 91 | }, 92 | Start: time.Now().Add(time.Hour * -1), 93 | End: time.Now().Add(time.Hour), 94 | }) 95 | assert.Nil(t, err) 96 | err = addOrUpdateUserTags(context.Background(), "1", []string{"javascript"}) 97 | assert.Nil(t, err) 98 | _, err = db.Exec("insert into ad_tags (ad_id, tag) values ('id', ?), ('id2', ?)", "javascript", "php") 99 | assert.Nil(t, err) 100 | 101 | var res []CampaignAd 102 | res, err = fetchCampaigns(context.Background(), time.Now(), "1") 103 | dup := camp 104 | dup.IsTagTargeted = true 105 | assert.Nil(t, err) 106 | assert.Equal(t, []CampaignAd{dup}, res) 107 | } 108 | 109 | func TestFetchCampaignsWithBoth(t *testing.T) { 110 | migrateDatabase() 111 | initializeDatabase() 112 | defer tearDatabase() 113 | defer dropDatabase() 114 | 115 | err := addCampaign(context.Background(), ScheduledCampaignAd{ 116 | CampaignAd: camp, 117 | Start: time.Now().Add(time.Hour * -1), 118 | End: time.Now().Add(time.Hour), 119 | }) 120 | assert.Nil(t, err) 121 | err = addCampaign(context.Background(), ScheduledCampaignAd{ 122 | CampaignAd: CampaignAd{ 123 | Placeholder: "placholder", 124 | Ratio: 0.5, 125 | Id: "id2", 126 | Probability: 1, 127 | Fallback: true, 128 | Ad: Ad{ 129 | Source: "source", 130 | Image: "image", 131 | Link: "http://link.com", 132 | Description: "desc", 133 | Company: "company", 134 | }, 135 | }, 136 | Start: time.Now().Add(time.Hour * -1), 137 | End: time.Now().Add(time.Hour), 138 | }) 139 | assert.Nil(t, err) 140 | err = setOrUpdateExperienceLevel(context.Background(), "1", "MORE_THAN_4_YEARS") 141 | assert.Nil(t, err) 142 | _, err = db.Exec("insert into ad_experience_level (ad_id, experience_level) values ('id', ?), ('id2', ?)", "MORE_THAN_4_YEARS", "MORE_THAN_6_YEARS") 143 | assert.Nil(t, err) 144 | 145 | var res []CampaignAd 146 | res, err = fetchCampaigns(context.Background(), time.Now(), "1") 147 | dup := camp 148 | dup.IsExpTargeted = true 149 | assert.Nil(t, err) 150 | assert.Equal(t, []CampaignAd{dup}, res) 151 | } 152 | 153 | func TestFetchCampaignsWithExperience(t *testing.T) { 154 | migrateDatabase() 155 | initializeDatabase() 156 | defer tearDatabase() 157 | defer dropDatabase() 158 | 159 | err := addCampaign(context.Background(), ScheduledCampaignAd{ 160 | CampaignAd: camp, 161 | Start: time.Now().Add(time.Hour * -1), 162 | End: time.Now().Add(time.Hour), 163 | }) 164 | assert.Nil(t, err) 165 | err = addCampaign(context.Background(), ScheduledCampaignAd{ 166 | CampaignAd: CampaignAd{ 167 | Placeholder: "placholder", 168 | Ratio: 0.5, 169 | Id: "id2", 170 | Probability: 1, 171 | Fallback: true, 172 | Ad: Ad{ 173 | Source: "source", 174 | Image: "image", 175 | Link: "http://link.com", 176 | Description: "desc", 177 | Company: "company", 178 | }, 179 | }, 180 | Start: time.Now().Add(time.Hour * -1), 181 | End: time.Now().Add(time.Hour), 182 | }) 183 | assert.Nil(t, err) 184 | err = addOrUpdateUserTags(context.Background(), "1", []string{"javascript"}) 185 | assert.Nil(t, err) 186 | _, err = db.Exec("insert into ad_tags (ad_id, tag) values ('id', ?), ('id2', ?)", "javascript", "php") 187 | assert.Nil(t, err) 188 | err = setOrUpdateExperienceLevel(context.Background(), "1", "MORE_THAN_4_YEARS") 189 | assert.Nil(t, err) 190 | _, err = db.Exec("insert into ad_experience_level (ad_id, experience_level) values ('id', ?), ('id2', ?)", "MORE_THAN_4_YEARS", "MORE_THAN_6_YEARS") 191 | assert.Nil(t, err) 192 | 193 | var res []CampaignAd 194 | res, err = fetchCampaigns(context.Background(), time.Now(), "1") 195 | dup := camp 196 | dup.IsExpTargeted = true 197 | dup.IsTagTargeted = true 198 | assert.Nil(t, err) 199 | assert.Equal(t, []CampaignAd{dup}, res) 200 | } 201 | 202 | func TestFetchCampaignsWithExperienceButNotMatching(t *testing.T) { 203 | migrateDatabase() 204 | initializeDatabase() 205 | defer tearDatabase() 206 | defer dropDatabase() 207 | 208 | err := addCampaign(context.Background(), ScheduledCampaignAd{ 209 | CampaignAd: camp, 210 | Start: time.Now().Add(time.Hour * -1), 211 | End: time.Now().Add(time.Hour), 212 | }) 213 | assert.Nil(t, err) 214 | err = addCampaign(context.Background(), ScheduledCampaignAd{ 215 | CampaignAd: CampaignAd{ 216 | Placeholder: "placholder", 217 | Ratio: 0.5, 218 | Id: "id2", 219 | Probability: 1, 220 | Fallback: true, 221 | Ad: Ad{ 222 | Source: "source", 223 | Image: "image", 224 | Link: "http://link.com", 225 | Description: "desc", 226 | Company: "company", 227 | }, 228 | }, 229 | Start: time.Now().Add(time.Hour * -1), 230 | End: time.Now().Add(time.Hour), 231 | }) 232 | assert.Nil(t, err) 233 | err = addOrUpdateUserTags(context.Background(), "1", []string{"javascript"}) 234 | assert.Nil(t, err) 235 | _, err = db.Exec("insert into ad_tags (ad_id, tag) values ('id', ?), ('id2', ?)", "javascript", "php") 236 | assert.Nil(t, err) 237 | err = setOrUpdateExperienceLevel(context.Background(), "1", "MORE_THAN_6_YEARS") 238 | assert.Nil(t, err) 239 | _, err = db.Exec("insert into ad_experience_level (ad_id, experience_level) values ('id', ?)", "MORE_THAN_4_YEARS") 240 | assert.Nil(t, err) 241 | 242 | var res []CampaignAd 243 | res, err = fetchCampaigns(context.Background(), time.Now(), "1") 244 | assert.Nil(t, err) 245 | assert.Equal(t, []CampaignAd(nil), res) 246 | } 247 | 248 | func TestFetchCampaignsWithExperienceButMissing(t *testing.T) { 249 | migrateDatabase() 250 | initializeDatabase() 251 | defer tearDatabase() 252 | defer dropDatabase() 253 | 254 | err := addCampaign(context.Background(), ScheduledCampaignAd{ 255 | CampaignAd: camp, 256 | Start: time.Now().Add(time.Hour * -1), 257 | End: time.Now().Add(time.Hour), 258 | }) 259 | assert.Nil(t, err) 260 | err = addCampaign(context.Background(), ScheduledCampaignAd{ 261 | CampaignAd: CampaignAd{ 262 | Placeholder: "placholder", 263 | Ratio: 0.5, 264 | Id: "id2", 265 | Probability: 1, 266 | Fallback: true, 267 | Ad: Ad{ 268 | Source: "source", 269 | Image: "image", 270 | Link: "http://link.com", 271 | Description: "desc", 272 | Company: "company", 273 | }, 274 | }, 275 | Start: time.Now().Add(time.Hour * -1), 276 | End: time.Now().Add(time.Hour), 277 | }) 278 | assert.Nil(t, err) 279 | err = addOrUpdateUserTags(context.Background(), "1", []string{"javascript"}) 280 | assert.Nil(t, err) 281 | _, err = db.Exec("insert into ad_tags (ad_id, tag) values ('id', ?), ('id2', ?)", "javascript", "php") 282 | assert.Nil(t, err) 283 | err = setOrUpdateExperienceLevel(context.Background(), "1", "MORE_THAN_6_YEARS") 284 | assert.Nil(t, err) 285 | 286 | var res []CampaignAd 287 | res, err = fetchCampaigns(context.Background(), time.Now(), "1") 288 | dup := camp 289 | dup.IsExpTargeted = false 290 | dup.IsTagTargeted = true 291 | assert.Nil(t, err) 292 | assert.Equal(t, []CampaignAd{dup}, res) 293 | } 294 | -------------------------------------------------------------------------------- /db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | _ "github.com/go-sql-driver/mysql" 6 | "github.com/golang-migrate/migrate/v4" 7 | "github.com/golang-migrate/migrate/v4/database/mysql" 8 | _ "github.com/golang-migrate/migrate/v4/database/mysql" 9 | _ "github.com/golang-migrate/migrate/v4/source/file" 10 | _ "github.com/golang-migrate/migrate/v4/source/github" 11 | log "github.com/sirupsen/logrus" 12 | "os" 13 | "time" 14 | ) 15 | 16 | var dbConnString = os.Getenv("DB_CONNECTION_STRING") 17 | 18 | const migrationVer uint = 11 19 | 20 | var db *sql.DB 21 | var hystrixDb = "db" 22 | var campStmt *sql.Stmt 23 | var addCampStmt *sql.Stmt 24 | var getUserTagsStmt *sql.Stmt 25 | var getUserExperienceLevelStmt *sql.Stmt 26 | 27 | func openDatabaseConnection() (*sql.DB, error) { 28 | conn, err := sql.Open("mysql", dbConnString+"?charset=utf8mb4,utf8") 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | conn.SetConnMaxLifetime(time.Minute * 3) 34 | conn.SetMaxOpenConns(20) 35 | conn.SetMaxIdleConns(20) 36 | 37 | return conn, nil 38 | } 39 | 40 | func newMigrate() (*migrate.Migrate, error) { 41 | con, err := openDatabaseConnection() 42 | if err != nil { 43 | log.Fatal("failed to open sql ", err) 44 | } 45 | driver, err := mysql.WithInstance(con, &mysql.Config{}) 46 | if err != nil { 47 | log.Fatal("failed to get driver ", err) 48 | } 49 | return migrate.NewWithDatabaseInstance( 50 | getEnv("MIGRATIONS_SOURCE", "file://migrations"), 51 | "mysql", driver) 52 | } 53 | 54 | func migrateDatabase() { 55 | log.Info("migrating database") 56 | m, err := newMigrate() 57 | if err != nil { 58 | log.Fatal("failed to connect ", err) 59 | } 60 | defer m.Close() 61 | 62 | err = m.Migrate(migrationVer) 63 | if err != nil && err.Error() != "no change" { 64 | log.Fatal("failed to migrate ", err) 65 | } 66 | } 67 | 68 | func dropDatabase() { 69 | log.Info("dropping database") 70 | m, err := newMigrate() 71 | if err != nil { 72 | log.Fatal("failed to connect ", err) 73 | } 74 | defer m.Close() 75 | 76 | err = m.Drop() 77 | if err != nil && err.Error() != "no change" { 78 | log.Fatal("failed to drop ", err) 79 | } 80 | } 81 | 82 | func initializeDatabase() { 83 | var err error 84 | db, err = openDatabaseConnection() 85 | if err != nil { 86 | log.Fatal("failed to open sql ", err) 87 | } 88 | 89 | campStmt, err = db.Prepare(` 90 | select id, 91 | title, 92 | url, 93 | image, 94 | ratio, 95 | placeholder, 96 | source, 97 | company, 98 | probability, 99 | fallback, 100 | geo, 101 | tag_relevant_ads.ad_id is not null as is_tag_targeted, 102 | exp_relevant_ads.ad_id is not null as is_exp_targeted 103 | from ads 104 | left join (select ad_id, max(relevant) as relevant 105 | from (select ad_id, 106 | exists (select user_id 107 | from user_tags 108 | where user_tags.tag = ad_tags.tag 109 | and user_tags.user_id = ?) as relevant 110 | from ad_tags) as res 111 | group by ad_id) tag_relevant_ads on ads.id = tag_relevant_ads.ad_id 112 | left join (select ad_id, max(relevant) as relevant 113 | from (select ad_id, 114 | exists (select user_id 115 | from user_experience_levels 116 | where user_experience_levels.experience_level = ad_experience_level.experience_level 117 | and user_experience_levels.user_id = ?) as relevant 118 | from ad_experience_level) as res 119 | group by ad_id) exp_relevant_ads on ads.id = exp_relevant_ads.ad_id 120 | where start <= ? and end > ? and 121 | ( 122 | (tag_relevant_ads.relevant = 1 and exp_relevant_ads.relevant = 1) 123 | or 124 | (tag_relevant_ads.relevant is null and exp_relevant_ads.relevant is null) 125 | or 126 | (tag_relevant_ads.relevant = 1 and exp_relevant_ads.relevant is null) 127 | or 128 | (tag_relevant_ads.relevant is null and exp_relevant_ads.relevant = 1) 129 | )`) 130 | if err != nil { 131 | log.Fatal("failed to prepare query ", err) 132 | } 133 | 134 | addCampStmt, err = db.Prepare( 135 | "insert into `ads` " + 136 | "(`id`, `title`, `url`, `image`, `ratio`, `placeholder`, `source`, " + 137 | "`company`, `probability`, `fallback`, `geo`, `start`, `end`) " + 138 | "values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") 139 | if err != nil { 140 | log.Fatal("failed to prepare query ", err) 141 | } 142 | 143 | getUserTagsStmt, err = db.Prepare("select tag from user_tags where user_id = ? order by last_read desc limit 50") 144 | if err != nil { 145 | log.Fatal("failed to prepare query ", err) 146 | } 147 | 148 | getUserExperienceLevelStmt, err = db.Prepare("select experience_level from user_experience_levels where user_id = ?") 149 | if err != nil { 150 | log.Fatal("failed to prepare query ", err) 151 | } 152 | } 153 | 154 | func tearDatabase() { 155 | addCampStmt.Close() 156 | campStmt.Close() 157 | getUserTagsStmt.Close() 158 | getUserExperienceLevelStmt.Close() 159 | 160 | db.Close() 161 | } 162 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | mysql: 4 | image: mariadb 5 | ports: 6 | - "3306:3306" 7 | environment: 8 | MYSQL_DATABASE: test 9 | MYSQL_ROOT_PASSWORD: 12345 10 | -------------------------------------------------------------------------------- /ethicalads.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | ) 9 | 10 | type EthicalAdsAd struct { 11 | Ad 12 | Pixel []string 13 | ReferralLink string 14 | } 15 | 16 | type EthicalAdsResponse struct { 17 | Id string 18 | Body string 19 | Image string 20 | Link string 21 | ViewUrl string `json:"view_url"` 22 | Nonce string 23 | } 24 | 25 | var hystrixEa = "EthicalAds" 26 | var ethicaladsToken = os.Getenv("ETHICALADS_TOKEN") 27 | 28 | var fetchEthicalAds = func(r *http.Request, keywords []string) (*EthicalAdsAd, error) { 29 | keywordsString := "" 30 | for i, keyword := range keywords { 31 | if i > 0 { 32 | keywordsString += ", " 33 | } 34 | keywordsString += fmt.Sprintf("\"%s\"", keyword) 35 | } 36 | ip := getIpAddress(r) 37 | ua := r.UserAgent() 38 | var body = []byte(`{ "publisher": "dailydev", "placements": [{ "div_id": "ad-div-1", "ad_type": "image-v1" }], "campaign_types": ["paid"], "user_ip": "` + ip + `", "user_ua": "` + ua + `", "keywords": [` + keywordsString + `] }`) 39 | var res EthicalAdsResponse 40 | req, _ := http.NewRequest("POST", "https://server.ethicalads.io/api/v1/decision/", bytes.NewBuffer(body)) 41 | req.Header.Set("User-Agent", "daily.dev ad server") 42 | req.Header.Set("Content-Type", "application/json") 43 | req.Header.Set("Authorization", "Token "+ethicaladsToken) 44 | req = req.WithContext(r.Context()) 45 | err := getJsonHystrix(hystrixEa, req, &res, true) 46 | if err != nil { 47 | return nil, err 48 | } 49 | if res.Body == "" { 50 | return nil, nil 51 | } 52 | 53 | ad := EthicalAdsAd{} 54 | ad.Company = "EthicalAds" 55 | ad.Description = res.Body 56 | ad.Link = res.Link 57 | ad.Source = "EthicalAds" 58 | ad.Pixel = []string{res.ViewUrl} 59 | ad.Image = res.Image 60 | ad.ReferralLink = "https://www.ethicalads.io/?ref=dailydev" 61 | ad.ProviderId = "ethical" 62 | 63 | return &ad, nil 64 | } 65 | -------------------------------------------------------------------------------- /geolocation.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/ip2location/ip2location-go" 5 | "strings" 6 | ) 7 | 8 | func openGeolocationDatabase() { 9 | ip2location.Open("./ip2location/IP2LOCATION-LITE-DB1.BIN") 10 | } 11 | 12 | func closeGeolocationDatabase() { 13 | ip2location.Close() 14 | } 15 | 16 | var getCountryByIP = func(ip string) string { 17 | return strings.ToLower(ip2location.Get_all(ip).Country_long) 18 | } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dailydotdev/daily-monetization 2 | 3 | go 1.23 4 | 5 | require ( 6 | cloud.google.com/go/pubsub v1.34.0 7 | contrib.go.opencensus.io/exporter/stackdriver v0.13.14 8 | github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 9 | github.com/dailydotdev/platform-go-common v0.2.40 10 | github.com/go-sql-driver/mysql v1.6.0 11 | github.com/golang-migrate/migrate/v4 v4.16.2 12 | github.com/ip2location/ip2location-go v8.2.0+incompatible 13 | github.com/sirupsen/logrus v1.9.2 14 | github.com/stretchr/testify v1.9.0 15 | go.opencensus.io v0.24.0 16 | go.uber.org/automaxprocs v1.5.2 17 | google.golang.org/api v0.160.0 18 | ) 19 | 20 | require ( 21 | cloud.google.com/go v0.112.0 // indirect 22 | cloud.google.com/go/compute v1.23.3 // indirect 23 | cloud.google.com/go/compute/metadata v0.2.3 // indirect 24 | cloud.google.com/go/iam v1.1.5 // indirect 25 | cloud.google.com/go/monitoring v1.17.0 // indirect 26 | cloud.google.com/go/trace v1.10.4 // indirect 27 | github.com/aws/aws-sdk-go v1.44.299 // indirect 28 | github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect 29 | github.com/davecgh/go-spew v1.1.1 // indirect 30 | github.com/deckarep/golang-set/v2 v2.3.0 // indirect 31 | github.com/felixge/httpsnoop v1.0.4 // indirect 32 | github.com/go-logr/logr v1.4.1 // indirect 33 | github.com/go-logr/stdr v1.2.2 // indirect 34 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 35 | github.com/golang/protobuf v1.5.4 // indirect 36 | github.com/google/go-github/v39 v39.2.0 // indirect 37 | github.com/google/go-querystring v1.1.0 // indirect 38 | github.com/google/s2a-go v0.1.7 // indirect 39 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect 40 | github.com/googleapis/gax-go/v2 v2.12.0 // indirect 41 | github.com/hashicorp/errwrap v1.1.0 // indirect 42 | github.com/hashicorp/go-multierror v1.1.1 // indirect 43 | github.com/imdario/mergo v0.3.16 // indirect 44 | github.com/jmespath/go-jmespath v0.4.0 // indirect 45 | github.com/kr/pretty v0.2.1 // indirect 46 | github.com/pmezard/go-difflib v1.0.0 // indirect 47 | github.com/prometheus/prometheus v0.45.0 // indirect 48 | github.com/smartystreets/goconvey v1.6.4 // indirect 49 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0 // indirect 50 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0 // indirect 51 | go.opentelemetry.io/otel v1.26.0 // indirect 52 | go.opentelemetry.io/otel/metric v1.26.0 // indirect 53 | go.opentelemetry.io/otel/trace v1.26.0 // indirect 54 | go.uber.org/atomic v1.11.0 // indirect 55 | golang.org/x/crypto v0.21.0 // indirect 56 | golang.org/x/exp v0.0.0-20231127185646-65229373498e // indirect 57 | golang.org/x/net v0.22.0 // indirect 58 | golang.org/x/oauth2 v0.16.0 // indirect 59 | golang.org/x/sync v0.7.0 // indirect 60 | golang.org/x/sys v0.19.0 // indirect 61 | golang.org/x/text v0.14.0 // indirect 62 | golang.org/x/time v0.5.0 // indirect 63 | google.golang.org/appengine v1.6.8 // indirect 64 | google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 // indirect 65 | google.golang.org/genproto/googleapis/api v0.0.0-20240314234333-6e1732d8331c // indirect 66 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c // indirect 67 | google.golang.org/grpc v1.62.1 // indirect 68 | google.golang.org/protobuf v1.33.0 // indirect 69 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 70 | gopkg.in/yaml.v3 v3.0.1 // indirect 71 | ) 72 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.112.0 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM= 3 | cloud.google.com/go v0.112.0/go.mod h1:3jEEVwZ/MHU4djK5t5RHuKOA/GbLddgTdVubX1qnPD4= 4 | cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= 5 | cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= 6 | cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= 7 | cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= 8 | cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI= 9 | cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8= 10 | cloud.google.com/go/kms v1.15.5 h1:pj1sRfut2eRbD9pFRjNnPNg/CzJPuQAzUujMIM1vVeM= 11 | cloud.google.com/go/kms v1.15.5/go.mod h1:cU2H5jnp6G2TDpUGZyqTCoy1n16fbubHZjmVXSMtwDI= 12 | cloud.google.com/go/monitoring v1.17.0 h1:blrdvF0MkPPivSO041ihul7rFMhXdVp8Uq7F59DKXTU= 13 | cloud.google.com/go/monitoring v1.17.0/go.mod h1:KwSsX5+8PnXv5NJnICZzW2R8pWTis8ypC4zmdRD63Tw= 14 | cloud.google.com/go/pubsub v1.34.0 h1:ZtPbfwfi5rLaPeSvDC29fFoE20/tQvGrUS6kVJZJvkU= 15 | cloud.google.com/go/pubsub v1.34.0/go.mod h1:alj4l4rBg+N3YTFDDC+/YyFTs6JAjam2QfYsddcAW4c= 16 | cloud.google.com/go/trace v1.10.4 h1:2qOAuAzNezwW3QN+t41BtkDJOG42HywL73q8x/f6fnM= 17 | cloud.google.com/go/trace v1.10.4/go.mod h1:Nso99EDIK8Mj5/zmB+iGr9dosS/bzWCJ8wGmE6TXNWY= 18 | contrib.go.opencensus.io/exporter/stackdriver v0.13.14 h1:zBakwHardp9Jcb8sQHcHpXy/0+JIb1M8KjigCJzx7+4= 19 | contrib.go.opencensus.io/exporter/stackdriver v0.13.14/go.mod h1:5pSSGY0Bhuk7waTHuDf4aQ8D2DrhgETRo9fy6k3Xlzc= 20 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= 21 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 22 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 23 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 24 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 25 | github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 h1:rFw4nCn9iMW+Vajsk51NtYIcwSTkXr+JGrMd36kTDJw= 26 | github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= 27 | github.com/aws/aws-sdk-go v1.44.299 h1:HVD9lU4CAFHGxleMJp95FV/sRhtg7P4miHD1v88JAQk= 28 | github.com/aws/aws-sdk-go v1.44.299/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= 29 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 30 | github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= 31 | github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= 32 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 33 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 34 | github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa h1:jQCWAUqqlij9Pgj2i/PB79y4KOPYVyFYdROxgaCwdTQ= 35 | github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa/go.mod h1:x/1Gn8zydmfq8dk6e9PdstVsDgu9RuyIIJqAaF//0IM= 36 | github.com/dailydotdev/platform-go-common v0.2.40 h1:VvEEUcP7EAc81QCIdRmBB/fE+ZhYiQ072eaqmzc0PUI= 37 | github.com/dailydotdev/platform-go-common v0.2.40/go.mod h1:rUCnmCopV7jbZLiMnuBUJ4EB+YC0x9I5ffHEZeFVdro= 38 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 39 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 40 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 41 | github.com/deckarep/golang-set/v2 v2.3.0 h1:qs18EKUfHm2X9fA50Mr/M5hccg2tNnVqsiBImnyDs0g= 42 | github.com/deckarep/golang-set/v2 v2.3.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= 43 | github.com/dhui/dktest v0.3.16 h1:i6gq2YQEtcrjKbeJpBkWjE8MmLZPYllcjOFbTZuPDnw= 44 | github.com/dhui/dktest v0.3.16/go.mod h1:gYaA3LRmM8Z4vJl2MA0THIigJoZrwOansEOsp+kqxp0= 45 | github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= 46 | github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 47 | github.com/docker/docker v24.0.2+incompatible h1:eATx+oLz9WdNVkQrr0qjQ8HvRJ4bOOxfzEo8R+dA3cg= 48 | github.com/docker/docker v24.0.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 49 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 50 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 51 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 52 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 53 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 54 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 55 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 56 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 57 | github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A= 58 | github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= 59 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 60 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 61 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 62 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 63 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 64 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 65 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 66 | github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= 67 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 68 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 69 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 70 | github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA= 71 | github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o= 72 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 73 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 74 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 75 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 76 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 77 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 78 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 79 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 80 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 81 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 82 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 83 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 84 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 85 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 86 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 87 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 88 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 89 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 90 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 91 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 92 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 93 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 94 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 95 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 96 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 97 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 98 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 99 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 100 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 101 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 102 | github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ= 103 | github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= 104 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 105 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 106 | github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= 107 | github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= 108 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 109 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= 110 | github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= 111 | github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= 112 | github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= 113 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 114 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 115 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 116 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 117 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 118 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 119 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 120 | github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= 121 | github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= 122 | github.com/ip2location/ip2location-go v8.2.0+incompatible h1:JPZVNMZgrxpA1JchRUBSBDBaGVtWU/dDoZgyjBLf3cg= 123 | github.com/ip2location/ip2location-go v8.2.0+incompatible/go.mod h1:3JUY1TBjTx1GdA7oRT7Zeqfc0bg3lMMuU5lXmzdpuME= 124 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 125 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 126 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 127 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 128 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 129 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 130 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 131 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 132 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 133 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 134 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 135 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 136 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 137 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 138 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 139 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 140 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 141 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 142 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 143 | github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= 144 | github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= 145 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 146 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 147 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 148 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 149 | github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= 150 | github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= 151 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 152 | github.com/prometheus/prometheus v0.45.0 h1:O/uG+Nw4kNxx/jDPxmjsSDd+9Ohql6E7ZSY1x5x/0KI= 153 | github.com/prometheus/prometheus v0.45.0/go.mod h1:jC5hyO8ItJBnDWGecbEucMyXjzxGv9cxsxsjS9u5s1w= 154 | github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y= 155 | github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 156 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 157 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 158 | github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= 159 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 160 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 161 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 162 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 163 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 164 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 165 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 166 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 167 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 168 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 169 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 170 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= 171 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 172 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0 h1:UNQQKPfTDe1J81ViolILjTKPr9WetKW6uei2hFgJmFs= 173 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0/go.mod h1:r9vWsPS/3AQItv3OSlEJ/E4mbrhUbbw18meOjArPtKQ= 174 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0 h1:sv9kVfal0MK0wBMCOGr+HeJm9v803BkJxGrk2au7j08= 175 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0/go.mod h1:SK2UL73Zy1quvRPonmOmRDiWk1KBV3LyIeeIxcEApWw= 176 | go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= 177 | go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= 178 | go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= 179 | go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= 180 | go.opentelemetry.io/otel/sdk v1.26.0 h1:Y7bumHf5tAiDlRYFmGqetNcLaVUZmh4iYfmGxtmz7F8= 181 | go.opentelemetry.io/otel/sdk v1.26.0/go.mod h1:0p8MXpqLeJ0pzcszQQN4F0S5FVjBLgypeGSngLsmirs= 182 | go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= 183 | go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= 184 | go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 185 | go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 186 | go.uber.org/automaxprocs v1.5.2 h1:2LxUOGiR3O6tw8ui5sZa2LAaHnsviZdVOUZw4fvbnME= 187 | go.uber.org/automaxprocs v1.5.2/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0= 188 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 189 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 190 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 191 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 192 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 193 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 194 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 195 | golang.org/x/exp v0.0.0-20231127185646-65229373498e h1:Gvh4YaCaXNs6dKTlfgismwWZKyjVZXwOPfIyUaqU3No= 196 | golang.org/x/exp v0.0.0-20231127185646-65229373498e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= 197 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 198 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 199 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 200 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 201 | golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= 202 | golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 203 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 204 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 205 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 206 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 207 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 208 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 209 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 210 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 211 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 212 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 213 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 214 | golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= 215 | golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 216 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 217 | golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= 218 | golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= 219 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 220 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 221 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 222 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 223 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 224 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 225 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 226 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 227 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 228 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 229 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 230 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 231 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 232 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 233 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 234 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 235 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= 236 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 237 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 238 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 239 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 240 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 241 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 242 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 243 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 244 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 245 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 246 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 247 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 248 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 249 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 250 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 251 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 252 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 253 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 254 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 255 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 256 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 257 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 258 | golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= 259 | golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= 260 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 261 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 262 | google.golang.org/api v0.160.0 h1:SEspjXHVqE1m5a1fRy8JFB+5jSu+V0GEDKDghF3ttO4= 263 | google.golang.org/api v0.160.0/go.mod h1:0mu0TpK33qnydLvWqbImq2b1eQ5FHRSDCBzAxX9ZHyw= 264 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 265 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 266 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 267 | google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 268 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 269 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 270 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 271 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 272 | google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ= 273 | google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80/go.mod h1:cc8bqMqtv9gMOr0zHg2Vzff5ULhhL2IXP4sbcn32Dro= 274 | google.golang.org/genproto/googleapis/api v0.0.0-20240314234333-6e1732d8331c h1:kaI7oewGK5YnVwj+Y+EJBO/YN1ht8iTL9XkFHtVZLsc= 275 | google.golang.org/genproto/googleapis/api v0.0.0-20240314234333-6e1732d8331c/go.mod h1:VQW3tUculP/D4B+xVCo+VgSq8As6wA9ZjHl//pmk+6s= 276 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c h1:lfpJ/2rWPa/kJgxyyXM8PrNnfCzcmxJ265mADgwmvLI= 277 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= 278 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 279 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 280 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 281 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 282 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 283 | google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= 284 | google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= 285 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 286 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 287 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 288 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 289 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 290 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 291 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 292 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 293 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 294 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 295 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 296 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 297 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 298 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 299 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 300 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 301 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 302 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 303 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 304 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 305 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 306 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 307 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 308 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 309 | -------------------------------------------------------------------------------- /health_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestHealthCheck(t *testing.T) { 11 | req, err := http.NewRequest("GET", "/health", nil) 12 | if err != nil { 13 | t.Fatal(err) 14 | } 15 | 16 | rr := httptest.NewRecorder() 17 | 18 | router := createApp() 19 | router.ServeHTTP(rr, req) 20 | 21 | assert.Equal(t, rr.Code, http.StatusOK, "wrong status code") 22 | } 23 | -------------------------------------------------------------------------------- /ip2location/IP2LOCATION-LITE-DB1.BIN: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dailydotdev/daily-monetization/dd6da76d2212709acf4f85c9de498d234c469c7c/ip2location/IP2LOCATION-LITE-DB1.BIN -------------------------------------------------------------------------------- /ip2location/README_LITE.TXT: -------------------------------------------------------------------------------- 1 | ------ 2 | README 3 | ------ 4 | 5 | IP2Location LITE - http://lite.ip2location.com 6 | 7 | Copyright (c) 2001-2018 Hexasoft Development Sdn. Bhd. 8 | All Rights Reserved. 9 | 10 | 1. IP2Location LITE Edition is free package with accuracy up to Class C (192.168.1.X) only. 11 | 12 | 2. IP2Location provides commercial packages with accuracy up to all IP addresses in Class C (192.168.1.1). You can get the commercial editions from http://www.ip2location.com. 13 | 14 | 3. Licensees may copy, distribute, display and perform the work and make derivative works based on IP2Location LITE only if they give the IP2LOCATION.COM credits in the manner specified below. 15 | 16 | All sites, advertising materials and documentation mentioning features or use of this database must display the following acknowledgment: 17 | 18 | "This site or product includes IP2Location LITE data available from http://www.ip2location.com." 19 | 20 | IP2Location is a registered trademark of Hexasoft Development Sdn Bhd. All other trademarks are the property of their respective owners. 21 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "math/rand" 8 | "net/http" 9 | "os" 10 | "regexp" 11 | "strings" 12 | "time" 13 | 14 | "cloud.google.com/go/pubsub" 15 | "contrib.go.opencensus.io/exporter/stackdriver" 16 | "contrib.go.opencensus.io/exporter/stackdriver/propagation" 17 | "github.com/afex/hystrix-go/hystrix" 18 | log "github.com/sirupsen/logrus" 19 | "go.opencensus.io/plugin/ochttp" 20 | "go.opencensus.io/trace" 21 | _ "go.uber.org/automaxprocs" 22 | "google.golang.org/api/option" 23 | 24 | "github.com/dailydotdev/platform-go-common/util" 25 | ) 26 | 27 | var gcpOpts []option.ClientOption 28 | var segmentToId = map[string]string{ 29 | "python": "CW7D52QL", 30 | "design-tools": "CW7DEK3M", 31 | } 32 | var pubsubClient *pubsub.Client = nil 33 | 34 | var pythonTags = []string{"django", "fastapi", "flask", "jupyter", "keras", "matplotlib", "numpy", "pandas", "pip", "plotly", "pyspark", "python", "pytorch", "scikit", "selenium", "tensorflow"} 35 | var designToolsTags = []string{ 36 | "design-patterns", 37 | "design-tools", 38 | "design-systems", 39 | "self-hosting", 40 | "ui-ux", 41 | "accessibility", 42 | "figma", 43 | "data-visualization", 44 | "ecommerce", 45 | } 46 | 47 | func hasIntersection(a, b []string) bool { 48 | seen := make(map[string]struct{}) 49 | for _, v := range a { 50 | seen[v] = struct{}{} 51 | } 52 | 53 | for _, v := range b { 54 | if _, exists := seen[v]; exists { 55 | return true 56 | } 57 | } 58 | 59 | return false 60 | } 61 | 62 | func tagsToSegments(tags []string) string { 63 | if hasIntersection(pythonTags, tags) { 64 | return "python" 65 | } 66 | 67 | if hasIntersection(designToolsTags, tags) { 68 | return "design-tools" 69 | } 70 | 71 | return "" 72 | } 73 | 74 | func getBsaAd(r *http.Request, country string, tags []string, active bool) (*BsaAd, error) { 75 | var bsa *BsaAd 76 | var err error 77 | 78 | segment := tagsToSegments(tags) 79 | propertyId, exists := segmentToId[segment] 80 | if exists { 81 | bsa, err = fetchBsa(r, propertyId) 82 | } else if active { 83 | bsa, err = fetchBsa(r, "CEAIP23E") 84 | } else if country == "united states" { 85 | bsa, err = fetchBsa(r, "CK7DT2QM") 86 | } else if country == "united kingdom" { 87 | bsa, err = fetchBsa(r, "CEAD62QI") 88 | } else { 89 | bsa, err = fetchBsa(r, "CK7DT2QM") 90 | } 91 | if err != nil { 92 | log.Warn("failed to fetch ad from BSA ", err) 93 | } 94 | return bsa, err 95 | } 96 | 97 | func ServeAd(w http.ResponseWriter, r *http.Request) { 98 | var err error 99 | var res []interface{} 100 | 101 | ip := getIpAddress(r) 102 | country := getCountryByIP(ip) 103 | active := r.URL.Query().Get("active") == "true" 104 | var userId string 105 | cookie, _ := r.Cookie("da2") 106 | if cookie != nil { 107 | userId = cookie.Value 108 | } 109 | 110 | camps := make([]CampaignAd, 0) 111 | 112 | if res == nil { 113 | camps, err = fetchCampaigns(r.Context(), time.Now(), userId) 114 | if err != nil { 115 | log.Warn("failed to fetch campaigns ", err) 116 | } 117 | 118 | // Look for a campaign ad based on probability 119 | prob := rand.Float32() 120 | for i := 0; i < len(camps); i++ { 121 | if !camps[i].Fallback && (len(camps[i].Geo) == 0 || strings.Contains(camps[i].Geo, country)) { 122 | if prob <= camps[i].Probability { 123 | res = []interface{}{camps[i]} 124 | break 125 | } 126 | prob -= camps[i].Probability 127 | } 128 | } 129 | } 130 | 131 | // Premium self-serve 132 | if res == nil { 133 | bsa, err := fetchBsa(r, "CEBI62JM") 134 | if err != nil { 135 | log.Warn("failed to fetch ad from premium self-serve ", err) 136 | } else if bsa != nil { 137 | bsa.ProviderId = "premium" 138 | res = []interface{}{*bsa} 139 | } 140 | } 141 | 142 | tags, err := getUserTags(r.Context(), userId) 143 | if err != nil { 144 | log.Warnln("getUserTags", err) 145 | } 146 | 147 | if res == nil { 148 | bsa, _ := getBsaAd(r, country, tags, active) 149 | if bsa != nil { 150 | res = []interface{}{*bsa} 151 | } 152 | } 153 | if res == nil { 154 | cf, err := fetchEthicalAds(r, tags) 155 | if err != nil { 156 | log.Warn("failed to fetch ad from EthicalAds ", err) 157 | } else if cf != nil { 158 | res = []interface{}{*cf} 159 | } 160 | } 161 | 162 | // Standard self-serve 163 | if res == nil { 164 | bsa, err := fetchBsa(r, "CEBI62J7") 165 | if err != nil { 166 | log.Warn("failed to fetch ad from standard self-serve ", err) 167 | } else if bsa != nil { 168 | bsa.ProviderId = "standard" 169 | res = []interface{}{*bsa} 170 | } 171 | } 172 | 173 | if res == nil { 174 | // Look for a fallback campaign ad based on probability 175 | prob := rand.Float32() 176 | for i := 0; i < len(camps); i++ { 177 | if camps[i].Fallback && (len(camps[i].Geo) == 0 || strings.Contains(country, camps[i].Geo)) { 178 | if prob <= camps[i].Probability { 179 | res = []interface{}{camps[i]} 180 | break 181 | } 182 | prob -= camps[i].Probability 183 | } 184 | } 185 | } 186 | 187 | if res == nil { 188 | log.Info("no ads to serve for extension") 189 | res = []interface{}{} 190 | } 191 | 192 | js, err := marshalJSON(res) 193 | if err != nil { 194 | log.Error("failed to marshal json ", err) 195 | http.Error(w, "Server Internal Error", http.StatusInternalServerError) 196 | return 197 | } 198 | 199 | w.Header().Set("Content-Type", "application/json") 200 | _, _ = w.Write(js) 201 | } 202 | 203 | func ServePostAd(w http.ResponseWriter, r *http.Request) { 204 | var err error 205 | var res []interface{} 206 | 207 | bsa, _ := fetchBsa(r, "CW7D623L") 208 | if bsa != nil { 209 | res = []interface{}{*bsa} 210 | } 211 | 212 | if res == nil { 213 | log.Info("no ads to serve for post page") 214 | res = []interface{}{} 215 | } 216 | 217 | js, err := marshalJSON(res) 218 | if err != nil { 219 | log.Error("failed to marshal json ", err) 220 | http.Error(w, "Server Internal Error", http.StatusInternalServerError) 221 | return 222 | } 223 | 224 | w.Header().Set("Content-Type", "application/json") 225 | _, _ = w.Write(js) 226 | } 227 | 228 | func ServeToilet(w http.ResponseWriter, r *http.Request) { 229 | var res []interface{} 230 | 231 | bsa, err := fetchBsa(r, "CK7DT2QM") 232 | if err != nil { 233 | log.Warn("failed to fetch ad from BSA ", err) 234 | } else if bsa != nil { 235 | res = []interface{}{*bsa} 236 | } 237 | 238 | if res == nil { 239 | log.Info("no ads to serve for toilet") 240 | res = []interface{}{} 241 | } 242 | 243 | js, err := marshalJSON(res) 244 | if err != nil { 245 | log.Error("failed to marshal json ", err) 246 | http.Error(w, "Server Internal Error", http.StatusInternalServerError) 247 | return 248 | } 249 | 250 | w.Header().Set("Content-Type", "application/json") 251 | _, _ = w.Write(js) 252 | } 253 | 254 | func ServeBsa(w http.ResponseWriter, r *http.Request) { 255 | res, err := sendBsaRequest(r, "CK7DT2QM") 256 | if err != nil { 257 | log.Warn("failed to fetch ad from BSA ", err) 258 | http.Error(w, "Server Internal Error", http.StatusInternalServerError) 259 | return 260 | } 261 | 262 | js, err := marshalJSON(res.Ads) 263 | if err != nil { 264 | log.Error("failed to marshal json ", err) 265 | http.Error(w, "Server Internal Error", http.StatusInternalServerError) 266 | return 267 | } 268 | 269 | w.Header().Set("Content-Type", "application/json") 270 | _, _ = w.Write(js) 271 | } 272 | 273 | type PubSubMessage struct { 274 | Message struct { 275 | Data []byte `json:"data,omitempty"` 276 | ID string `json:"id"` 277 | } `json:"message"` 278 | Subscription string `json:"subscription"` 279 | } 280 | 281 | type HealthHandler struct{} 282 | type AdsHandler struct{} 283 | type App struct { 284 | HealthHandler *HealthHandler 285 | AdsHandler *AdsHandler 286 | } 287 | 288 | func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { 289 | var head string 290 | head, r.URL.Path = shiftPath(r.URL.Path) 291 | 292 | switch head { 293 | case "health": 294 | h.HealthHandler.ServeHTTP(w, r) 295 | return 296 | case "a": 297 | h.AdsHandler.ServeHTTP(w, r) 298 | return 299 | case "v1": 300 | head, r.URL.Path = shiftPath(r.URL.Path) 301 | if head == "a" { 302 | h.AdsHandler.ServeHTTP(w, r) 303 | } 304 | return 305 | } 306 | 307 | http.Error(w, "Not Found", http.StatusNotFound) 308 | } 309 | 310 | var re = regexp.MustCompile(`^(?:https:\/\/)?(?:[\w-]+\.)*daily\.dev$`) 311 | 312 | func (h *AdsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 313 | origin := r.Header.Get("Origin") 314 | w.Header().Set("Vary", "Origin,Access-Control-Request-Headers") 315 | 316 | if re.MatchString(origin) { 317 | w.Header().Set("Access-Control-Allow-Origin", origin) 318 | w.Header().Set("Access-Control-Allow-Credentials", "true") 319 | } 320 | 321 | if r.Method == "OPTIONS" { 322 | w.Header().Set("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE") 323 | w.Header().Set("Cache-Control", "max-age=86400") 324 | w.Header().Set("Access-Control-Max-Age", "86400") 325 | 326 | accessHeaders := r.Header.Get("Access-Control-Request-Headers") 327 | 328 | if accessHeaders != "" { 329 | w.Header().Set("Access-Control-Allow-Headers", accessHeaders) 330 | } 331 | 332 | w.WriteHeader(http.StatusNoContent) 333 | 334 | return 335 | } 336 | 337 | if r.Method == "GET" { 338 | if r.URL.Path == "/" { 339 | ServeAd(w, r) 340 | return 341 | } 342 | 343 | if r.URL.Path == "/post" { 344 | ServePostAd(w, r) 345 | return 346 | } 347 | 348 | if r.URL.Path == "/toilet" { 349 | ServeToilet(w, r) 350 | return 351 | } 352 | 353 | _, tail := shiftPath(r.URL.Path) 354 | if tail == "/" { 355 | ServeBsa(w, r) 356 | return 357 | } 358 | } 359 | 360 | http.Error(w, "Not Found", http.StatusNotFound) 361 | } 362 | 363 | func NewAd(ctx context.Context, log *log.Entry, ad ScheduledCampaignAd) error { 364 | log.Infof("[AD %s] adding new campaign ad", ad.Id) 365 | if err := addCampaign(ctx, ad); err != nil { 366 | log.WithField("ad", ad).Errorf("[AD %s] failed to add new campaign ad %v", ad.Id, err) 367 | return err 368 | } 369 | 370 | log.Infof("[AD %s] added new campaign ad", ad.Id) 371 | return nil 372 | } 373 | 374 | type ViewMessage struct { 375 | UserId string 376 | Tags []string 377 | } 378 | 379 | func View(ctx context.Context, log *log.Entry, data ViewMessage) error { 380 | if len(data.Tags) > 0 { 381 | if err := addOrUpdateUserTags(ctx, data.UserId, data.Tags); err != nil { 382 | log.WithField("view", data).Errorf("addOrUpdateUserTags %v", err) 383 | return err 384 | } 385 | } 386 | return nil 387 | } 388 | 389 | type user struct { 390 | Id string `json:"id"` 391 | ExperienceLevel string `json:"experienceLevel"` 392 | } 393 | 394 | type UserCreatedMessage struct { 395 | User user `json:"user"` 396 | } 397 | 398 | type UserUpdatedMessage struct { 399 | NewProfile user `json:"newProfile"` 400 | } 401 | 402 | var allowedExperienceLevels = []string{ 403 | "LESS_THAN_1_YEAR", 404 | "MORE_THAN_1_YEAR", 405 | "MORE_THAN_2_YEARS", 406 | "MORE_THAN_4_YEARS", 407 | "NOT_ENGINEER", 408 | "MORE_THAN_10_YEARS", 409 | "MORE_THAN_6_YEARS", 410 | } 411 | 412 | func CreateUserExperienceLevel(ctx context.Context, log *log.Entry, data UserCreatedMessage) error { 413 | if data.User.ExperienceLevel != "" && util.Contains[string](allowedExperienceLevels, data.User.ExperienceLevel) { 414 | if err := setOrUpdateExperienceLevel(ctx, data.User.Id, data.User.ExperienceLevel); err != nil { 415 | log.WithField("experience", data).Errorf("setOrUpdateExperienceLevel %v", err) 416 | return err 417 | } 418 | } 419 | return nil 420 | } 421 | 422 | func UpdateUserExperienceLevel(ctx context.Context, log *log.Entry, data UserUpdatedMessage) error { 423 | if data.NewProfile.ExperienceLevel != "" && util.Contains[string](allowedExperienceLevels, data.NewProfile.ExperienceLevel) { 424 | if err := setOrUpdateExperienceLevel(ctx, data.NewProfile.Id, data.NewProfile.ExperienceLevel); err != nil { 425 | log.WithField("experience", data).Errorf("setOrUpdateExperienceLevel %v", err) 426 | return err 427 | } 428 | } 429 | return nil 430 | } 431 | 432 | type UserDeletedMessage struct { 433 | UserId string `json:"id"` 434 | } 435 | 436 | func DeleteUserExperienceLevel(ctx context.Context, log *log.Entry, data UserDeletedMessage) error { 437 | if data.UserId != "" { 438 | if err := deleteUserExperienceLevel(ctx, data.UserId); err != nil { 439 | log.WithField("user_deleted", data).Errorf("deleteUserExperienceLevel %v", err) 440 | return err 441 | } 442 | } 443 | return nil 444 | } 445 | 446 | func DeleteOldTags(ctx context.Context, log *log.Entry) error { 447 | if err := deleteOldTags(ctx); err != nil { 448 | log.Errorf("deleteOldTags %v", err) 449 | return err 450 | } 451 | return nil 452 | } 453 | 454 | func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 455 | if r.URL.Path == "/" && r.Method == "GET" { 456 | fmt.Fprintf(w, "OK") 457 | return 458 | } 459 | 460 | http.Error(w, "Not Found", http.StatusNotFound) 461 | } 462 | 463 | func createApp() *App { 464 | return &App{ 465 | HealthHandler: new(HealthHandler), 466 | AdsHandler: new(AdsHandler), 467 | } 468 | } 469 | 470 | func subscribeToNewAd() { 471 | const sub = "monetization-new-ad" 472 | log.Info("receiving messages from ", sub) 473 | ctx := context.Background() 474 | err := pubsubClient.Subscription(sub).Receive(ctx, func(ctx context.Context, msg *pubsub.Message) { 475 | childLog := log.WithField("messageId", msg.ID) 476 | var data ScheduledCampaignAd 477 | if err := json.Unmarshal(msg.Data, &data); err != nil { 478 | childLog.Errorf("failed to decode message %v", err) 479 | msg.Ack() 480 | return 481 | } 482 | 483 | if err := NewAd(ctx, childLog, data); err != nil { 484 | msg.Nack() 485 | } else { 486 | msg.Ack() 487 | } 488 | }) 489 | 490 | if err != nil { 491 | log.Fatal("failed to receive messages from pubsub ", err) 492 | } 493 | } 494 | 495 | func subscribeToView() { 496 | const sub = "monetization-views" 497 | log.Info("receiving messages from ", sub) 498 | ctx := context.Background() 499 | err := pubsubClient.Subscription(sub).Receive(ctx, func(ctx context.Context, msg *pubsub.Message) { 500 | childLog := log.WithField("messageId", msg.ID) 501 | var data ViewMessage 502 | if err := json.Unmarshal(msg.Data, &data); err != nil { 503 | childLog.Errorf("failed to decode message %v", err) 504 | msg.Ack() 505 | return 506 | } 507 | 508 | if err := View(ctx, childLog, data); err != nil { 509 | msg.Nack() 510 | } else { 511 | msg.Ack() 512 | } 513 | }) 514 | 515 | if err != nil { 516 | log.Fatal("failed to receive messages from pubsub ", err) 517 | } 518 | } 519 | 520 | func subscribeToUserCreated() { 521 | const sub = "monetization-user-created" 522 | log.Info("receiving messages from ", sub) 523 | ctx := context.Background() 524 | err := pubsubClient.Subscription(sub).Receive(ctx, func(ctx context.Context, msg *pubsub.Message) { 525 | childLog := log.WithField("messageId", msg.ID) 526 | var data UserCreatedMessage 527 | if err := json.Unmarshal(msg.Data, &data); err != nil { 528 | childLog.Errorf("failed to decode message %v", err) 529 | msg.Ack() 530 | return 531 | } 532 | 533 | if err := CreateUserExperienceLevel(ctx, childLog, data); err != nil { 534 | msg.Nack() 535 | } else { 536 | msg.Ack() 537 | } 538 | }) 539 | 540 | if err != nil { 541 | log.Fatal("failed to receive messages from pubsub ", err) 542 | } 543 | } 544 | 545 | func subscribeToUserUpdated() { 546 | const sub = "monetization-user-updated" 547 | log.Info("receiving messages from ", sub) 548 | ctx := context.Background() 549 | err := pubsubClient.Subscription(sub).Receive(ctx, func(ctx context.Context, msg *pubsub.Message) { 550 | childLog := log.WithField("messageId", msg.ID) 551 | var data UserUpdatedMessage 552 | if err := json.Unmarshal(msg.Data, &data); err != nil { 553 | childLog.Errorf("failed to decode message %v", err) 554 | msg.Ack() 555 | return 556 | } 557 | 558 | if err := UpdateUserExperienceLevel(ctx, childLog, data); err != nil { 559 | msg.Nack() 560 | } else { 561 | msg.Ack() 562 | } 563 | }) 564 | 565 | if err != nil { 566 | log.Fatal("failed to receive messages from pubsub ", err) 567 | } 568 | } 569 | 570 | func subscribeToUserDeleted() { 571 | const sub = "monetization-user-deleted" 572 | log.Info("receiving messages from ", sub) 573 | ctx := context.Background() 574 | err := pubsubClient.Subscription(sub).Receive(ctx, func(ctx context.Context, msg *pubsub.Message) { 575 | childLog := log.WithField("messageId", msg.ID) 576 | var data UserDeletedMessage 577 | if err := json.Unmarshal(msg.Data, &data); err != nil { 578 | childLog.Errorf("failed to decode message %v", err) 579 | msg.Ack() 580 | return 581 | } 582 | 583 | if err := DeleteUserExperienceLevel(ctx, childLog, data); err != nil { 584 | msg.Nack() 585 | } else { 586 | msg.Ack() 587 | } 588 | }) 589 | 590 | if err != nil { 591 | log.Fatal("failed to receive messages from pubsub ", err) 592 | } 593 | } 594 | 595 | func subscribeToDeleteOldTags() { 596 | const sub = "monetization-delete-old-tags" 597 | log.Info("receiving messages from ", sub) 598 | ctx := context.Background() 599 | err := pubsubClient.Subscription(sub).Receive(ctx, func(ctx context.Context, msg *pubsub.Message) { 600 | childLog := log.WithField("messageId", msg.ID) 601 | if err := DeleteOldTags(ctx, childLog); err != nil { 602 | msg.Nack() 603 | } else { 604 | msg.Ack() 605 | } 606 | }) 607 | 608 | if err != nil { 609 | log.Fatal("failed to receive messages from pubsub ", err) 610 | } 611 | } 612 | 613 | func createBackgroundApp() { 614 | go subscribeToNewAd() 615 | go subscribeToView() 616 | go subscribeToUserCreated() 617 | go subscribeToUserUpdated() 618 | go subscribeToUserDeleted() 619 | subscribeToDeleteOldTags() 620 | } 621 | 622 | func init() { 623 | hystrix.ConfigureCommand(hystrixDb, hystrix.CommandConfig{Timeout: 300, MaxConcurrentRequests: 1000, SleepWindow: 1000, RequestVolumeThreshold: 100}) 624 | hystrix.ConfigureCommand(hystrixBsa, hystrix.CommandConfig{Timeout: 700, MaxConcurrentRequests: 1000, SleepWindow: 1000, RequestVolumeThreshold: 100}) 625 | hystrix.ConfigureCommand(hystrixEa, hystrix.CommandConfig{Timeout: 700, MaxConcurrentRequests: 1000, SleepWindow: 1000, RequestVolumeThreshold: 100}) 626 | 627 | if file, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS"); ok { 628 | gcpOpts = append(gcpOpts, option.WithCredentialsFile(file)) 629 | } 630 | 631 | projectID := os.Getenv("GCLOUD_PROJECT") 632 | ctx := context.Background() 633 | 634 | log.SetOutput(os.Stdout) 635 | if getEnv("ENV", "DEV") == "PROD" { 636 | log.SetFormatter(&log.JSONFormatter{}) 637 | 638 | exporter, err := stackdriver.NewExporter(stackdriver.Options{ 639 | ProjectID: projectID, 640 | TraceClientOptions: gcpOpts, 641 | }) 642 | if err != nil { 643 | log.Fatal(err) 644 | } 645 | trace.RegisterExporter(exporter) 646 | trace.ApplyConfig(trace.Config{DefaultSampler: trace.ProbabilitySampler(0.25)}) 647 | 648 | httpClient = &http.Client{ 649 | Transport: &ochttp.Transport{ 650 | // Use Google Cloud propagation format. 651 | Propagation: &propagation.HTTPFormat{}, 652 | }, 653 | } 654 | } else { 655 | httpClient = &http.Client{} 656 | } 657 | 658 | var err error 659 | pubsubClient, err = pubsub.NewClient(ctx, projectID, gcpOpts...) 660 | if err != nil { 661 | log.Fatal(err) 662 | } 663 | } 664 | 665 | func main() { 666 | if len(os.Args) > 1 && os.Args[1] == "migrate" { 667 | migrateDatabase() 668 | } else { 669 | openGeolocationDatabase() 670 | defer closeGeolocationDatabase() 671 | 672 | initializeDatabase() 673 | defer tearDatabase() 674 | 675 | if len(os.Args) > 1 && os.Args[1] == "background" { 676 | log.Info("background processing is on") 677 | createBackgroundApp() 678 | } else { 679 | app := createApp() 680 | addr := fmt.Sprintf(":%s", getEnv("PORT", "9090")) 681 | log.Info("server is listening to ", addr) 682 | err := http.ListenAndServe(addr, &ochttp.Handler{Handler: app, Propagation: &propagation.HTTPFormat{}}) // set listen addr 683 | if err != nil { 684 | log.Fatal("failed to start listening ", err) 685 | } 686 | } 687 | } 688 | } 689 | -------------------------------------------------------------------------------- /migrations/10_ad_experience_level.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE `ad_experience_level`; 2 | -------------------------------------------------------------------------------- /migrations/10_ad_experience_level.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `ad_experience_level` ( 2 | `ad_id` varchar(255) NOT NULL REFERENCES ads(id), 3 | `experience_level` varchar(255) CHARACTER SET utf8mb4 NOT NULL, 4 | PRIMARY KEY (`ad_id`, `experience_level`), 5 | KEY `ad_experience_level_ad_id_index` (`ad_id`) 6 | ); -------------------------------------------------------------------------------- /migrations/11_user_experience_levels.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE `user_experience_levels`; 2 | -------------------------------------------------------------------------------- /migrations/11_user_experience_levels.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `user_experience_levels` ( 2 | `user_id` varchar(255) NOT NULL, 3 | `experience_level` varchar(255) CHARACTER SET utf8mb4 NOT NULL, 4 | `d_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | PRIMARY KEY (`user_id`), 6 | KEY `user_experience_levels_user_id_index` (`user_id`), 7 | KEY `user_experience_levels_experience_level_index` (`experience_level`), 8 | KEY `user_experience_levels_d_update_index` (`d_update`) 9 | ); -------------------------------------------------------------------------------- /migrations/1_initialize.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE `ads`; 2 | -------------------------------------------------------------------------------- /migrations/1_initialize.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `ads` ( 2 | `id` varchar(255) NOT NULL, 3 | `title` varchar(255) CHARACTER SET utf8mb4 NOT NULL, 4 | `url` text NOT NULL, 5 | `image` text DEFAULT NULL, 6 | `ratio` float(8,2) DEFAULT NULL, 7 | `placeholder` text, 8 | `source` varchar(255) DEFAULT NULL, 9 | `start` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | `end` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 11 | PRIMARY KEY (`id`), 12 | KEY `ads_start_end_index` (`start`,`end`) 13 | ); 14 | -------------------------------------------------------------------------------- /migrations/2_probability.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `ads` DROP COLUMN `probability`, `fallback`, `company`; 2 | -------------------------------------------------------------------------------- /migrations/2_probability.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `ads` 2 | ADD COLUMN `probability` FLOAT(8,2) DEFAULT 0.0, 3 | ADD COLUMN `fallback` TINYINT DEFAULT 0, 4 | ADD COLUMN `company` varchar(255) CHARACTER SET utf8mb4; 5 | -------------------------------------------------------------------------------- /migrations/3_geo.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `ads` DROP COLUMN `geo`; 2 | -------------------------------------------------------------------------------- /migrations/3_geo.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `ads` 2 | ADD COLUMN `geo` text CHARACTER SET utf8mb4; 3 | -------------------------------------------------------------------------------- /migrations/4_segment.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE `segments`; 2 | -------------------------------------------------------------------------------- /migrations/4_segment.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `segments` ( 2 | `user_id` varchar(255) NOT NULL, 3 | `segment` varchar(255) NOT NULL, 4 | PRIMARY KEY (`user_id`), 5 | KEY `segment_index` (`segment`) 6 | ); 7 | -------------------------------------------------------------------------------- /migrations/5_ad_segment.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `ads` DROP COLUMN `segment`; 2 | -------------------------------------------------------------------------------- /migrations/5_ad_segment.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `ads` 2 | ADD COLUMN `segment` text CHARACTER SET utf8mb4; 3 | -------------------------------------------------------------------------------- /migrations/6_user_tag.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE `user_tags`; 2 | -------------------------------------------------------------------------------- /migrations/6_user_tag.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `user_tags` ( 2 | `user_id` varchar(255) NOT NULL, 3 | `tag` varchar(255) CHARACTER SET utf8mb4 NOT NULL, 4 | `last_read` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | PRIMARY KEY (`user_id`, `tag`), 6 | KEY `user_tags_user_id_index` (`user_id`), 7 | KEY `user_tags_tag_index` (`tag`), 8 | KEY `user_tags_last_read_index` (`last_read`) 9 | ); -------------------------------------------------------------------------------- /migrations/7_ad_tags.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE `ad_tags`; 2 | -------------------------------------------------------------------------------- /migrations/7_ad_tags.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `ad_tags` ( 2 | `ad_id` varchar(255) NOT NULL REFERENCES ads(id), 3 | `tag` varchar(255) CHARACTER SET utf8mb4 NOT NULL, 4 | PRIMARY KEY (`ad_id`, `tag`), 5 | KEY `ad_tags_ad_id_index` (`ad_id`) 6 | ); -------------------------------------------------------------------------------- /migrations/8_ad_goal.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `ads` 2 | DROP COLUMN `goal`, 3 | DROP COLUMN `price`; 4 | -------------------------------------------------------------------------------- /migrations/8_ad_goal.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `ads` 2 | ADD COLUMN `goal` INT UNSIGNED, 3 | ADD COLUMN `price` FLOAT UNSIGNED; 4 | -------------------------------------------------------------------------------- /migrations/9_ad_budget.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `ads` 2 | DROP COLUMN `budget`; 3 | -------------------------------------------------------------------------------- /migrations/9_ad_budget.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `ads` 2 | ADD COLUMN `budget` FLOAT UNSIGNED; 3 | -------------------------------------------------------------------------------- /serve_ad_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | var ad = Ad{ 16 | Source: "source", 17 | Image: "image", 18 | Link: "http://link.com", 19 | Description: "desc", 20 | Company: "company", 21 | } 22 | 23 | var campaignNotAvailable = func(ctx context.Context, timestamp time.Time, userId string) ([]CampaignAd, error) { 24 | return nil, nil 25 | } 26 | 27 | var bsaNotAvailable = func(r *http.Request, propertyId string) (*BsaAd, error) { 28 | return nil, nil 29 | } 30 | 31 | var ethicalNotAvailable = func(r *http.Request, keywords []string) (*EthicalAdsAd, error) { 32 | return nil, nil 33 | } 34 | 35 | var emptyUserTags = func(ctx context.Context, userId string) ([]string, error) { 36 | return []string{}, nil 37 | } 38 | 39 | var originalGetUserTags = getUserTags 40 | 41 | func TestFallbackCampaignAvailable(t *testing.T) { 42 | exp := []CampaignAd{ 43 | { 44 | Ad: ad, 45 | Placeholder: "placholder", 46 | Ratio: 0.5, 47 | Id: "id", 48 | Fallback: true, 49 | Probability: 1, 50 | }, 51 | } 52 | 53 | fetchEthicalAds = ethicalNotAvailable 54 | fetchBsa = bsaNotAvailable 55 | getUserTags = emptyUserTags 56 | fetchCampaigns = func(ctx context.Context, timestamp time.Time, userId string) ([]CampaignAd, error) { 57 | return exp, nil 58 | } 59 | 60 | req, err := http.NewRequest("GET", "/a", nil) 61 | assert.Nil(t, err) 62 | 63 | rr := httptest.NewRecorder() 64 | 65 | router := createApp() 66 | router.ServeHTTP(rr, req) 67 | 68 | assert.Equal(t, http.StatusOK, rr.Code, "wrong status code") 69 | 70 | var actual []CampaignAd 71 | assert.NoError(t, json.NewDecoder(rr.Body).Decode(&actual)) 72 | assert.Equal(t, []CampaignAd{ 73 | { 74 | Ad: ad, 75 | Placeholder: "placholder", 76 | Ratio: 0.5, 77 | Id: "id", 78 | Fallback: false, 79 | }, 80 | }, actual, "wrong body") 81 | getUserTags = originalGetUserTags 82 | } 83 | 84 | func TestFallbackCampaignNotAvailable(t *testing.T) { 85 | fetchEthicalAds = ethicalNotAvailable 86 | fetchBsa = bsaNotAvailable 87 | fetchCampaigns = campaignNotAvailable 88 | 89 | req, err := http.NewRequest("GET", "/a", nil) 90 | assert.Nil(t, err) 91 | 92 | rr := httptest.NewRecorder() 93 | 94 | router := createApp() 95 | router.ServeHTTP(rr, req) 96 | 97 | assert.Equal(t, http.StatusOK, rr.Code, "wrong status code") 98 | 99 | var actual []interface{} 100 | assert.NoError(t, json.NewDecoder(rr.Body).Decode(&actual)) 101 | assert.Equal(t, []interface{}{}, actual, "wrong body") 102 | } 103 | 104 | func TestCampaignFail(t *testing.T) { 105 | fetchEthicalAds = ethicalNotAvailable 106 | fetchBsa = bsaNotAvailable 107 | 108 | fetchCampaigns = func(ctx context.Context, timestamp time.Time, userId string) ([]CampaignAd, error) { 109 | return nil, errors.New("error") 110 | } 111 | 112 | req, err := http.NewRequest("GET", "/a", nil) 113 | assert.Nil(t, err) 114 | 115 | rr := httptest.NewRecorder() 116 | 117 | router := createApp() 118 | router.ServeHTTP(rr, req) 119 | 120 | assert.Equal(t, http.StatusOK, rr.Code, "wrong status code") 121 | 122 | var actual []interface{} 123 | assert.NoError(t, json.NewDecoder(rr.Body).Decode(&actual)) 124 | assert.Equal(t, []interface{}{}, actual, "wrong body") 125 | } 126 | 127 | func TestCampaignAvailable(t *testing.T) { 128 | exp := []CampaignAd{ 129 | { 130 | Ad: ad, 131 | Placeholder: "placholder", 132 | Ratio: 0.5, 133 | Id: "id", 134 | Fallback: false, 135 | Probability: 1, 136 | }, 137 | } 138 | 139 | fetchBsa = bsaNotAvailable 140 | fetchEthicalAds = ethicalNotAvailable 141 | fetchCampaigns = func(ctx context.Context, timestamp time.Time, userId string) ([]CampaignAd, error) { 142 | return exp, nil 143 | } 144 | 145 | req, err := http.NewRequest("GET", "/a", nil) 146 | assert.Nil(t, err) 147 | 148 | rr := httptest.NewRecorder() 149 | 150 | router := createApp() 151 | router.ServeHTTP(rr, req) 152 | 153 | assert.Equal(t, http.StatusOK, rr.Code, "wrong status code") 154 | 155 | var actual []CampaignAd 156 | assert.NoError(t, json.NewDecoder(rr.Body).Decode(&actual)) 157 | assert.Equal(t, []CampaignAd{ 158 | { 159 | Ad: ad, 160 | Placeholder: "placholder", 161 | Ratio: 0.5, 162 | Id: "id", 163 | }, 164 | }, actual, "wrong body") 165 | } 166 | 167 | func TestCampaignAvailableByGeo(t *testing.T) { 168 | exp := []CampaignAd{ 169 | { 170 | Ad: ad, 171 | Placeholder: "placholder", 172 | Ratio: 0.5, 173 | Id: "id", 174 | Fallback: false, 175 | Probability: 1, 176 | Geo: "united states,israel,germany", 177 | }, 178 | } 179 | 180 | getCountryByIP = func(ip string) string { 181 | return "united states" 182 | } 183 | fetchBsa = bsaNotAvailable 184 | fetchEthicalAds = ethicalNotAvailable 185 | fetchCampaigns = func(ctx context.Context, timestamp time.Time, userId string) ([]CampaignAd, error) { 186 | return exp, nil 187 | } 188 | 189 | req, err := http.NewRequest("GET", "/a", nil) 190 | assert.Nil(t, err) 191 | 192 | rr := httptest.NewRecorder() 193 | 194 | router := createApp() 195 | router.ServeHTTP(rr, req) 196 | 197 | assert.Equal(t, http.StatusOK, rr.Code, "wrong status code") 198 | 199 | var actual []CampaignAd 200 | assert.NoError(t, json.NewDecoder(rr.Body).Decode(&actual)) 201 | assert.Equal(t, []CampaignAd{ 202 | { 203 | Ad: ad, 204 | Placeholder: "placholder", 205 | Ratio: 0.5, 206 | Id: "id", 207 | }, 208 | }, actual, "wrong body") 209 | } 210 | 211 | func TestBsaAvailable(t *testing.T) { 212 | fetchEthicalAds = ethicalNotAvailable 213 | exp := []BsaAd{ 214 | { 215 | Ad: ad, 216 | Pixel: []string{"pixel"}, 217 | ReferralLink: "https://referral.com", 218 | }, 219 | } 220 | 221 | fetchCampaigns = campaignNotAvailable 222 | fetchBsa = func(r *http.Request, propertyId string) (*BsaAd, error) { 223 | return &exp[0], nil 224 | } 225 | 226 | req, err := http.NewRequest("GET", "/a", nil) 227 | assert.Nil(t, err) 228 | 229 | rr := httptest.NewRecorder() 230 | 231 | router := createApp() 232 | router.ServeHTTP(rr, req) 233 | 234 | assert.Equal(t, http.StatusOK, rr.Code, "wrong status code") 235 | 236 | var actual []BsaAd 237 | assert.NoError(t, json.NewDecoder(rr.Body).Decode(&actual)) 238 | assert.Equal(t, exp, actual, "wrong body") 239 | } 240 | 241 | func TestBsaFail(t *testing.T) { 242 | fetchEthicalAds = ethicalNotAvailable 243 | exp := []CampaignAd{ 244 | { 245 | Ad: ad, 246 | Placeholder: "placholder", 247 | Ratio: 0.5, 248 | Id: "id", 249 | Fallback: true, 250 | Probability: 1, 251 | }, 252 | } 253 | 254 | fetchBsa = func(r *http.Request, propertyId string) (*BsaAd, error) { 255 | return nil, errors.New("error") 256 | } 257 | 258 | fetchCampaigns = func(ctx context.Context, timestamp time.Time, userId string) ([]CampaignAd, error) { 259 | return exp, nil 260 | } 261 | 262 | req, err := http.NewRequest("GET", "/a", nil) 263 | assert.Nil(t, err) 264 | 265 | rr := httptest.NewRecorder() 266 | 267 | router := createApp() 268 | router.ServeHTTP(rr, req) 269 | 270 | assert.Equal(t, http.StatusOK, rr.Code, "wrong status code") 271 | 272 | var actual []CampaignAd 273 | assert.NoError(t, json.NewDecoder(rr.Body).Decode(&actual)) 274 | assert.Equal(t, []CampaignAd{ 275 | { 276 | Ad: ad, 277 | Placeholder: "placholder", 278 | Ratio: 0.5, 279 | Id: "id", 280 | }, 281 | }, actual, "wrong body") 282 | } 283 | -------------------------------------------------------------------------------- /serve_toilet_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestToiletBsaAvailable(t *testing.T) { 14 | exp := []BsaAd{ 15 | { 16 | Ad: ad, 17 | Pixel: []string{"pixel"}, 18 | ReferralLink: "https://referral.com", 19 | }, 20 | } 21 | 22 | fetchBsa = func(r *http.Request, propertyId string) (*BsaAd, error) { 23 | return &exp[0], nil 24 | } 25 | 26 | req, err := http.NewRequest("GET", "/a/toilet", nil) 27 | assert.Nil(t, err) 28 | 29 | rr := httptest.NewRecorder() 30 | 31 | router := createApp() 32 | router.ServeHTTP(rr, req) 33 | 34 | assert.Equal(t, http.StatusOK, rr.Code, "wrong status code") 35 | 36 | var actual []BsaAd 37 | assert.NoError(t, json.NewDecoder(rr.Body).Decode(&actual)) 38 | assert.Equal(t, exp, actual, "wrong body") 39 | } 40 | 41 | func TestToiletBsaNotAvailable(t *testing.T) { 42 | fetchBsa = bsaNotAvailable 43 | 44 | req, err := http.NewRequest("GET", "/a/toilet", nil) 45 | assert.Nil(t, err) 46 | 47 | rr := httptest.NewRecorder() 48 | 49 | router := createApp() 50 | router.ServeHTTP(rr, req) 51 | 52 | assert.Equal(t, http.StatusOK, rr.Code, "wrong status code") 53 | 54 | var actual []interface{} 55 | assert.NoError(t, json.NewDecoder(rr.Body).Decode(&actual)) 56 | assert.Equal(t, []interface{}{}, actual, "wrong body") 57 | } 58 | 59 | func TestToiletBsaNotFail(t *testing.T) { 60 | fetchBsa = func(r *http.Request, propertyId string) (*BsaAd, error) { 61 | return nil, errors.New("error") 62 | } 63 | 64 | req, err := http.NewRequest("GET", "/a/toilet", nil) 65 | assert.Nil(t, err) 66 | 67 | rr := httptest.NewRecorder() 68 | 69 | router := createApp() 70 | router.ServeHTTP(rr, req) 71 | 72 | assert.Equal(t, http.StatusOK, rr.Code, "wrong status code") 73 | 74 | var actual []interface{} 75 | assert.NoError(t, json.NewDecoder(rr.Body).Decode(&actual)) 76 | assert.Equal(t, []interface{}{}, actual, "wrong body") 77 | } 78 | -------------------------------------------------------------------------------- /userExperienceLevels.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | 8 | "github.com/afex/hystrix-go/hystrix" 9 | ) 10 | 11 | func setOrUpdateExperienceLevel(ctx context.Context, userId string, experienceLevel string) error { 12 | return hystrix.DoC(ctx, hystrixDb, 13 | func(ctx context.Context) error { 14 | var query = "INSERT INTO user_experience_levels (user_id, experience_level) VALUES (?, ?) ON DUPLICATE KEY UPDATE experience_level=?, d_update=CURRENT_TIMESTAMP" 15 | _, err := db.ExecContext(ctx, query, userId, experienceLevel, experienceLevel) 16 | if err != nil { 17 | return err 18 | } 19 | return nil 20 | }, nil) 21 | } 22 | 23 | func deleteUserExperienceLevel(ctx context.Context, userId string) error { 24 | return hystrix.DoC(ctx, hystrixDb, 25 | func(ctx context.Context) error { 26 | _, err := db.ExecContext(ctx, "DELETE FROM user_experience_levels WHERE user_id = ?", userId) 27 | if err != nil { 28 | return err 29 | } 30 | return nil 31 | }, nil) 32 | } 33 | 34 | var getUserExperienceLevel = func(ctx context.Context, userId string) (string, error) { 35 | output := make(chan string, 1) 36 | errors := hystrix.GoC(ctx, hystrixDb, 37 | func(ctx context.Context) error { 38 | 39 | var experienceLevel string 40 | row := getUserExperienceLevelStmt.QueryRow(userId) 41 | switch err := row.Scan(&experienceLevel); { 42 | case errors.Is(err, sql.ErrNoRows): 43 | output <- "UNKNOWN" 44 | return nil 45 | case err == nil: 46 | output <- experienceLevel 47 | return nil 48 | default: 49 | return err 50 | } 51 | }, nil) 52 | select { 53 | case out := <-output: 54 | return out, nil 55 | case err := <-errors: 56 | return "UNKNOWN", err 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /userExperienceLevels_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestSetOrUpdateUserExperienceLevel(t *testing.T) { 11 | migrateDatabase() 12 | initializeDatabase() 13 | defer tearDatabase() 14 | defer dropDatabase() 15 | _, err := db.Exec("INSERT INTO user_experience_levels (user_id, experience_level) VALUES ('1', 'MORE_THAN_2_YEARS')") 16 | require.NoError(t, err) 17 | 18 | err = setOrUpdateExperienceLevel(context.Background(), "1", "MORE_THAN_6_YEARS") 19 | require.NoError(t, err) 20 | 21 | row := db.QueryRow("SELECT user_id, experience_level FROM user_experience_levels") 22 | require.NoError(t, err) 23 | var userId string 24 | var experienceLevel string 25 | 26 | err = row.Scan(&userId, &experienceLevel) 27 | require.NoError(t, err) 28 | require.Equal(t, "MORE_THAN_6_YEARS", experienceLevel) 29 | } 30 | 31 | func TestDeleteUserExperienceLevel(t *testing.T) { 32 | migrateDatabase() 33 | initializeDatabase() 34 | defer tearDatabase() 35 | defer dropDatabase() 36 | _, err := db.Exec("INSERT INTO user_experience_levels (user_id, experience_level) VALUES ('1', 'MORE_THAN_4_YEARS'), ('2', 'MORE_THAN_10_YEARS')") 37 | require.NoError(t, err) 38 | 39 | err = setOrUpdateExperienceLevel(context.Background(), "1", "MORE_THAN_10_YEARS") 40 | require.NoError(t, err) 41 | 42 | err = deleteUserExperienceLevel(context.Background(), "1") 43 | require.NoError(t, err) 44 | 45 | row := db.QueryRow("SELECT count(*) FROM user_experience_levels") 46 | require.NoError(t, err) 47 | var count int 48 | require.NoError(t, row.Scan(&count)) 49 | require.Equal(t, 1, count) 50 | } 51 | 52 | func TestGetUserExperienceLevel(t *testing.T) { 53 | migrateDatabase() 54 | initializeDatabase() 55 | defer tearDatabase() 56 | defer dropDatabase() 57 | _, err := db.Exec("INSERT INTO user_experience_levels (user_id, experience_level) VALUES ('1', 'MORE_THAN_4_YEARS'), ('2', 'MORE_THAN_10_YEARS')") 58 | require.NoError(t, err) 59 | 60 | { 61 | level, err := getUserExperienceLevel(context.Background(), "1") 62 | require.NoError(t, err) 63 | require.Equal(t, "MORE_THAN_4_YEARS", level) 64 | } 65 | 66 | { 67 | level, err := getUserExperienceLevel(context.Background(), "2") 68 | require.NoError(t, err) 69 | require.Equal(t, "MORE_THAN_10_YEARS", level) 70 | } 71 | 72 | { 73 | level, err := getUserExperienceLevel(context.Background(), "3") 74 | require.NoError(t, err) 75 | require.Equal(t, "UNKNOWN", level) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /userTags.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/afex/hystrix-go/hystrix" 6 | ) 7 | 8 | func addOrUpdateUserTags(ctx context.Context, userId string, tags []string) error { 9 | return hystrix.DoC(ctx, hystrixDb, 10 | func(ctx context.Context) error { 11 | var parameters []interface{} 12 | var query = "INSERT INTO user_tags (user_id, tag) VALUES " 13 | for i, tag := range tags { 14 | if i > 0 { 15 | query += ", " 16 | } 17 | query += "(?,?)" 18 | parameters = append(parameters, userId, tag) 19 | } 20 | query += "ON DUPLICATE KEY UPDATE last_read=CURRENT_TIMESTAMP" 21 | _, err := db.ExecContext(ctx, query, parameters...) 22 | if err != nil { 23 | return err 24 | } 25 | return nil 26 | }, nil) 27 | } 28 | 29 | func deleteOldTags(ctx context.Context) error { 30 | return hystrix.DoC(ctx, hystrixDb, 31 | func(ctx context.Context) error { 32 | _, err := db.ExecContext(ctx, "DELETE FROM user_tags WHERE last_read < now() - interval 6 month") 33 | if err != nil { 34 | return err 35 | } 36 | return nil 37 | }, nil) 38 | } 39 | 40 | var getUserTags = func(ctx context.Context, userId string) ([]string, error) { 41 | output := make(chan []string, 1) 42 | errors := hystrix.GoC(ctx, hystrixDb, 43 | func(ctx context.Context) error { 44 | rows, err := getUserTagsStmt.QueryContext(ctx, userId) 45 | if err != nil { 46 | return err 47 | } 48 | defer rows.Close() 49 | 50 | var res []string 51 | var tag string 52 | for rows.Next() { 53 | err = rows.Scan(&tag) 54 | if err != nil { 55 | return err 56 | } 57 | res = append(res, tag) 58 | } 59 | err = rows.Err() 60 | if err != nil { 61 | return err 62 | } 63 | 64 | output <- res 65 | return nil 66 | }, nil) 67 | select { 68 | case out := <-output: 69 | return out, nil 70 | case err := <-errors: 71 | return nil, err 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /userTags_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/stretchr/testify/assert" 6 | "sort" 7 | "testing" 8 | ) 9 | 10 | func TestAddOrUpdateUserTags(t *testing.T) { 11 | migrateDatabase() 12 | initializeDatabase() 13 | defer tearDatabase() 14 | defer dropDatabase() 15 | _, err := db.Exec("INSERT INTO user_tags (user_id, tag, last_read) VALUES ('1', 'webdev', '2021-09-12 08:54:07')") 16 | assert.Nil(t, err) 17 | 18 | rows, err := db.Query("SELECT last_read FROM user_tags WHERE tag = 'webdev' LIMIT 1") 19 | assert.Nil(t, err) 20 | defer rows.Close() 21 | rows.Next() 22 | var webdevLastRead string 23 | err = rows.Scan(&webdevLastRead) 24 | assert.Nil(t, err) 25 | 26 | err = addOrUpdateUserTags(context.Background(), "1", []string{"webdev", "javascript"}) 27 | assert.Nil(t, err) 28 | 29 | rows, err = db.Query("SELECT user_id, tag, last_read FROM user_tags ORDER BY tag") 30 | assert.Nil(t, err) 31 | defer rows.Close() 32 | 33 | var userId string 34 | var tag string 35 | var lastRead string 36 | var i = 0 37 | for rows.Next() { 38 | err := rows.Scan(&userId, &tag, &lastRead) 39 | assert.Nil(t, err) 40 | assert.Equal(t, "1", userId) 41 | if i == 0 { 42 | assert.Equal(t, "javascript", tag) 43 | } else if i == 1 { 44 | assert.Equal(t, "webdev", tag) 45 | assert.NotEqual(t, lastRead, webdevLastRead) 46 | } 47 | i++ 48 | } 49 | assert.Equal(t, 2, i) 50 | err = rows.Err() 51 | assert.Nil(t, err) 52 | } 53 | 54 | func TestDeleteOldUserTags(t *testing.T) { 55 | migrateDatabase() 56 | initializeDatabase() 57 | defer tearDatabase() 58 | defer dropDatabase() 59 | _, err := db.Exec("INSERT INTO user_tags (user_id, tag, last_read) VALUES ('1', 'webdev', '2021-01-12 08:54:07')") 60 | assert.Nil(t, err) 61 | 62 | err = addOrUpdateUserTags(context.Background(), "1", []string{"php", "javascript"}) 63 | assert.Nil(t, err) 64 | 65 | err = deleteOldTags(context.Background()) 66 | assert.Nil(t, err) 67 | 68 | rows, err := db.Query("SELECT count(*) FROM user_tags") 69 | assert.Nil(t, err) 70 | defer rows.Close() 71 | rows.Next() 72 | var count int 73 | assert.NoError(t, rows.Scan(&count)) 74 | assert.Equal(t, 2, count) 75 | } 76 | 77 | func TestGetUserTags(t *testing.T) { 78 | migrateDatabase() 79 | initializeDatabase() 80 | defer tearDatabase() 81 | defer dropDatabase() 82 | _, err := db.Exec("INSERT INTO user_tags (user_id, tag, last_read) VALUES ('1', 'webdev', '2021-01-12 08:54:07'), ('1', 'php', '2021-01-12 08:54:07'), ('2', 'webdev', '2021-01-12 08:54:07')") 83 | assert.Nil(t, err) 84 | 85 | tags, err := getUserTags(context.Background(), "1") 86 | assert.Nil(t, err) 87 | sort.Strings(tags) 88 | assert.Equal(t, []string{"php", "webdev"}, tags) 89 | } 90 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "github.com/afex/hystrix-go/hystrix" 7 | log "github.com/sirupsen/logrus" 8 | "io" 9 | "net/http" 10 | "os" 11 | "path" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "unicode" 16 | "unicode/utf8" 17 | ) 18 | 19 | var httpClient *http.Client 20 | 21 | func getEnv(key, fallback string) string { 22 | if value, ok := os.LookupEnv(key); ok { 23 | return value 24 | } 25 | return fallback 26 | } 27 | 28 | func getJson(req *http.Request, target interface{}) error { 29 | r, err := httpClient.Do(req) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | defer r.Body.Close() 35 | 36 | if r.StatusCode == http.StatusOK { 37 | return json.NewDecoder(r.Body).Decode(target) 38 | } else { 39 | bodyBytes, err := io.ReadAll(r.Body) 40 | if err != nil { 41 | log.Warn("failed to parse error", err) 42 | } 43 | bodyString := string(bodyBytes) 44 | return errors.New(strconv.Itoa(r.StatusCode) + ": " + bodyString) 45 | } 46 | } 47 | 48 | func getJsonHystrix(breakerName string, req *http.Request, target interface{}, ignoreNotFound bool) error { 49 | return hystrix.Do(breakerName, 50 | func() error { 51 | err := getJson(req, target) 52 | if ignoreNotFound && err != nil && err.Error() == "404" { 53 | return nil 54 | } 55 | return err 56 | }, nil) 57 | } 58 | 59 | // Regexp definitions 60 | var keyMatchRegex = regexp.MustCompile(`\"(\w+)\":`) 61 | 62 | func marshalJSON(v interface{}) ([]byte, error) { 63 | marshalled, err := json.Marshal(v) 64 | 65 | converted := keyMatchRegex.ReplaceAllFunc( 66 | marshalled, 67 | func(match []byte) []byte { 68 | // Empty keys are valid JSON, only lowercase if we do not have an 69 | // empty key. 70 | if len(match) > 2 { 71 | // Decode first rune after the double quotes 72 | r, width := utf8.DecodeRune(match[1:]) 73 | r = unicode.ToLower(r) 74 | utf8.EncodeRune(match[1:width+1], r) 75 | } 76 | return match 77 | }, 78 | ) 79 | 80 | return converted, err 81 | } 82 | 83 | // ShiftPath splits off the first component of p, which will be cleaned of 84 | // relative components before processing. head will never contain a slash and 85 | // tail will always be a rooted path without trailing slash. 86 | func shiftPath(p string) (head, tail string) { 87 | p = path.Clean("/" + p) 88 | i := strings.Index(p[1:], "/") + 1 89 | if i <= 0 { 90 | return p[1:], "/" 91 | } 92 | return p[1:i], p[i:] 93 | } 94 | 95 | func getIpAddress(r *http.Request) string { 96 | ip := r.Header.Get("x-forwarded-for") 97 | if len(ip) == 0 { 98 | return r.RemoteAddr 99 | } 100 | return strings.Split(ip, ",")[0] 101 | } 102 | --------------------------------------------------------------------------------