├── .git-blame-ignore-revs ├── .github ├── scala-steward.conf └── workflows │ ├── ci.yml │ └── docker-latest.yml ├── .gitignore ├── .scalafix.conf ├── .scalafmt.conf ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── Runner.Dockerfile ├── build.sbt ├── client ├── index.html ├── script.js └── style.css ├── data └── .gitignore ├── docker-compose.yml ├── lib └── h2-2.1.214.jar ├── project ├── build.properties └── plugins.sbt ├── samples ├── FilterChallenge.png ├── RainDropsCaptcha.gif ├── popping.gif └── shadowText.png ├── scripts └── simpleTest.py ├── src └── main │ ├── java │ ├── lc │ │ ├── captchas │ │ │ ├── FontFunCaptcha.java │ │ │ ├── PoppingCharactersCaptcha.java │ │ │ ├── ShadowTextCaptcha.java │ │ │ └── interfaces │ │ │ │ ├── Challenge.java │ │ │ │ └── ChallengeProvider.java │ │ └── misc │ │ │ ├── GifSequenceWriter.java │ │ │ ├── HelperFunctions.java │ │ │ └── PngImageWriter.java │ └── org │ │ └── limium │ │ └── picoserve │ │ └── Server.java │ ├── resources │ └── index.html │ └── scala │ └── lc │ ├── Main.scala │ ├── background │ └── taskThread.scala │ ├── captchas │ ├── DebugCaptcha.scala │ ├── FilterChallenge.scala │ ├── LabelCaptcha.scala │ └── RainDropsCaptcha.scala │ ├── core │ ├── captchaFields.scala │ ├── captchaManager.scala │ ├── captchaProviders.scala │ ├── config.scala │ └── models.scala │ ├── database │ ├── DB.scala │ └── statements.scala │ └── server │ └── Server.scala └── tests ├── debug-config.json ├── locustfile-functional.py ├── locustfile.py └── run.sh /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with sbt-java-formatter 0.8.0 2 | 57ce691a00babb03e0cae03a26fe56d63fc609af 3 | 4 | # Scala Steward: Reformat with scalafmt 3.6.1 5 | f2b19baca828a4d88b46bc009aef6d7115e63924 6 | 7 | # Scala Steward: Reformat with scalafmt 3.9.5 8 | ecfbaf459af23ed5981aa3b030425f67dece7f9e 9 | 10 | # Scala Steward: Reformat with scalafmt 3.9.7 11 | cb2021ce0648edfbf4bb45f2962562820c7b677b 12 | -------------------------------------------------------------------------------- /.github/scala-steward.conf: -------------------------------------------------------------------------------- 1 | # If true, Scala Steward will sign off all commits (e.g. `git --signoff`). 2 | # Default: false 3 | signoffCommits = true 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Core CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [ master ] 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up JDK 1.11 16 | uses: actions/setup-java@v1 17 | with: 18 | java-version: 1.11 19 | - uses: sbt/setup-sbt@v1 20 | - name: Run tests 21 | run: sbt test assembly 22 | #- name: Run linter 23 | #run: sbt "scalafixAll --check" 24 | - name: Run locust tests 25 | run: sudo apt-get install -y tesseract-ocr && ./tests/run.sh 26 | -------------------------------------------------------------------------------- /.github/workflows/docker-latest.yml: -------------------------------------------------------------------------------- 1 | name: Update docker image 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | tags: 7 | - 'v*' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | 14 | - name: Check Out Repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Set up JDK 18 | uses: actions/setup-java@v1 19 | with: 20 | java-version: 1.16 21 | 22 | - uses: sbt/setup-sbt@v1 23 | 24 | - name: Assemble Jar 25 | run: sbt assembly 26 | 27 | - 28 | name: Docker meta 29 | id: meta 30 | uses: docker/metadata-action@v3 31 | with: 32 | images: librecaptcha/lc-core 33 | tags: | 34 | type=ref,event=branch 35 | type=ref,event=pr 36 | type=semver,pattern={{version}} 37 | type=semver,pattern={{major}}.{{minor}} 38 | 39 | - name: Login to Docker Hub 40 | if: github.event_name != 'pull_request' 41 | uses: docker/login-action@v1 42 | with: 43 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 44 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 45 | 46 | - name: Set up Docker Buildx 47 | id: buildx 48 | uses: docker/setup-buildx-action@v1 49 | 50 | - name: Build and push 51 | id: docker_build 52 | uses: docker/build-push-action@v2 53 | with: 54 | context: ./ 55 | file: ./Runner.Dockerfile 56 | push: ${{ github.event_name != 'pull_request' }} 57 | tags: ${{ steps.meta.outputs.tags }} 58 | labels: ${{ steps.meta.outputs.labels }} 59 | platforms: | 60 | linux/amd64 61 | linux/arm64 62 | linux/arm/v7 63 | 64 | - name: Image digest 65 | run: echo ${{ steps.docker_build.outputs.digest }} 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.log 2 | /*.png 3 | /bin/ 4 | /project/** 5 | /target/ 6 | **__pycache__ 7 | .bloop 8 | .metals 9 | .vscode 10 | .bsp 11 | 12 | # for python test env 13 | /testEnv/ 14 | 15 | # for various captcha 16 | /known/ 17 | /unknown/ 18 | /lib/fonts/ 19 | 20 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules=[ 2 | DisableSyntax, 3 | LeakingImplicitClassVal, 4 | NoValInForComprehension, 5 | ] 6 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version="3.9.7" 2 | maxColumn = 120 3 | runner.dialect = scala3 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | jdk: openjdk11 3 | scala: 4 | - 2.13.2 5 | script: 6 | - sbt ++$TRAVIS_SCALA_VERSION compile 7 | - sbt "scalafixAll --check" 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eclipse-temurin:17-jre-jammy AS base-builder 2 | ARG SBT_VERSION=1.7.1 3 | ENV JAVA_HOME="/usr/lib/jvm/default-jvm/" 4 | ENV PATH=$PATH:${JAVA_HOME}/bin 5 | RUN \ 6 | apt update && \ 7 | apt install -y wget && \ 8 | wget -O sbt-$SBT_VERSION.tgz https://github.com/sbt/sbt/releases/download/v$SBT_VERSION/sbt-$SBT_VERSION.tgz && \ 9 | tar -xzvf sbt-$SBT_VERSION.tgz && \ 10 | rm sbt-$SBT_VERSION.tgz 11 | 12 | ENV PATH=$PATH:/sbt/bin/ 13 | 14 | 15 | FROM base-builder AS sbt-builder 16 | WORKDIR /build 17 | COPY lib/ lib/ 18 | COPY project/plugins.sbt project/ 19 | COPY build.sbt . 20 | RUN sbt assembly 21 | 22 | FROM sbt-builder as builder 23 | COPY src/ src/ 24 | RUN sbt assembly 25 | 26 | FROM eclipse-temurin:17-jre-jammy AS base-core 27 | ENV JAVA_HOME="/usr/lib/jvm/default-jvm/" 28 | RUN apt update && apt install -y fonts-dejavu 29 | ENV PATH=$PATH:${JAVA_HOME}/bin 30 | 31 | 32 | FROM base-core 33 | WORKDIR /lc-core 34 | COPY --from=builder /build/target/scala-3.6.4/LibreCaptcha.jar . 35 | RUN mkdir data/ 36 | 37 | EXPOSE 8888 38 | 39 | CMD [ "java", "-jar", "LibreCaptcha.jar" ] 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LibreCaptcha 2 | LibreCaptcha is a framework that allows developers to create their own [CAPTCHA](https://en.wikipedia.org/wiki/CAPTCHA)s. 3 | The framework defines the API for a CAPTCHA generator and takes care of mundane details 4 | such as: 5 | * An HTTP interface for serving CAPTCHAs 6 | * Background workers to pre-compute CAPTCHAs and to store them in a database 7 | * Managing secrets for the CAPTCHAs (tokens, expected answers, etc) 8 | * Safe re-impressions of CAPTCHA images (by creating unique tokens for every impression) 9 | * Garbage collection of stale CAPTCHAs 10 | * Sandboxed plugin architecture (TBD) 11 | 12 | Some sample CAPTCHA generators are included in the distribution (see below). We will continue adding more samples to the list. For quick 13 | deployments the samples themselves might be sufficient. Projects with more resources might want create their own CAPTCHAs 14 | and use the samples as inspiration. See the [CAPTCHA creation guide](https://github.com/librecaptcha/lc-core/wiki/Creating-your-own-CAPTCHA-provider). 15 | 16 | ## Current Status 17 | The framework is stable, but since it is our first public release, we recommend using it only on small to medium scale 18 | web apps. 19 | 20 | The sample CAPTCHAs are also just that, samples. They have not been tested against bots or CAPTCHA crackers yet. 21 | 22 | ## Quick start with Java 23 | 24 | 1. Download the `jar` file from the latest release 25 | 2. Type `mkdir data/`. 26 | (The data directory is used to store a config file that you can tweak, and for storing the Database) 27 | 3. Type `java -jar LibreCaptcha.jar` 28 | 4. Open [localhost:8888/demo/index.html](http://localhost:8888/demo/index.html) in browser 29 | 30 | We recommend a Java 11+ runtime as that's what we compile the code with. 31 | 32 | Alternatively, 33 | 1. Install [sbt](https://www.scala-sbt.org/) 34 | 2. Clone this repository 35 | 3. Type `sbt run` within the repository 36 | 4. Open [localhost:8888/demo/index.html](http://localhost:8888/demo/index.html) in browser 37 | 38 | 39 | ## Quick start with Docker 40 | Using `docker-compose`: 41 | 42 | ``` 43 | git clone https://github.com/librecaptcha/lc-core.git 44 | docker-compose up 45 | ``` 46 | 47 | Using `docker`: 48 | 49 | ``` 50 | docker run -p=8888:8888 -v ./lcdata:/lc-core/data librecaptcha/lc-core:2.0 51 | ``` 52 | 53 | A default `config.json` is automatically created in the mounted volume. 54 | 55 | The above commands should work with `podman` as well, if docker.io registry is pre-configured. Otherwise, 56 | you can manually specify the repository like so: 57 | 58 | ``` 59 | podman run -p=8888:8888 -v ./lcdata:/lc-core/data docker.io/librecaptcha/lc-core:2.0 60 | ``` 61 | 62 | ## Quick test 63 | Open [localhost:8888/demo/index.html](http://localhost:8888/demo/index.html) in browser. 64 | 65 | Alternatively, on the command line, try: 66 | 67 | ``` 68 | > $ curl -d '{"media":"image/png","level":"easy","input_type":"text","size":"350x100"}' localhost:8888/v2/captcha 69 | {"id":"3bf928ce-a1e7-4616-b34f-8252d777855d"} 70 | 71 | > $ curl "localhost:8888/v1/media?id=3bf928ce-a1e7-4616-b34f-8252d777855d" -o sample.png 72 | 73 | > $ file sample.png 74 | sample.png: PNG image data, 350 x 100, 8-bit/color RGB, non-interlaced 75 | ``` 76 | 77 | The API endpoints are described at the end of this file. 78 | 79 | ## Configuration 80 | If a `config.json` file is not present in the `data/` folder, the app creates one, and this can be modified 81 | to customize the app features, such as which CAPTCHAs are enabled and their difficulty settings. 82 | 83 | More details can be found [in the wiki](https://github.com/librecaptcha/lc-core/wiki/Configuration) 84 | 85 | ## Why LibreCaptcha? 86 | 87 | ### Eliminate dependency on a third-party 88 | An open-source CAPTCHA framework will allow anyone to host their own CAPTCHA service and thus avoid dependencies on 89 | third-parties. 90 | 91 | ### Respecting user privacy 92 | A self-hosted service prevents user information from leaking to other parties. 93 | 94 | ### More variety of CAPTCHAs 95 | Ain't it boring to identify photos of buses, store-fronts and traffic signals? With LibreCaptcha, developers can 96 | create CAPTCHAs that suit their application and audience, with matching themes and looks. 97 | 98 | And, the more the variety of CAPTCHAS, the harder it is for bots to crack CAPTCHAs. 99 | 100 | ## Sample CAPTCHAs 101 | These are included in this server. 102 | 103 | ### ShadowText 104 | ![ShadowText Sample](./samples/shadowText.png) 105 | 106 | 107 | ### FilterCaptcha 108 | 109 | ![FilterCaptcha Sample](./samples/FilterChallenge.png) 110 | 111 | An image of a random string of alphabets is created. Then a series of image filters that add effects such as Smear, Diffuse, and Ripple are applied to the image to make it less readable. 112 | 113 | ### RainDropsCaptcha 114 | ![RaindDrops Sample](./samples/RainDropsCaptcha.gif) 115 | 116 | ### PoppingCharactersCaptcha 117 | ![PoppingCharacters Sample](./samples/popping.gif) 118 | 119 | ### LabelCaptcha 120 | This CAPTCHA provider takes in two sets of images. One with known labels, and the other unknown. 121 | The created image has a pair of words one from each set. 122 | The user is tested on the known word, and their answer to the unknown word is recorded. 123 | If a sufficient number of users agree on their answer to the unknown word, it is transferred to the list of known words. 124 | 125 | (There is a known issue with this provider; see issue #68 ) 126 | 127 | *** 128 | 129 | ## HTTP API 130 | 131 | The service can be accessed using a simple HTTP API. 132 | 133 | ### - `/v1/captcha`: `POST` 134 | - Parameters: 135 | - `level`: `String` - 136 | The difficulty level of a captcha 137 | - easy 138 | - medium 139 | - hard 140 | - `input_type`: `String` - 141 | The type of input option for a captcha 142 | - text 143 | - (More to come) 144 | - `media`: `String` - 145 | The type of media of a captcha 146 | - image/png 147 | - image/gif 148 | - (More to come) 149 | - `size`: String - 150 | The dimensions of a captcha. It needs to be a string in the format `"widthxheight"` in pixels, and will be matched 151 | with the `allowedSizes` config setting. Example: `size: "450x200"` which requests an image of width 450 and height 152 | 200 pixels. 153 | 154 | - Returns: 155 | - `id`: `String` - The uuid of the captcha generated 156 | 157 | 158 | ### - `/v1/media`: `GET` 159 | - Parameters: 160 | - `id`: `String` - The uuid of the captcha 161 | 162 | - Returns: 163 | - `image`: `Array[Byte]` - The requested media as bytes 164 | 165 | 166 | ### - `/v1/answer`: `POST` 167 | - Parameter: 168 | - `id`: `String` - The uuid of the captcha that needs to be solved 169 | - `answer`: `String` - The answer to the captcha that needs to be validated 170 | 171 | - Returns: 172 | - `result`: `String` - The result after validation/checking of the answer 173 | - True - If the answer is correct 174 | - False - If the answer is incorrect 175 | - Expired - If the time limit to solve the captcha exceeds 176 | 177 | 178 | ## Example usage 179 | 180 | In javascript: 181 | 182 | ```js 183 | const resp = await fetch("/v2/captcha", { 184 | method: 'POST', 185 | body: JSON.stringify({level: "easy", media: "image/png", "input_type" : "text", size: "350x100"}) 186 | }) 187 | 188 | const respJson = await resp.json(); 189 | 190 | let captchaId = null; 191 | 192 | if (resp.ok) { 193 | // The CAPTCHA can be displayed using the data in respJson. 194 | console.log(respJson); 195 | // Store the id somewhere so that it can be used later for answer verification 196 | captchaId = respJson.id; 197 | } else { 198 | console.err(respJson); 199 | } 200 | 201 | 202 | // When user submits an answer it can be sent to the server for verification thusly: 203 | const resp = await fetch("/v2/answer", { 204 | method: 'POST', 205 | body: JSON.stringify({id: captchaId, answer: "user input"}) 206 | }); 207 | const respJson = await resp.json(); 208 | console.log(respJson.result); 209 | ``` 210 | 211 | *** 212 | 213 | ## Roadmap 214 | 215 | Things to do in the future: 216 | * Sandboxed plugin architecture 217 | * Audio CAPTCHA samples 218 | * Interactive CAPTCHA samples 219 | -------------------------------------------------------------------------------- /Runner.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eclipse-temurin:17-jre-jammy AS base-core 2 | ENV JAVA_HOME="/usr/lib/jvm/default-jvm/" 3 | RUN apt update && apt install -y fonts-dejavu 4 | ENV PATH=$PATH:${JAVA_HOME}/bin 5 | 6 | 7 | FROM base-core 8 | RUN mkdir /lc-core 9 | COPY target/scala-3.6.4/LibreCaptcha.jar /lc-core 10 | WORKDIR /lc-core 11 | RUN mkdir data/ 12 | 13 | EXPOSE 8888 14 | 15 | CMD [ "java", "-jar", "LibreCaptcha.jar" ] 16 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | lazy val root = (project in file(".")).settings( 2 | inThisBuild( 3 | List( 4 | organization := "com.example", 5 | scalaVersion := "3.6.4", 6 | version := "0.2.1-snapshot" 7 | // semanticdbEnabled := true, 8 | // semanticdbVersion := scalafixSemanticdb.revision 9 | 10 | // This is apparently not supported on Scala 3 currently 11 | // scalafixScalaBinaryVersion := "3.1" 12 | ) 13 | ), 14 | name := "LibreCaptcha", 15 | libraryDependencies += "com.sksamuel.scrimage" % "scrimage-core" % "4.3.1", 16 | libraryDependencies += "com.sksamuel.scrimage" % "scrimage-filters" % "4.3.1", 17 | libraryDependencies += "org.json4s" %% "json4s-jackson" % "4.0.7" 18 | ) 19 | 20 | Compile / unmanagedResourceDirectories += { baseDirectory.value / "lib" } 21 | scalacOptions ++= List( 22 | "-deprecation" 23 | ) 24 | javacOptions += "-g:none" 25 | compileOrder := CompileOrder.JavaThenScala 26 | // javafmtOnCompile := false 27 | assembly / mainClass := Some("lc.LCFramework") 28 | Compile / run / mainClass := Some("lc.LCFramework") 29 | assembly / assemblyJarName := "LibreCaptcha.jar" 30 | 31 | ThisBuild / assemblyMergeStrategy := { 32 | case PathList("module-info.class") => MergeStrategy.discard 33 | case x if x.endsWith("/module-info.class") => MergeStrategy.discard 34 | case x => 35 | val oldStrategy = (ThisBuild / assemblyMergeStrategy).value 36 | oldStrategy(x) 37 | } 38 | 39 | run / fork := true 40 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Libre Captcha 5 | 6 | 7 | 8 |
9 |

Libre Captcha


10 |

Open Source solution to Captchas

11 |

v0.2 (Beta)

12 |
13 |
14 | 15 | 16 | 17 |
18 |
19 |

20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /client/script.js: -------------------------------------------------------------------------------- 1 | document.getElementById("reg-btn").addEventListener("click", function(){ 2 | var email = document.getElementById("email").value; 3 | var url = window.location.origin+"/v1/token?email="+email 4 | fetch(url) 5 | .then(res => res.json()) 6 | .then((data) => { 7 | document.getElementById("token").innerHTML = "SECRET "+data.token; 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /client/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | } 4 | 5 | .header { 6 | text-align: center; 7 | } 8 | 9 | .form { 10 | width: 200px; 11 | margin: 0 auto; 12 | } 13 | 14 | .form input { 15 | width: 100%; 16 | margin: 2px; 17 | padding: 2px; 18 | } 19 | 20 | #token { 21 | margin: 10px; 22 | padding: 3px; 23 | text-align: center; 24 | } 25 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | 3 | services: 4 | lc-core: 5 | container_name: "libre-captcha" 6 | image: librecaptcha/lc-core:latest 7 | # Comment "image" & uncomment "build" if you intend to build from source 8 | #build: . 9 | volumes: 10 | - "./docker-data:/lc-core/data" 11 | ports: 12 | - "8888:8888" 13 | -------------------------------------------------------------------------------- /lib/h2-2.1.214.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/librecaptcha/lc-core/07e54b5bd61e88c5182263ba85a4a65339418c3a/lib/h2-2.1.214.jar -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.0 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | // addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.2") 2 | // addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") 3 | // addSbtPlugin("com.lightbend.sbt" % "sbt-java-formatter" % "0.8.0") 4 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1") 5 | -------------------------------------------------------------------------------- /samples/FilterChallenge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/librecaptcha/lc-core/07e54b5bd61e88c5182263ba85a4a65339418c3a/samples/FilterChallenge.png -------------------------------------------------------------------------------- /samples/RainDropsCaptcha.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/librecaptcha/lc-core/07e54b5bd61e88c5182263ba85a4a65339418c3a/samples/RainDropsCaptcha.gif -------------------------------------------------------------------------------- /samples/popping.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/librecaptcha/lc-core/07e54b5bd61e88c5182263ba85a4a65339418c3a/samples/popping.gif -------------------------------------------------------------------------------- /samples/shadowText.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/librecaptcha/lc-core/07e54b5bd61e88c5182263ba85a4a65339418c3a/samples/shadowText.png -------------------------------------------------------------------------------- /scripts/simpleTest.py: -------------------------------------------------------------------------------- 1 | import http.client 2 | import json 3 | import subprocess 4 | import os 5 | 6 | tempDir = os.getenv('XDG_RUNTIME_DIR', '.') 7 | conn = http.client.HTTPConnection('localhost', 8888) 8 | 9 | params = """{ 10 | "level": "debug", 11 | "media": "image/png", 12 | "input_type": "text" 13 | }""" 14 | 15 | def getCaptcha(): 16 | conn.request("POST", "/v1/captcha", body=params) 17 | response = conn.getresponse() 18 | 19 | if response: 20 | responseStr = response.read() 21 | return json.loads(responseStr) 22 | 23 | def getAndSolve(idStr): 24 | conn.request("GET", "/v1/media?id=" + idStr) 25 | response = conn.getresponse() 26 | 27 | if response: 28 | responseBytes = response.read() 29 | fileName = tempDir + "/captcha.png" 30 | with open(fileName, "wb") as f: 31 | f.write(responseBytes) 32 | ocrResult = subprocess.Popen("gocr " + fileName, shell=True, stdout=subprocess.PIPE) 33 | ocrAnswer = ocrResult.stdout.readlines()[0].strip().decode() 34 | return ocrAnswer 35 | 36 | def postAnswer(captchaId, ans): 37 | reply = {"answer": ans, "id" : captchaId} 38 | conn.request("POST", "/v1/answer", json.dumps(reply)) 39 | response = conn.getresponse() 40 | if response: 41 | return response.read() 42 | print(responseStr) 43 | 44 | 45 | for i in range(0, 10000): 46 | captcha = getCaptcha() 47 | captchaId = captcha["id"] 48 | ans = getAndSolve(captchaId) 49 | print(i, postAnswer(captchaId, ans)) 50 | -------------------------------------------------------------------------------- /src/main/java/lc/captchas/FontFunCaptcha.java: -------------------------------------------------------------------------------- 1 | package lc.captchas; 2 | 3 | import java.awt.*; 4 | import java.awt.image.BufferedImage; 5 | import java.io.ByteArrayOutputStream; 6 | import java.io.File; 7 | import java.io.FilenameFilter; 8 | import java.util.List; 9 | import java.util.Map; 10 | import lc.captchas.interfaces.Challenge; 11 | import lc.captchas.interfaces.ChallengeProvider; 12 | import lc.misc.HelperFunctions; 13 | import lc.misc.PngImageWriter; 14 | 15 | public class FontFunCaptcha implements ChallengeProvider { 16 | 17 | public String getId() { 18 | return "FontFunCaptcha"; 19 | } 20 | 21 | public Map> supportedParameters() { 22 | return Map.of( 23 | "supportedLevels", List.of("medium"), 24 | "supportedMedia", List.of("image/png"), 25 | "supportedInputType", List.of("text")); 26 | } 27 | 28 | public void configure(String config) { 29 | // TODO: Add custom config 30 | } 31 | 32 | private String getFontName(String path, String level) { 33 | File file = new File(path + level + "/"); 34 | FilenameFilter txtFileFilter = 35 | new FilenameFilter() { 36 | @Override 37 | public boolean accept(File dir, String name) { 38 | if (name.endsWith(".ttf")) return true; 39 | else return false; 40 | } 41 | }; 42 | File[] files = file.listFiles(txtFileFilter); 43 | return path 44 | + level.toLowerCase() 45 | + "/" 46 | + files[HelperFunctions.randomNumber(0, files.length - 1)].getName(); 47 | } 48 | 49 | private Font loadCustomFont(String level, String path) { 50 | String fontName = getFontName(path, level); 51 | try { 52 | Font font = Font.createFont(Font.TRUETYPE_FONT, new File(fontName)); 53 | font = font.deriveFont(Font.PLAIN, 48f); 54 | return font; 55 | } catch (Exception e) { 56 | e.printStackTrace(); 57 | } 58 | return null; 59 | } 60 | 61 | private byte[] fontFun( 62 | final int width, final int height, String captchaText, String level, String path) { 63 | String[] colors = {"#f68787", "#f8a978", "#f1eb9a", "#a4f6a5"}; 64 | BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); 65 | Graphics2D graphics2D = img.createGraphics(); 66 | for (int i = 0; i < captchaText.length(); i++) { 67 | Font font = loadCustomFont(level, path); 68 | graphics2D.setFont(font); 69 | FontMetrics fontMetrics = graphics2D.getFontMetrics(); 70 | HelperFunctions.setRenderingHints(graphics2D); 71 | graphics2D.setColor(Color.decode(colors[HelperFunctions.randomNumber(0, 3)])); 72 | graphics2D.drawString( 73 | String.valueOf(captchaText.charAt(i)), (i * 48), fontMetrics.getAscent()); 74 | } 75 | graphics2D.dispose(); 76 | ByteArrayOutputStream baos = new ByteArrayOutputStream(); 77 | try { 78 | PngImageWriter.write(baos, img); 79 | } catch (Exception e) { 80 | e.printStackTrace(); 81 | } 82 | return baos.toByteArray(); 83 | } 84 | 85 | public Challenge returnChallenge(String level, String size) { 86 | String secret = HelperFunctions.randomString(7); 87 | final int[] size2D = HelperFunctions.parseSize2D(size); 88 | final int width = size2D[0]; 89 | final int height = size2D[1]; 90 | String path = "./lib/fonts/"; 91 | return new Challenge( 92 | fontFun(width, height, secret, "medium", path), "image/png", secret.toLowerCase()); 93 | } 94 | 95 | public boolean checkAnswer(String secret, String answer) { 96 | return answer.toLowerCase().equals(secret); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/lc/captchas/PoppingCharactersCaptcha.java: -------------------------------------------------------------------------------- 1 | package lc.captchas; 2 | 3 | import java.awt.Color; 4 | import java.awt.Font; 5 | import java.awt.Graphics2D; 6 | import java.awt.RenderingHints; 7 | import java.awt.image.BufferedImage; 8 | import java.io.ByteArrayOutputStream; 9 | import java.io.IOException; 10 | import java.util.List; 11 | import java.util.Map; 12 | import java.util.function.Consumer; 13 | import java.util.stream.IntStream; 14 | import javax.imageio.stream.MemoryCacheImageOutputStream; 15 | import lc.captchas.interfaces.Challenge; 16 | import lc.captchas.interfaces.ChallengeProvider; 17 | import lc.misc.GifSequenceWriter; 18 | import lc.misc.HelperFunctions; 19 | 20 | public class PoppingCharactersCaptcha implements ChallengeProvider { 21 | 22 | private int[] computeOffsets( 23 | final Font font, final int width, final int height, final String text) { 24 | final var img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); 25 | final var graphics2D = img.createGraphics(); 26 | final var frc = graphics2D.getFontRenderContext(); 27 | final var advances = new int[text.length() + 1]; 28 | final var spacing = font.getStringBounds(" ", frc).getWidth() / 3; 29 | var currX = 0; 30 | for (int i = 0; i < text.length(); i++) { 31 | final var c = text.charAt(i); 32 | advances[i] = currX; 33 | currX += font.getStringBounds(String.valueOf(c), frc).getWidth(); 34 | currX += spacing; 35 | } 36 | ; 37 | advances[text.length()] = currX; 38 | graphics2D.dispose(); 39 | return advances; 40 | } 41 | 42 | private BufferedImage makeImage( 43 | final Font font, final int width, final int height, final Consumer f) { 44 | final var img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); 45 | final var graphics2D = img.createGraphics(); 46 | graphics2D.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); 47 | graphics2D.setRenderingHint( 48 | RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); 49 | graphics2D.setFont(font); 50 | f.accept(graphics2D); 51 | graphics2D.dispose(); 52 | return img; 53 | } 54 | 55 | private int jitter() { 56 | return HelperFunctions.randomNumber(-2, +2); 57 | } 58 | 59 | private byte[] gifCaptcha(final int width, final int height, final String text) { 60 | try { 61 | final var fontHeight = (int) (height * 0.5); 62 | final Font font = new Font("Arial", Font.ROMAN_BASELINE, fontHeight); 63 | final var byteArrayOutputStream = new ByteArrayOutputStream(); 64 | final var output = new MemoryCacheImageOutputStream(byteArrayOutputStream); 65 | final var writer = new GifSequenceWriter(output, 1, 900, true); 66 | final var advances = computeOffsets(font, width, height, text); 67 | final var expectedWidth = advances[advances.length - 1]; 68 | final var scale = width / (float) expectedWidth; 69 | final var prevColor = Color.getHSBColor(0f, 0f, 0.1f); 70 | IntStream.range(0, text.length()) 71 | .forEach( 72 | i -> { 73 | final var color = 74 | Color.getHSBColor(HelperFunctions.randomNumber(0, 100) / 100.0f, 0.6f, 1.0f); 75 | final var nextImage = 76 | makeImage( 77 | font, 78 | width, 79 | height, 80 | (g) -> { 81 | g.scale(scale, 1); 82 | if (i > 0) { 83 | final var prevI = (i - 1) % text.length(); 84 | g.setColor(prevColor); 85 | g.drawString( 86 | String.valueOf(text.charAt(prevI)), 87 | advances[prevI] + jitter(), 88 | fontHeight * 1.1f + jitter()); 89 | } 90 | g.setColor(color); 91 | g.drawString( 92 | String.valueOf(text.charAt(i)), 93 | advances[i] + jitter(), 94 | fontHeight * 1.1f + jitter()); 95 | }); 96 | try { 97 | writer.writeToSequence(nextImage); 98 | } catch (final IOException e) { 99 | e.printStackTrace(); 100 | } 101 | }); 102 | writer.close(); 103 | output.close(); 104 | return byteArrayOutputStream.toByteArray(); 105 | } catch (IOException e) { 106 | e.printStackTrace(); 107 | } 108 | return null; 109 | } 110 | 111 | public void configure(final String config) { 112 | // TODO: Add custom config 113 | } 114 | 115 | public Map> supportedParameters() { 116 | return Map.of( 117 | "supportedLevels", List.of("hard"), 118 | "supportedMedia", List.of("image/gif"), 119 | "supportedInputType", List.of("text")); 120 | } 121 | 122 | public Challenge returnChallenge(String level, String size) { 123 | final var secret = HelperFunctions.randomString(6); 124 | final int[] size2D = HelperFunctions.parseSize2D(size); 125 | final int width = size2D[0]; 126 | final int height = size2D[1]; 127 | return new Challenge(gifCaptcha(width, height, secret), "image/gif", secret.toLowerCase()); 128 | } 129 | 130 | public boolean checkAnswer(String secret, String answer) { 131 | return answer.toLowerCase().equals(secret); 132 | } 133 | 134 | public String getId() { 135 | return "PoppingCharactersCaptcha"; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/main/java/lc/captchas/ShadowTextCaptcha.java: -------------------------------------------------------------------------------- 1 | package lc.captchas; 2 | 3 | import java.awt.Color; 4 | import java.awt.Font; 5 | import java.awt.Graphics2D; 6 | import java.awt.image.BufferedImage; 7 | import java.awt.image.ConvolveOp; 8 | import java.awt.image.Kernel; 9 | import java.io.ByteArrayOutputStream; 10 | import java.util.List; 11 | import java.util.Map; 12 | import lc.captchas.interfaces.Challenge; 13 | import lc.captchas.interfaces.ChallengeProvider; 14 | import lc.misc.HelperFunctions; 15 | import lc.misc.PngImageWriter; 16 | 17 | public class ShadowTextCaptcha implements ChallengeProvider { 18 | 19 | public String getId() { 20 | return "ShadowTextCaptcha"; 21 | } 22 | 23 | public void configure(String config) { 24 | // TODO: Add custom config 25 | } 26 | 27 | public Map> supportedParameters() { 28 | return Map.of( 29 | "supportedLevels", List.of("easy"), 30 | "supportedMedia", List.of("image/png"), 31 | "supportedInputType", List.of("text")); 32 | } 33 | 34 | public boolean checkAnswer(String secret, String answer) { 35 | return answer.toLowerCase().equals(secret); 36 | } 37 | 38 | private float[] makeKernel(int size) { 39 | final int N = size * size; 40 | final float weight = 1.0f / (N); 41 | final float[] kernel = new float[N]; 42 | java.util.Arrays.fill(kernel, weight); 43 | return kernel; 44 | }; 45 | 46 | private byte[] shadowText(final int width, final int height, String text) { 47 | BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); 48 | final int fontHeight = (int) (height * 0.5f); 49 | Font font = new Font("Arial", Font.PLAIN, fontHeight); 50 | Graphics2D graphics2D = img.createGraphics(); 51 | HelperFunctions.setRenderingHints(graphics2D); 52 | graphics2D.setPaint(Color.WHITE); 53 | graphics2D.fillRect(0, 0, width, height); 54 | graphics2D.setPaint(Color.BLACK); 55 | graphics2D.setFont(font); 56 | final var stringWidth = graphics2D.getFontMetrics().stringWidth(text); 57 | final var padding = (stringWidth > width) ? 0 : (width - stringWidth) / 2; 58 | final var scaleX = (stringWidth > width) ? width / ((double) stringWidth) : 1d; 59 | graphics2D.scale(scaleX, 1d); 60 | graphics2D.drawString(text, padding, fontHeight * 1.1f); 61 | graphics2D.dispose(); 62 | final int kernelSize = (int) Math.ceil((Math.min(width, height) / 50.0)); 63 | ConvolveOp op = 64 | new ConvolveOp( 65 | new Kernel(kernelSize, kernelSize, makeKernel(kernelSize)), 66 | ConvolveOp.EDGE_NO_OP, 67 | null); 68 | BufferedImage img2 = op.filter(img, null); 69 | Graphics2D g2d = img2.createGraphics(); 70 | HelperFunctions.setRenderingHints(g2d); 71 | g2d.setPaint(Color.WHITE); 72 | g2d.scale(scaleX, 1d); 73 | g2d.setFont(font); 74 | g2d.drawString(text, padding - kernelSize, fontHeight * 1.1f); 75 | g2d.dispose(); 76 | ByteArrayOutputStream baos = new ByteArrayOutputStream(); 77 | try { 78 | PngImageWriter.write(baos, img2); 79 | } catch (Exception e) { 80 | e.printStackTrace(); 81 | } 82 | return baos.toByteArray(); 83 | } 84 | 85 | public Challenge returnChallenge(String level, String size) { 86 | String secret = HelperFunctions.randomString(6); 87 | final int[] size2D = HelperFunctions.parseSize2D(size); 88 | final int width = size2D[0]; 89 | final int height = size2D[1]; 90 | return new Challenge(shadowText(width, height, secret), "image/png", secret.toLowerCase()); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/main/java/lc/captchas/interfaces/Challenge.java: -------------------------------------------------------------------------------- 1 | package lc.captchas.interfaces; 2 | 3 | public class Challenge { 4 | public final byte[] content; 5 | public final String contentType; 6 | public final String secret; 7 | 8 | public Challenge(final byte[] content, final String contentType, final String secret) { 9 | this.content = content; 10 | this.contentType = contentType; 11 | this.secret = secret; 12 | } 13 | 14 | public String toString() { 15 | return "Challenge: " + contentType + " content length: " + content.length; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/lc/captchas/interfaces/ChallengeProvider.java: -------------------------------------------------------------------------------- 1 | package lc.captchas.interfaces; 2 | 3 | import java.util.List; 4 | import java.util.Map; 5 | 6 | public interface ChallengeProvider { 7 | public String getId(); 8 | 9 | public Challenge returnChallenge(String level, String size); 10 | 11 | public boolean checkAnswer(String secret, String answer); 12 | 13 | public void configure(String config); 14 | 15 | public Map> supportedParameters(); 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/lc/misc/GifSequenceWriter.java: -------------------------------------------------------------------------------- 1 | // This code was adapted from http://elliot.kroo.net/software/java/GifSequenceWriter/ 2 | // It was available under CC By 3.0 3 | 4 | package lc.misc; 5 | 6 | import java.awt.image.*; 7 | import java.io.*; 8 | import java.util.Iterator; 9 | import javax.imageio.*; 10 | import javax.imageio.metadata.*; 11 | import javax.imageio.stream.*; 12 | 13 | public class GifSequenceWriter { 14 | protected ImageWriter gifWriter; 15 | protected ImageWriteParam imageWriteParam; 16 | protected IIOMetadata imageMetaData; 17 | 18 | /** 19 | * Creates a new GifSequenceWriter 20 | * 21 | * @param outputStream the ImageOutputStream to be written to 22 | * @param imageType one of the imageTypes specified in BufferedImage 23 | * @param timeBetweenFramesMS the time between frames in miliseconds 24 | * @param loopContinuously wether the gif should loop repeatedly 25 | * @throws IIOException if no gif ImageWriters are found 26 | * @author Elliot Kroo (elliot[at]kroo[dot]net) 27 | */ 28 | public GifSequenceWriter( 29 | ImageOutputStream outputStream, 30 | int imageType, 31 | int timeBetweenFramesMS, 32 | boolean loopContinuously) 33 | throws IIOException, IOException { 34 | // my method to create a writer 35 | gifWriter = getWriter(); 36 | imageWriteParam = gifWriter.getDefaultWriteParam(); 37 | ImageTypeSpecifier imageTypeSpecifier = 38 | ImageTypeSpecifier.createFromBufferedImageType(imageType); 39 | 40 | imageMetaData = gifWriter.getDefaultImageMetadata(imageTypeSpecifier, imageWriteParam); 41 | 42 | String metaFormatName = imageMetaData.getNativeMetadataFormatName(); 43 | 44 | IIOMetadataNode root = (IIOMetadataNode) imageMetaData.getAsTree(metaFormatName); 45 | 46 | IIOMetadataNode graphicsControlExtensionNode = getNode(root, "GraphicControlExtension"); 47 | 48 | graphicsControlExtensionNode.setAttribute("disposalMethod", "none"); 49 | graphicsControlExtensionNode.setAttribute("userInputFlag", "FALSE"); 50 | graphicsControlExtensionNode.setAttribute("transparentColorFlag", "FALSE"); 51 | graphicsControlExtensionNode.setAttribute( 52 | "delayTime", Integer.toString(timeBetweenFramesMS / 10)); 53 | graphicsControlExtensionNode.setAttribute("transparentColorIndex", "0"); 54 | 55 | IIOMetadataNode commentsNode = getNode(root, "CommentExtensions"); 56 | commentsNode.setAttribute("CommentExtension", "Created by MAH"); 57 | 58 | IIOMetadataNode appEntensionsNode = getNode(root, "ApplicationExtensions"); 59 | 60 | IIOMetadataNode child = new IIOMetadataNode("ApplicationExtension"); 61 | 62 | child.setAttribute("applicationID", "NETSCAPE"); 63 | child.setAttribute("authenticationCode", "2.0"); 64 | 65 | int loop = loopContinuously ? 0 : 1; 66 | 67 | child.setUserObject(new byte[] {0x1, (byte) (loop & 0xFF), (byte) ((loop >> 8) & 0xFF)}); 68 | appEntensionsNode.appendChild(child); 69 | 70 | imageMetaData.setFromTree(metaFormatName, root); 71 | 72 | gifWriter.setOutput(outputStream); 73 | 74 | gifWriter.prepareWriteSequence(null); 75 | } 76 | 77 | public void writeToSequence(RenderedImage img) throws IOException { 78 | gifWriter.writeToSequence(new IIOImage(img, null, imageMetaData), imageWriteParam); 79 | } 80 | 81 | /** 82 | * Close this GifSequenceWriter object. This does not close the underlying stream, just finishes 83 | * off the GIF. 84 | */ 85 | public void close() throws IOException { 86 | gifWriter.endWriteSequence(); 87 | } 88 | 89 | /** 90 | * Returns the first available GIF ImageWriter using ImageIO.getImageWritersBySuffix("gif"). 91 | * 92 | * @return a GIF ImageWriter object 93 | * @throws IIOException if no GIF image writers are returned 94 | */ 95 | private static ImageWriter getWriter() throws IIOException { 96 | Iterator iter = ImageIO.getImageWritersBySuffix("gif"); 97 | if (!iter.hasNext()) { 98 | throw new IIOException("No GIF Image Writers Exist"); 99 | } else { 100 | return iter.next(); 101 | } 102 | } 103 | 104 | /** 105 | * Returns an existing child node, or creates and returns a new child node (if the requested node 106 | * does not exist). 107 | * 108 | * @param rootNode the IIOMetadataNode to search for the child node. 109 | * @param nodeName the name of the child node. 110 | * @return the child node, if found or a new node created with the given name. 111 | */ 112 | private static IIOMetadataNode getNode(IIOMetadataNode rootNode, String nodeName) { 113 | int nNodes = rootNode.getLength(); 114 | for (int i = 0; i < nNodes; i++) { 115 | if (rootNode.item(i).getNodeName().compareToIgnoreCase(nodeName) == 0) { 116 | return ((IIOMetadataNode) rootNode.item(i)); 117 | } 118 | } 119 | IIOMetadataNode node = new IIOMetadataNode(nodeName); 120 | rootNode.appendChild(node); 121 | return (node); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/main/java/lc/misc/HelperFunctions.java: -------------------------------------------------------------------------------- 1 | package lc.misc; 2 | 3 | import java.awt.*; 4 | import java.util.Random; 5 | 6 | public class HelperFunctions { 7 | 8 | private static Random random = new Random(); 9 | 10 | public static synchronized void setSeed(long seed) { 11 | random.setSeed(seed); 12 | } 13 | 14 | public static int[] parseSize2D(final String size) { 15 | final String[] fields = size.split("x"); 16 | final int[] result = {Integer.parseInt(fields[0]), Integer.parseInt(fields[1])}; 17 | return result; 18 | } 19 | 20 | public static void setRenderingHints(Graphics2D g2d) { 21 | g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); 22 | g2d.setRenderingHint( 23 | RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); 24 | g2d.setRenderingHint( 25 | RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); 26 | } 27 | 28 | public static final String safeAlphabets = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; 29 | public static final String allAlphabets = safeAlphabets + "ILlO"; 30 | public static final String safeNumbers = "23456789"; 31 | public static final String allNumbers = safeNumbers + "10"; 32 | public static final String specialCharacters = "$#%@&?"; 33 | public static final String safeAlphaNum = safeAlphabets + safeNumbers; 34 | public static final String safeCharacters = safeAlphaNum + specialCharacters; 35 | 36 | public static String randomString(final int n) { 37 | return randomString(n, safeCharacters); 38 | } 39 | 40 | public static String randomString(final int n, final String characters) { 41 | final StringBuilder stringBuilder = new StringBuilder(); 42 | for (int i = 0; i < n; i++) { 43 | int index = randomNumber(characters.length()); 44 | stringBuilder.append(characters.charAt(index)); 45 | } 46 | return stringBuilder.toString(); 47 | } 48 | 49 | public static synchronized int randomNumber(int min, int max) { 50 | return random.nextInt((max - min) + 1) + min; 51 | } 52 | 53 | public static synchronized int randomNumber(int bound) { 54 | return random.nextInt(bound); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/lc/misc/PngImageWriter.java: -------------------------------------------------------------------------------- 1 | package lc.misc; 2 | 3 | import java.awt.image.BufferedImage; 4 | import java.io.ByteArrayOutputStream; 5 | import java.io.IOException; 6 | import java.util.Iterator; 7 | import javax.imageio.IIOImage; 8 | import javax.imageio.ImageIO; 9 | import javax.imageio.ImageTypeSpecifier; 10 | import javax.imageio.ImageWriteParam; 11 | import javax.imageio.ImageWriter; 12 | import javax.imageio.metadata.IIOInvalidTreeException; 13 | import javax.imageio.metadata.IIOMetadata; 14 | import javax.imageio.metadata.IIOMetadataNode; 15 | import javax.imageio.stream.ImageOutputStream; 16 | 17 | public class PngImageWriter { 18 | 19 | static final int DPI = 245; 20 | static final double INCH_2_CM = 2.54; 21 | 22 | public static void write(ByteArrayOutputStream boas, BufferedImage gridImage) throws IOException { 23 | final String formatName = "png"; 24 | for (Iterator iw = ImageIO.getImageWritersByFormatName(formatName); 25 | iw.hasNext(); ) { 26 | ImageWriter writer = iw.next(); 27 | ImageWriteParam writeParam = writer.getDefaultWriteParam(); 28 | ImageTypeSpecifier typeSpecifier = 29 | ImageTypeSpecifier.createFromBufferedImageType(BufferedImage.TYPE_INT_RGB); 30 | IIOMetadata metadata = writer.getDefaultImageMetadata(typeSpecifier, writeParam); 31 | if (metadata.isReadOnly() || !metadata.isStandardMetadataFormatSupported()) { 32 | continue; 33 | } 34 | 35 | setDPIMeta(metadata); 36 | 37 | final ImageOutputStream stream = ImageIO.createImageOutputStream(boas); 38 | try { 39 | writer.setOutput(stream); 40 | writer.write(metadata, new IIOImage(gridImage, null, metadata), writeParam); 41 | } finally { 42 | stream.close(); 43 | } 44 | break; 45 | } 46 | } 47 | 48 | private static void setDPIMeta(IIOMetadata metadata) throws IIOInvalidTreeException { 49 | 50 | // for PNG, it's dots per millimeter 51 | double dotsPerMilli = 1.0 * DPI / 10 / INCH_2_CM; 52 | 53 | IIOMetadataNode horiz = new IIOMetadataNode("HorizontalPixelSize"); 54 | horiz.setAttribute("value", Double.toString(dotsPerMilli)); 55 | 56 | IIOMetadataNode vert = new IIOMetadataNode("VerticalPixelSize"); 57 | vert.setAttribute("value", Double.toString(dotsPerMilli)); 58 | 59 | IIOMetadataNode dim = new IIOMetadataNode("Dimension"); 60 | dim.appendChild(horiz); 61 | dim.appendChild(vert); 62 | 63 | IIOMetadataNode root = new IIOMetadataNode("javax_imageio_1.0"); 64 | root.appendChild(dim); 65 | 66 | metadata.mergeTree("javax_imageio_1.0", root); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/org/limium/picoserve/Server.java: -------------------------------------------------------------------------------- 1 | // Distributed under Apache 2 license 2 | // Copyright 2021 github.com/hrj 3 | 4 | package org.limium.picoserve; 5 | 6 | import com.sun.net.httpserver.HttpExchange; 7 | import com.sun.net.httpserver.HttpHandler; 8 | import com.sun.net.httpserver.HttpServer; 9 | import java.io.IOException; 10 | import java.net.InetSocketAddress; 11 | import java.net.URLDecoder; 12 | import java.nio.charset.StandardCharsets; 13 | import java.util.Arrays; 14 | import java.util.LinkedList; 15 | import java.util.List; 16 | import java.util.Map; 17 | import java.util.Optional; 18 | import java.util.concurrent.Executor; 19 | import java.util.regex.Pattern; 20 | import java.util.stream.Collectors; 21 | 22 | public final class Server { 23 | private final HttpServer server; 24 | 25 | public static interface Response { 26 | public int getCode(); 27 | 28 | public byte[] getBytes(); 29 | 30 | public Map> getResponseHeaders(); 31 | } 32 | 33 | public static class ByteResponse implements Response { 34 | private final int code; 35 | private final byte[] bytes; 36 | private final Map> responseHeaders; 37 | 38 | public ByteResponse(final int code, final byte[] bytes) { 39 | this.code = code; 40 | this.bytes = bytes; 41 | this.responseHeaders = null; 42 | } 43 | 44 | public ByteResponse( 45 | final int code, final byte[] bytes, final Map> responseHeaders) { 46 | this.code = code; 47 | this.bytes = bytes; 48 | this.responseHeaders = responseHeaders; 49 | } 50 | 51 | public int getCode() { 52 | return this.code; 53 | } 54 | 55 | public byte[] getBytes() { 56 | return this.bytes; 57 | } 58 | 59 | public Map> getResponseHeaders() { 60 | return this.responseHeaders; 61 | } 62 | } 63 | 64 | public static class StringResponse extends ByteResponse { 65 | public StringResponse(final int code, final String msg) { 66 | super(code, msg.getBytes()); 67 | } 68 | 69 | public StringResponse( 70 | final int code, final String msg, final Map> responseHeaders) { 71 | super(code, msg.getBytes(), responseHeaders); 72 | } 73 | } 74 | 75 | public final class Request { 76 | final HttpExchange exchange; 77 | 78 | Request(final HttpExchange exchange) { 79 | this.exchange = exchange; 80 | } 81 | 82 | public String getMethod() { 83 | return exchange.getRequestMethod(); 84 | } 85 | 86 | public Map> getQueryParams() { 87 | final var query = exchange.getRequestURI().getQuery(); 88 | final var params = parseParams(query); 89 | return params; 90 | } 91 | 92 | public byte[] getBody() { 93 | try (final var bodyIS = exchange.getRequestBody()) { 94 | final var bytes = bodyIS.readAllBytes(); 95 | bodyIS.close(); 96 | return bytes; 97 | } catch (IOException ioe) { 98 | return null; 99 | } 100 | } 101 | 102 | public String getBodyString() { 103 | return new String(getBody()); 104 | } 105 | } 106 | 107 | @FunctionalInterface 108 | public static interface Processor { 109 | public Response process(final Request request); 110 | } 111 | 112 | public static class Handler { 113 | public final String path; 114 | public final Processor processor; 115 | public final String[] methods; 116 | 117 | public Handler(final String path, final Processor processor) { 118 | this.path = path; 119 | this.processor = processor; 120 | this.methods = new String[] {}; 121 | } 122 | 123 | public Handler(final String path, final String methods, final Processor processor) { 124 | this.path = path; 125 | this.processor = processor; 126 | this.methods = methods.split(","); 127 | } 128 | } 129 | 130 | public Server( 131 | final InetSocketAddress addr, 132 | final int backlog, 133 | final List handlers, 134 | final Executor executor) 135 | throws IOException { 136 | this.server = HttpServer.create(addr, backlog); 137 | this.server.setExecutor(executor); 138 | for (final var handler : handlers) { 139 | // System.out.println("Registering handler for " + handler.path); 140 | this.server.createContext( 141 | handler.path, 142 | new HttpHandler() { 143 | public void handle(final HttpExchange exchange) { 144 | final var method = exchange.getRequestMethod(); 145 | final Response errorResponse = checkMethods(handler.methods, method); 146 | try (final var os = exchange.getResponseBody()) { 147 | Response response; 148 | if (errorResponse != null) { 149 | response = errorResponse; 150 | } else { 151 | try { 152 | response = handler.processor.process(new Request(exchange)); 153 | } catch (final Exception e) { 154 | e.printStackTrace(); 155 | response = new StringResponse(500, "Error: " + e); 156 | } 157 | } 158 | final var headersToSend = response.getResponseHeaders(); 159 | if (headersToSend != null) { 160 | final var responseHeaders = exchange.getResponseHeaders(); 161 | responseHeaders.putAll(headersToSend); 162 | } 163 | final var bytes = response.getBytes(); 164 | final var code = response.getCode(); 165 | exchange.sendResponseHeaders(code, bytes.length); 166 | os.write(bytes); 167 | os.close(); 168 | } catch (IOException ioe) { 169 | System.out.println("Error: " + ioe); 170 | } 171 | } 172 | }); 173 | } 174 | } 175 | 176 | public static Response checkMethods(final String[] methods, final String method) { 177 | if (methods.length > 0) { 178 | var found = false; 179 | for (var m : methods) { 180 | if (m.equals(method)) { 181 | found = true; 182 | break; 183 | } 184 | } 185 | if (!found) { 186 | return new StringResponse(404, "Method Not Accepted"); 187 | } 188 | } 189 | return null; 190 | } 191 | 192 | public void start() { 193 | this.server.start(); 194 | } 195 | 196 | public void stop(int delay) { 197 | this.server.stop(delay); 198 | } 199 | 200 | public static ServerBuilder builder() { 201 | return new ServerBuilder(); 202 | } 203 | 204 | // Adapted from https://stackoverflow.com/a/37368660 205 | private static final Pattern ampersandPattern = Pattern.compile("&"); 206 | private static final Pattern equalPattern = Pattern.compile("="); 207 | private static final Map> emptyMap = Map.of(); 208 | 209 | private static Map> parseParams(final String query) { 210 | if (query == null) { 211 | return emptyMap; 212 | } 213 | final var params = 214 | ampersandPattern 215 | .splitAsStream(query) 216 | .map(s -> Arrays.copyOf(equalPattern.split(s, 2), 2)) 217 | .collect( 218 | Collectors.groupingBy( 219 | s -> decode(s[0]), Collectors.mapping(s -> decode(s[1]), Collectors.toList()))); 220 | return params; 221 | } 222 | 223 | private static String decode(final String encoded) { 224 | return Optional.ofNullable(encoded) 225 | .map(e -> URLDecoder.decode(e, StandardCharsets.UTF_8)) 226 | .orElse(null); 227 | } 228 | 229 | public static class ServerBuilder { 230 | private InetSocketAddress mAddress = new InetSocketAddress(9000); 231 | private int backlog = 5; 232 | private List handlers = new LinkedList(); 233 | private Executor executor = null; 234 | 235 | public ServerBuilder port(final int port) { 236 | mAddress = new InetSocketAddress(port); 237 | return this; 238 | } 239 | 240 | public ServerBuilder backlog(final int backlog) { 241 | this.backlog = backlog; 242 | return this; 243 | } 244 | 245 | public ServerBuilder address(final InetSocketAddress addr) { 246 | mAddress = addr; 247 | return this; 248 | } 249 | 250 | public ServerBuilder handle(final Handler handler) { 251 | handlers.add(handler); 252 | return this; 253 | } 254 | 255 | public ServerBuilder GET(final String path, final Processor processor) { 256 | handlers.add(new Handler(path, "GET", request -> processor.process(request))); 257 | return this; 258 | } 259 | 260 | public ServerBuilder POST(final String path, final Processor processor) { 261 | handlers.add(new Handler(path, "POST", request -> processor.process(request))); 262 | return this; 263 | } 264 | 265 | public ServerBuilder PUT(final String path, final Processor processor) { 266 | handlers.add(new Handler(path, "PUT", request -> processor.process(request))); 267 | return this; 268 | } 269 | 270 | public ServerBuilder DELETE(final String path, final Processor processor) { 271 | handlers.add(new Handler(path, "DELETE", request -> processor.process(request))); 272 | return this; 273 | } 274 | 275 | public ServerBuilder HEAD(final String path, final Processor processor) { 276 | handlers.add(new Handler(path, "HEAD", request -> processor.process(request))); 277 | return this; 278 | } 279 | 280 | public ServerBuilder executor(final Executor executor) { 281 | this.executor = executor; 282 | return this; 283 | } 284 | 285 | public Server build() throws IOException { 286 | return new Server(mAddress, backlog, handlers, executor); 287 | } 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/main/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 60 | 61 |
62 |
63 | Level 64 | 65 |
66 |
67 | Media 68 | 69 |
70 |
71 | Input Type 72 | 73 |
74 |
75 | Input Size 76 | 77 |
78 |
79 | 80 |
81 |
82 |
83 |
...
84 |
85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /src/main/scala/lc/Main.scala: -------------------------------------------------------------------------------- 1 | package lc 2 | 3 | import lc.core.{CaptchaProviders, CaptchaManager, Config} 4 | import lc.server.Server 5 | import lc.background.BackgroundTask 6 | import lc.database.Statements 7 | 8 | object LCFramework { 9 | def main(args: scala.Array[String]): Unit = { 10 | val configFilePath = if (args.length > 0) { 11 | args(0) 12 | } else { 13 | "data/config.json" 14 | } 15 | val config = new Config(configFilePath) 16 | Statements.maxAttempts = config.maxAttempts 17 | val captchaProviders = new CaptchaProviders(config = config) 18 | val captchaManager = new CaptchaManager(config = config, captchaProviders = captchaProviders) 19 | val backgroundTask = new BackgroundTask(config = config, captchaManager = captchaManager) 20 | backgroundTask.beginThread(delay = config.threadDelay) 21 | val server = new Server( 22 | address = config.address, 23 | port = config.port, 24 | captchaManager = captchaManager, 25 | playgroundEnabled = config.playgroundEnabled, 26 | corsHeader = config.corsHeader 27 | ) 28 | 29 | Runtime.getRuntime.addShutdownHook(new Thread { 30 | override def run(): Unit = { 31 | println("Shutting down gracefully...") 32 | backgroundTask.shutdown() 33 | } 34 | }) 35 | 36 | server.start() 37 | } 38 | } 39 | 40 | object MakeSamples { 41 | def main(args: scala.Array[String]): Unit = { 42 | val configFilePath = if (args.length > 0) { 43 | args(0) 44 | } else { 45 | "data/config.json" 46 | } 47 | val config = new Config(configFilePath) 48 | val captchaProviders = new CaptchaProviders(config = config) 49 | val samples = captchaProviders.generateChallengeSamples() 50 | samples.foreach { case (key, sample) => 51 | val extensionMap = Map("image/png" -> "png", "image/gif" -> "gif") 52 | println(key + ": " + sample) 53 | 54 | val outStream = new java.io.FileOutputStream("samples/" + key + "." + extensionMap(sample.contentType)) 55 | outStream.write(sample.content) 56 | outStream.close 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/lc/background/taskThread.scala: -------------------------------------------------------------------------------- 1 | package lc.background 2 | 3 | import lc.database.Statements 4 | import java.util.concurrent.{ScheduledThreadPoolExecutor, TimeUnit} 5 | import lc.core.{CaptchaManager, Config} 6 | import lc.core.{Parameters, Size} 7 | import lc.misc.HelperFunctions 8 | 9 | class BackgroundTask(config: Config, captchaManager: CaptchaManager) { 10 | 11 | private val task = new Runnable { 12 | def run(): Unit = { 13 | try { 14 | val mapIdGCPstmt = Statements.tlStmts.get.mapIdGCPstmt 15 | mapIdGCPstmt.setInt(1, config.captchaExpiryTimeLimit) 16 | mapIdGCPstmt.executeUpdate() 17 | 18 | val challengeGCPstmt = Statements.tlStmts.get.challengeGCPstmt 19 | challengeGCPstmt.executeUpdate() 20 | 21 | val allCombinations = allParameterCombinations() 22 | val requiredCountPerCombination = Math.max(1, (config.bufferCount * 1.01) / allCombinations.size).toInt 23 | 24 | for (param <- allCombinations) { 25 | if (!shutdownInProgress) { 26 | val countExisting = captchaManager.getCount(param).getOrElse(0) 27 | val countRequired = requiredCountPerCombination - countExisting 28 | if (countRequired > 0) { 29 | val countCreate = Math.min(1.0 + requiredCountPerCombination / 10.0, countRequired).toInt 30 | println(s"Creating $countCreate of $countRequired captchas for $param") 31 | 32 | for (i <- 0 until countCreate) { 33 | if (!shutdownInProgress) { 34 | captchaManager.generateChallenge(param) 35 | } 36 | } 37 | } 38 | } 39 | } 40 | } catch { case exception: Exception => println(exception) } 41 | } 42 | } 43 | 44 | private def allParameterCombinations(): List[Parameters] = { 45 | (config.captchaConfig).flatMap { captcha => 46 | (captcha.allowedLevels).flatMap { level => 47 | (captcha.allowedMedia).flatMap { media => 48 | (captcha.allowedInputType).flatMap { inputType => 49 | (captcha.allowedSizes).map { size => 50 | Parameters(level, media, inputType, size) 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | private def getRandomParam(): Parameters = { 59 | val captcha = pickRandom(config.captchaConfig) 60 | val level = pickRandom(captcha.allowedLevels) 61 | val media = pickRandom(captcha.allowedMedia) 62 | val inputType = pickRandom(captcha.allowedInputType) 63 | val size = pickRandom(captcha.allowedSizes) 64 | 65 | Parameters(level, media, inputType, size) 66 | } 67 | 68 | private def pickRandom[T](list: List[T]): T = { 69 | list(HelperFunctions.randomNumber(list.size)) 70 | } 71 | 72 | private val ex = new ScheduledThreadPoolExecutor(1) 73 | 74 | def beginThread(delay: Int): Unit = { 75 | ex.scheduleWithFixedDelay(task, 1, delay, TimeUnit.SECONDS) 76 | } 77 | 78 | @volatile var shutdownInProgress = false 79 | 80 | def shutdown(): Unit = { 81 | println(" Shutting down background task...") 82 | shutdownInProgress = true 83 | ex.shutdown() 84 | println(" Finished Shutting background task") 85 | println(" Shutting down DB...") 86 | Statements.tlStmts.get.shutdown.execute() 87 | println(" Finished shutting down db") 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/main/scala/lc/captchas/DebugCaptcha.scala: -------------------------------------------------------------------------------- 1 | package lc.captchas 2 | 3 | import java.awt.Color 4 | import java.awt.Font 5 | import java.awt.font.TextLayout 6 | import java.awt.image.BufferedImage 7 | import java.io.ByteArrayOutputStream 8 | import java.util.Map 9 | import java.util.List 10 | 11 | import lc.misc.HelperFunctions 12 | import lc.captchas.interfaces.Challenge 13 | import lc.captchas.interfaces.ChallengeProvider 14 | import lc.misc.PngImageWriter 15 | 16 | /** This captcha is only for debugging purposes. It creates very simple captchas that are deliberately easy to solve 17 | * with OCR engines 18 | */ 19 | class DebugCaptcha extends ChallengeProvider { 20 | 21 | def getId(): String = { 22 | "DebugCaptcha" 23 | } 24 | 25 | def configure(config: String): Unit = { 26 | // TODO: Add custom config 27 | } 28 | 29 | def supportedParameters(): Map[String, List[String]] = { 30 | Map.of( 31 | "supportedLevels", 32 | List.of("debug"), 33 | "supportedMedia", 34 | List.of("image/png"), 35 | "supportedInputType", 36 | List.of("text") 37 | ) 38 | } 39 | 40 | def checkAnswer(secret: String, answer: String): Boolean = { 41 | val matches = answer.toLowerCase().replaceAll(" ", "").equals(secret) 42 | if (!matches) { 43 | println(s"Didn't match, answer: '$answer' to secret '$secret'") 44 | } 45 | matches 46 | } 47 | 48 | private def simpleText(width: Int, height: Int, text: String): Array[Byte] = { 49 | val img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) 50 | val font = new Font("Arial", Font.ROMAN_BASELINE, 56) 51 | val graphics2D = img.createGraphics() 52 | val textLayout = new TextLayout(text, font, graphics2D.getFontRenderContext()) 53 | HelperFunctions.setRenderingHints(graphics2D) 54 | graphics2D.setPaint(Color.WHITE) 55 | graphics2D.fillRect(0, 0, width, height) 56 | graphics2D.setPaint(Color.BLACK) 57 | textLayout.draw(graphics2D, 15, 50) 58 | graphics2D.dispose() 59 | val baos = new ByteArrayOutputStream() 60 | try { 61 | PngImageWriter.write(baos, img); 62 | } catch { 63 | case e: Exception => 64 | e.printStackTrace() 65 | } 66 | baos.toByteArray() 67 | } 68 | 69 | def returnChallenge(level: String, size: String): Challenge = { 70 | val secret = HelperFunctions.randomString(6, HelperFunctions.safeAlphabets) 71 | val size2D = HelperFunctions.parseSize2D(size) 72 | val width = size2D(0) 73 | val height = size2D(1) 74 | new Challenge(simpleText(width, height, secret), "image/png", secret.toLowerCase()) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main/scala/lc/captchas/FilterChallenge.scala: -------------------------------------------------------------------------------- 1 | package lc.captchas 2 | 3 | import com.sksamuel.scrimage._ 4 | import com.sksamuel.scrimage.filter._ 5 | import java.awt.image.BufferedImage 6 | import java.awt.Font 7 | import java.awt.Color 8 | import lc.captchas.interfaces.ChallengeProvider 9 | import lc.captchas.interfaces.Challenge 10 | import java.util.{List => JavaList, Map => JavaMap} 11 | import java.io.ByteArrayOutputStream 12 | import lc.misc.PngImageWriter 13 | import lc.misc.HelperFunctions 14 | 15 | class FilterChallenge extends ChallengeProvider { 16 | def getId = "FilterChallenge" 17 | 18 | def configure(config: String): Unit = { 19 | // TODO: add custom config 20 | } 21 | 22 | def supportedParameters(): JavaMap[String, JavaList[String]] = { 23 | JavaMap.of( 24 | "supportedLevels", 25 | JavaList.of("medium", "hard"), 26 | "supportedMedia", 27 | JavaList.of("image/png"), 28 | "supportedInputType", 29 | JavaList.of("text") 30 | ) 31 | } 32 | 33 | private val filterTypes = List(new FilterType1, new FilterType2) 34 | 35 | def returnChallenge(level: String, size: String): Challenge = { 36 | val mediumLevel = level == "medium" 37 | val r = new scala.util.Random 38 | val characters = if (mediumLevel) HelperFunctions.safeAlphaNum else HelperFunctions.safeCharacters 39 | val n = if (mediumLevel) 5 else 7 40 | val secret = LazyList.continually(r.nextInt(characters.size)).map(characters).take(n).mkString 41 | val size2D = HelperFunctions.parseSize2D(size) 42 | val width = size2D(0) 43 | val height = size2D(1) 44 | val canvas = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) 45 | val g = canvas.createGraphics() 46 | val fontHeight = (height * 0.6).toInt 47 | g.setColor(Color.WHITE) 48 | g.fillRect(0, 0, canvas.getWidth, canvas.getHeight) 49 | g.setColor(Color.BLACK) 50 | val font = new Font("Serif", Font.BOLD, fontHeight) 51 | g.setFont(font) 52 | val stringWidth = g.getFontMetrics().stringWidth(secret) 53 | val scaleX = if (stringWidth > width) width / (stringWidth.toDouble) else 1d 54 | val margin = if (stringWidth > width) 0 else (width - stringWidth) 55 | val xOffset = (margin * r.nextDouble).toInt 56 | g.scale(scaleX, 1d) 57 | g.drawString(secret, xOffset, fontHeight) 58 | g.dispose() 59 | var image = ImmutableImage.fromAwt(canvas) 60 | val s = r.nextInt(2) 61 | image = filterTypes(s).applyFilter(image, !mediumLevel) 62 | val img = image.awt() 63 | val baos = new ByteArrayOutputStream() 64 | PngImageWriter.write(baos, img); 65 | new Challenge(baos.toByteArray, "image/png", secret) 66 | } 67 | def checkAnswer(secret: String, answer: String): Boolean = { 68 | secret == answer 69 | } 70 | } 71 | 72 | trait FilterType { 73 | def applyFilter(image: ImmutableImage, hardLevel: Boolean): ImmutableImage 74 | } 75 | 76 | class FilterType1 extends FilterType { 77 | override def applyFilter(image: ImmutableImage, hardLevel: Boolean): ImmutableImage = { 78 | val radius = if (hardLevel) 3 else 2 79 | val blur = new GaussianBlurFilter(radius) 80 | val smear = new SmearFilter(com.sksamuel.scrimage.filter.SmearType.Circles, 10, 10, 10, 0, 1) 81 | val diffuse = new DiffuseFilter(radius.toFloat) 82 | blur.apply(image) 83 | diffuse.apply(image) 84 | smear.apply(image) 85 | image 86 | } 87 | } 88 | 89 | class FilterType2 extends FilterType { 90 | override def applyFilter(image: ImmutableImage, hardLevel: Boolean): ImmutableImage = { 91 | val radius = if (hardLevel) 2f else 1f 92 | val smear = new SmearFilter(com.sksamuel.scrimage.filter.SmearType.Circles, 10, 10, 10, 0, 1) 93 | val diffuse = new DiffuseFilter(radius) 94 | val ripple = new RippleFilter(com.sksamuel.scrimage.filter.RippleType.Noise, 1, 1, 0.005.toFloat, 0.005.toFloat) 95 | diffuse.apply(image) 96 | ripple.apply(image) 97 | smear.apply(image) 98 | image 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/scala/lc/captchas/LabelCaptcha.scala: -------------------------------------------------------------------------------- 1 | package lc.captchas 2 | 3 | import java.io.File 4 | import java.io.ByteArrayOutputStream 5 | import javax.imageio.ImageIO 6 | import scala.collection.mutable.Map 7 | import java.nio.file.{Files, StandardCopyOption} 8 | import java.awt.image.BufferedImage 9 | import java.awt.Color 10 | import lc.captchas.interfaces.ChallengeProvider 11 | import lc.captchas.interfaces.Challenge 12 | import java.util.{List => JavaList, Map => JavaMap} 13 | import lc.misc.PngImageWriter 14 | 15 | class LabelCaptcha extends ChallengeProvider { 16 | private var knownFiles = new File("known").list.toList 17 | private var unknownFiles = new File("unknown").list.toList 18 | private val unknownAnswers = Map[String, Map[String, Int]]() 19 | private val total = Map[String, Int]() 20 | 21 | for (file <- unknownFiles) { 22 | unknownAnswers += file -> Map[String, Int]() 23 | total += file -> 0 24 | } 25 | 26 | def getId = "LabelCaptcha" 27 | 28 | def configure(config: String): Unit = { 29 | // TODO: add custom config 30 | } 31 | 32 | def supportedParameters(): JavaMap[String, JavaList[String]] = { 33 | JavaMap.of( 34 | "supportedLevels", 35 | JavaList.of("hard"), 36 | "supportedMedia", 37 | JavaList.of("image/png"), 38 | "supportedInputType", 39 | JavaList.of("text") 40 | ) 41 | } 42 | 43 | def returnChallenge(level: String, size: String): Challenge = 44 | synchronized { 45 | val r = scala.util.Random.nextInt(knownFiles.length) 46 | val s = scala.util.Random.nextInt(unknownFiles.length) 47 | val knownImageFile = knownFiles(r) 48 | val unknownImageFile = unknownFiles(s) 49 | 50 | val knownImage = ImageIO.read(new File("known/" + knownImageFile)) 51 | val unknownImage = ImageIO.read(new File("unknown/" + unknownImageFile)) 52 | val mergedImage = merge(knownImage, unknownImage) 53 | 54 | val token = encrypt(knownImageFile + "," + unknownImageFile) 55 | val baos = new ByteArrayOutputStream() 56 | PngImageWriter.write(baos, mergedImage); 57 | 58 | new Challenge(baos.toByteArray(), "image/png", token) 59 | } 60 | 61 | private def merge(knownImage: BufferedImage, unknownImage: BufferedImage) = { 62 | val width = knownImage.getWidth() + unknownImage.getWidth() 63 | val height = List(knownImage.getHeight(), unknownImage.getHeight()).max 64 | val imageType = knownImage.getType() 65 | val finalImage = new BufferedImage(width, height, imageType) 66 | val g = finalImage.createGraphics() 67 | g.setColor(Color.WHITE) 68 | g.fillRect(0, 0, finalImage.getWidth(), finalImage.getHeight()) 69 | g.drawImage(knownImage, null, 0, 0) 70 | g.drawImage(unknownImage, null, knownImage.getWidth(), 0) 71 | g.dispose() 72 | finalImage 73 | } 74 | 75 | def checkAnswer(token: String, input: String): Boolean = 76 | synchronized { 77 | val parts = decrypt(token).split(",") 78 | val knownImage = parts(0) 79 | val unknownImage = parts(1) 80 | val expectedAnswer = knownImage.split('.')(0) 81 | val userAnswer = input.split(' ') 82 | if (userAnswer(0) == expectedAnswer) { 83 | val unknownFile = unknownImage 84 | if ((unknownAnswers(unknownFile)).contains(userAnswer(1))) { 85 | unknownAnswers(unknownFile)(userAnswer(1)) += 1 86 | total(unknownFile) += 1 87 | } else { 88 | unknownAnswers(unknownFile) += (userAnswer(1)) -> 1 89 | total(unknownFile) += 1 90 | } 91 | if (total(unknownFile) >= 3) { 92 | if ((unknownAnswers(unknownFile)(userAnswer(1)) / total(unknownFile)) >= 0.9) { 93 | unknownAnswers -= unknownFile 94 | Files.move( 95 | new File("unknown/" + unknownFile).toPath, 96 | new File("known/" + userAnswer(1) + ".png").toPath, 97 | StandardCopyOption.REPLACE_EXISTING 98 | ) 99 | knownFiles = new File("known").list.toList 100 | unknownFiles = new File("unknown").list.toList 101 | } 102 | } 103 | true 104 | } else { 105 | false 106 | } 107 | } 108 | 109 | // TODO: Encryption is not implemented for the POC, since the API re-maps the tokens anyway. 110 | // But we need to encrypt after POC, to avoid leaking file-names. 111 | // There are good ideas here: https://stackoverflow.com/questions/1205135/how-to-encrypt-string-in-java 112 | private def encrypt(s: String) = s 113 | private def decrypt(s: String) = s 114 | } 115 | 116 | class ImagePair(val known: String, val unknown: String) 117 | -------------------------------------------------------------------------------- /src/main/scala/lc/captchas/RainDropsCaptcha.scala: -------------------------------------------------------------------------------- 1 | package lc.captchas 2 | 3 | import java.awt.image.BufferedImage 4 | import java.awt.RenderingHints 5 | import java.awt.Font 6 | import java.awt.font.TextAttribute 7 | import java.awt.Color 8 | import java.io.ByteArrayOutputStream; 9 | import javax.imageio.stream.MemoryCacheImageOutputStream; 10 | import lc.captchas.interfaces.ChallengeProvider 11 | import lc.captchas.interfaces.Challenge 12 | import lc.misc.GifSequenceWriter 13 | import java.util.{List => JavaList, Map => JavaMap} 14 | import lc.misc.HelperFunctions 15 | 16 | class Drop { 17 | var x = 0 18 | var y = 0 19 | var yOffset = 0 20 | var color = 0 21 | var colorChange = 10 22 | def mkColor: Color = { 23 | new Color(color, color, math.min(200, color + 100)) 24 | } 25 | } 26 | 27 | class RainDropsCP extends ChallengeProvider { 28 | private val bgColor = new Color(200, 200, 200) 29 | private val textColor = new Color(208, 208, 218) 30 | private val textHighlightColor = new Color(100, 100, 125) 31 | 32 | def getId = "FilterChallenge" 33 | 34 | def configure(config: String): Unit = { 35 | // TODO: add custom config 36 | } 37 | 38 | def supportedParameters(): JavaMap[String, JavaList[String]] = { 39 | JavaMap.of( 40 | "supportedLevels", 41 | JavaList.of("medium", "easy"), 42 | "supportedMedia", 43 | JavaList.of("image/gif"), 44 | "supportedInputType", 45 | JavaList.of("text") 46 | ) 47 | } 48 | 49 | private def extendDrops(drops: Array[Drop], steps: Int, xOffset: Int) = { 50 | drops.map(d => { 51 | val nd = new Drop() 52 | nd.x + xOffset * steps 53 | nd.y + d.yOffset * steps 54 | nd 55 | }) 56 | } 57 | 58 | def returnChallenge(level: String, size: String): Challenge = { 59 | val r = new scala.util.Random 60 | val n = if (level == "easy") 4 else 6 61 | val secret = HelperFunctions.randomString(n, HelperFunctions.safeAlphaNum) 62 | val size2D = HelperFunctions.parseSize2D(size) 63 | val width = size2D(0) 64 | val height = size2D(1) 65 | val imgType = BufferedImage.TYPE_INT_RGB 66 | val xOffset = 2 + r.nextInt(3) 67 | val xBias = (height / 10) - 2 68 | val dropsOrig = Array.fill[Drop](2000)(new Drop()) 69 | for (d <- dropsOrig) { 70 | d.x = r.nextInt(width) - (xBias / 2) * xOffset 71 | d.yOffset = 6 + r.nextInt(6) 72 | d.y = r.nextInt(height) 73 | d.color = r.nextInt(240) 74 | if (d.color > 128) { 75 | d.colorChange *= -1 76 | } 77 | } 78 | val drops = dropsOrig ++ extendDrops(dropsOrig, 1, xOffset) ++ extendDrops(dropsOrig, 2, xOffset) ++ extendDrops( 79 | dropsOrig, 80 | 3, 81 | xOffset 82 | ) 83 | 84 | val fontHeight = (height * 0.5f).toInt 85 | val baseFont = new Font(Font.MONOSPACED, Font.BOLD, fontHeight) 86 | val attributes = new java.util.HashMap[TextAttribute, Object]() 87 | attributes.put(TextAttribute.TRACKING, Double.box(0.2)) 88 | attributes.put(TextAttribute.WEIGHT, TextAttribute.WEIGHT_EXTRABOLD) 89 | val spacedFont = baseFont.deriveFont(attributes) 90 | 91 | val baos = new ByteArrayOutputStream(); 92 | val ios = new MemoryCacheImageOutputStream(baos); 93 | val writer = new GifSequenceWriter(ios, imgType, 60, true); 94 | for (_ <- 0 until 60) { 95 | // val yOffset = 5+r.nextInt(5) 96 | val canvas = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) 97 | val g = canvas.createGraphics() 98 | g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) 99 | 100 | // clear the canvas 101 | g.setColor(bgColor) 102 | g.fillRect(0, 0, canvas.getWidth, canvas.getHeight) 103 | 104 | // paint the rain 105 | for (d <- drops) { 106 | g.setColor(d.mkColor) 107 | g.drawLine(d.x, d.y, d.x + xOffset, d.y + d.yOffset) 108 | d.x += xOffset / 2 109 | d.y += d.yOffset / 2 110 | d.color += d.colorChange 111 | if (d.x > width || d.y > height) { 112 | val ySteps = (height / d.yOffset) + 1 113 | d.x -= xOffset * ySteps 114 | d.y -= d.yOffset * ySteps 115 | 116 | } 117 | if (d.color > 200 || d.color < 21) { 118 | d.colorChange *= -1 119 | } 120 | } 121 | 122 | g.setFont(spacedFont) 123 | val textWidth = g.getFontMetrics().stringWidth(secret) 124 | val scaleX = if (textWidth > width) width / textWidth.toDouble else 1.0d 125 | g.scale(scaleX, 1) 126 | 127 | // center the text 128 | val textX = if (textWidth > width) 0 else ((width - textWidth) / 2) 129 | 130 | // this will be overlapped by the following text to show the top outline because of the offset 131 | val yOffset = (fontHeight * 0.01).ceil.toInt 132 | g.setColor(textHighlightColor) 133 | g.drawString(secret, textX, (fontHeight * 1.1).toInt - yOffset) 134 | 135 | // paint the text 136 | g.setColor(textColor) 137 | g.drawString(secret, textX, (fontHeight * 1.1).toInt) 138 | 139 | g.dispose() 140 | writer.writeToSequence(canvas) 141 | } 142 | writer.close 143 | ios.close 144 | 145 | // ImageIO.write(canvas,"png",baos); 146 | new Challenge(baos.toByteArray, "image/gif", secret) 147 | } 148 | def checkAnswer(secret: String, answer: String): Boolean = { 149 | secret == answer 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/main/scala/lc/core/captchaFields.scala: -------------------------------------------------------------------------------- 1 | package lc.core 2 | 3 | object ParametersEnum extends Enumeration { 4 | type Parameter = Value 5 | 6 | val SUPPORTEDLEVEL: Value = Value("supportedLevels") 7 | val SUPPORTEDMEDIA: Value = Value("supportedMedia") 8 | val SUPPORTEDINPUTTYPE: Value = Value("supportedInputType") 9 | 10 | val ALLOWEDLEVELS: Value = Value("allowedLevels") 11 | val ALLOWEDMEDIA: Value = Value("allowedMedia") 12 | val ALLOWEDINPUTTYPE: Value = Value("allowedInputType") 13 | val ALLOWEDSIZES: Value = Value("allowedSizes") 14 | } 15 | 16 | object AttributesEnum extends Enumeration { 17 | type Attribute = Value 18 | 19 | val NAME: Value = Value("name") 20 | val RANDOM_SEED: Value = Value("randomSeed") 21 | val PORT: Value = Value("port") 22 | val ADDRESS: Value = Value("address") 23 | val CAPTCHA_EXPIRY_TIME_LIMIT: Value = Value("captchaExpiryTimeLimit") 24 | val BUFFER_COUNT: Value = Value("bufferCount") 25 | val THREAD_DELAY: Value = Value("threadDelay") 26 | val PLAYGROUND_ENABLED: Value = Value("playgroundEnabled") 27 | val CORS_HEADER: Value = Value("corsHeader") 28 | val CONFIG: Value = Value("config") 29 | val MAX_ATTEMPTS_RATIO: Value = Value("maxAttemptsRatio") 30 | } 31 | 32 | object ResultEnum extends Enumeration { 33 | type Result = Value 34 | 35 | val TRUE: Value = Value("True") 36 | val FALSE: Value = Value("False") 37 | val EXPIRED: Value = Value("Expired") 38 | } 39 | 40 | object ErrorMessageEnum extends Enumeration { 41 | type ErrorMessage = Value 42 | 43 | val SMW: Value = Value("Oops, something went worng!") 44 | val INVALID_PARAM: Value = Value("Parameters invalid or missing") 45 | val IMG_MISSING: Value = Value("Image missing") 46 | val IMG_NOT_FOUND: Value = Value("Image not found") 47 | val NO_CAPTCHA: Value = Value("No captcha for the provided parameters. Change config options.") 48 | val BAD_METHOD: Value = Value("Bad request method") 49 | } 50 | -------------------------------------------------------------------------------- /src/main/scala/lc/core/captchaManager.scala: -------------------------------------------------------------------------------- 1 | package lc.core 2 | 3 | import lc.captchas.interfaces.{Challenge, ChallengeProvider} 4 | import lc.database.Statements 5 | import java.io.ByteArrayInputStream 6 | import java.sql.{Blob, ResultSet} 7 | import java.util.UUID 8 | 9 | class CaptchaManager(config: Config, captchaProviders: CaptchaProviders) { 10 | 11 | def getCaptcha(id: Id): Either[Error, Image] = { 12 | val blob = getImage(id.id) 13 | blob match { 14 | case Some(value) => { 15 | if (blob != null) { 16 | Right(Image(value.getBytes(1, value.length().toInt))) 17 | } else { 18 | Left(Error(ErrorMessageEnum.IMG_MISSING.toString)) 19 | } 20 | } 21 | case None => Left(Error(ErrorMessageEnum.IMG_NOT_FOUND.toString)) 22 | } 23 | } 24 | 25 | private def getImage(id: String): Option[Blob] = { 26 | val imagePstmt = Statements.tlStmts.get.imagePstmt 27 | imagePstmt.setString(1, id) 28 | val rs: ResultSet = imagePstmt.executeQuery() 29 | if (rs.next()) { 30 | Some(rs.getBlob("image")) 31 | } else { 32 | None 33 | } 34 | } 35 | 36 | def generateChallenge(param: Parameters): Option[Int] = { 37 | try { 38 | captchaProviders.getProvider(param).flatMap { provider => 39 | val providerId = provider.getId() 40 | val challenge = provider.returnChallenge(param.level, param.size) 41 | val blob = new ByteArrayInputStream(challenge.content) 42 | val token = insertCaptcha(provider, challenge, providerId, param, blob) 43 | // println("Added new challenge: " + token.toString) 44 | token.map(_.toInt) 45 | } 46 | } catch { 47 | case e: Exception => 48 | e.printStackTrace() 49 | None 50 | } 51 | } 52 | 53 | private def insertCaptcha( 54 | provider: ChallengeProvider, 55 | challenge: Challenge, 56 | providerId: String, 57 | param: Parameters, 58 | blob: ByteArrayInputStream 59 | ): Option[Int] = { 60 | val insertPstmt = Statements.tlStmts.get.insertPstmt 61 | insertPstmt.setString(1, provider.getId) 62 | insertPstmt.setString(2, challenge.secret) 63 | insertPstmt.setString(3, providerId) 64 | insertPstmt.setString(4, challenge.contentType) 65 | insertPstmt.setString(5, param.level) 66 | insertPstmt.setString(6, param.input_type) 67 | insertPstmt.setString(7, param.size) 68 | insertPstmt.setBlob(8, blob) 69 | insertPstmt.executeUpdate() 70 | val rs: ResultSet = insertPstmt.getGeneratedKeys() 71 | if (rs.next()) { 72 | Some(rs.getInt("token")) 73 | } else { 74 | None 75 | } 76 | } 77 | 78 | val allowedInputType = config.allowedInputType 79 | val allowedLevels = config.allowedLevels 80 | val allowedMedia = config.allowedMedia 81 | 82 | private def validateParam(param: Parameters): Array[String] = { 83 | var invalid_params = Array[String]() 84 | if (!allowedLevels.contains(param.level)) invalid_params :+= "level" 85 | if (!allowedMedia.contains(param.media)) invalid_params :+= "media" 86 | if (!allowedInputType.contains(param.input_type)) invalid_params :+= "input_type" 87 | 88 | invalid_params 89 | } 90 | 91 | def getChallenge(param: Parameters): Either[Error, Id] = { 92 | val validParam = validateParam(param) 93 | if (validParam.isEmpty) { 94 | val tokenOpt = getToken(param) 95 | val token = tokenOpt.orElse(generateChallenge(param)) 96 | token match { 97 | case Some(value) => { 98 | val uuid = getUUID(value) 99 | updateAttempted(value) 100 | Right(Id(uuid)) 101 | } 102 | case None => { 103 | Left(Error(ErrorMessageEnum.NO_CAPTCHA.toString)) 104 | } 105 | } 106 | } else { 107 | Left(Error(ErrorMessageEnum.INVALID_PARAM.toString + " => " + validParam.mkString(", "))) 108 | } 109 | } 110 | 111 | def getCount(param: Parameters): Option[Int] = { 112 | val countPstmt = Statements.tlStmts.get.countForParameterPstmt 113 | countPstmt.setString(1, param.level) 114 | countPstmt.setString(2, param.media) 115 | countPstmt.setString(3, param.input_type) 116 | countPstmt.setString(4, param.size.toString()) 117 | val rs = countPstmt.executeQuery() 118 | if (rs.next()) { 119 | Some(rs.getInt("count")) 120 | } else { 121 | None 122 | } 123 | } 124 | 125 | private def getToken(param: Parameters): Option[Int] = { 126 | val count = getCount(param).getOrElse(0) 127 | if (count == 0) { 128 | None 129 | } else { 130 | val tokenPstmt = Statements.tlStmts.get.tokenPstmt 131 | tokenPstmt.setString(1, param.level) 132 | tokenPstmt.setString(2, param.media) 133 | tokenPstmt.setString(3, param.input_type) 134 | tokenPstmt.setString(4, param.size) 135 | tokenPstmt.setInt(5, count) 136 | val rs = tokenPstmt.executeQuery() 137 | if (rs.next()) { 138 | Some(rs.getInt("token")) 139 | } else { 140 | None 141 | } 142 | } 143 | } 144 | 145 | private def updateAttempted(token: Int): Unit = { 146 | val updateAttemptedPstmt = Statements.tlStmts.get.updateAttemptedPstmt 147 | updateAttemptedPstmt.setInt(1, token) 148 | updateAttemptedPstmt.executeUpdate() 149 | } 150 | 151 | private def getUUID(id: Int): String = { 152 | val uuid = UUID.randomUUID().toString 153 | val mapPstmt = Statements.tlStmts.get.mapPstmt 154 | mapPstmt.setString(1, uuid) 155 | mapPstmt.setInt(2, id) 156 | mapPstmt.executeUpdate() 157 | uuid 158 | } 159 | 160 | def checkAnswer(answer: Answer): Either[Error, Success] = { 161 | val challenge = getSecret(answer.id) 162 | challenge match { 163 | case None => Right(Success(ResultEnum.EXPIRED.toString)) 164 | case Some(value) => { 165 | val (provider, secret) = value 166 | val check = captchaProviders.getProviderById(provider).checkAnswer(secret, answer.answer) 167 | deleteCaptcha(answer.id) 168 | val result = if (check) ResultEnum.TRUE.toString else ResultEnum.FALSE.toString 169 | Right(Success(result)) 170 | } 171 | } 172 | } 173 | 174 | private def getSecret(id: String): Option[(String, String)] = { 175 | val selectPstmt = Statements.tlStmts.get.selectPstmt 176 | selectPstmt.setInt(1, config.captchaExpiryTimeLimit) 177 | selectPstmt.setString(2, id) 178 | val rs: ResultSet = selectPstmt.executeQuery() 179 | if (rs.first()) { 180 | val secret = rs.getString("secret") 181 | val provider = rs.getString("provider") 182 | Some(provider, secret) 183 | } else { 184 | None 185 | } 186 | } 187 | 188 | private def deleteCaptcha(id: String): Unit = { 189 | val deleteAnswerPstmt = Statements.tlStmts.get.deleteAnswerPstmt 190 | deleteAnswerPstmt.setString(1, id) 191 | deleteAnswerPstmt.executeUpdate() 192 | } 193 | 194 | def display(): Unit = { 195 | val rs: ResultSet = Statements.tlStmts.get.getChallengeTable.executeQuery() 196 | println("token\t\tid\t\tsecret\t\tattempted") 197 | while (rs.next()) { 198 | val token = rs.getInt("token") 199 | val id = rs.getString("id") 200 | val secret = rs.getString("secret") 201 | val attempted = rs.getString("attempted") 202 | println(s"${token}\t\t${id}\t\t${secret}\t\t${attempted}\n\n") 203 | } 204 | 205 | val rss: ResultSet = Statements.tlStmts.get.getMapIdTable.executeQuery() 206 | println("uuid\t\ttoken\t\tlastServed") 207 | while (rss.next()) { 208 | val uuid = rss.getString("uuid") 209 | val token = rss.getInt("token") 210 | val lastServed = rss.getTimestamp("lastServed") 211 | println(s"${uuid}\t\t${token}\t\t${lastServed}\n\n") 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/main/scala/lc/core/captchaProviders.scala: -------------------------------------------------------------------------------- 1 | package lc.core 2 | 3 | import lc.captchas.* 4 | import lc.captchas.interfaces.ChallengeProvider 5 | import lc.captchas.interfaces.Challenge 6 | import scala.collection.mutable.Map 7 | import lc.misc.HelperFunctions 8 | 9 | class CaptchaProviders(config: Config) { 10 | private val providers = Map( 11 | "FilterChallenge" -> new FilterChallenge, 12 | // "FontFunCaptcha" -> new FontFunCaptcha, 13 | "PoppingCharactersCaptcha" -> new PoppingCharactersCaptcha, 14 | "ShadowTextCaptcha" -> new ShadowTextCaptcha, 15 | "RainDropsCaptcha" -> new RainDropsCP, 16 | "DebugCaptcha" -> new DebugCaptcha 17 | // "LabelCaptcha" -> new LabelCaptcha 18 | ) 19 | 20 | def generateChallengeSamples(): Map[String, Challenge] = { 21 | providers.map { case (key, provider) => 22 | (key, provider.returnChallenge("easy", "350x100")) 23 | } 24 | } 25 | 26 | private val captchaConfig = config.captchaConfig 27 | 28 | def getProviderById(id: String): ChallengeProvider = { 29 | return providers(id) 30 | } 31 | 32 | private def filterProviderByParam(param: Parameters): Iterable[(String, String)] = { 33 | val configFilter = for { 34 | configValue <- captchaConfig 35 | if configValue.allowedLevels.contains(param.level) 36 | if configValue.allowedMedia.contains(param.media) 37 | if configValue.allowedInputType.contains(param.input_type) 38 | if configValue.allowedSizes.contains(param.size) 39 | } yield (configValue.name, configValue.config) 40 | 41 | val providerFilter = for { 42 | providerValue <- configFilter 43 | providerConfigMap = providers(providerValue._1).supportedParameters() 44 | if providerConfigMap.get(ParametersEnum.SUPPORTEDLEVEL.toString).contains(param.level) 45 | if providerConfigMap.get(ParametersEnum.SUPPORTEDMEDIA.toString).contains(param.media) 46 | if providerConfigMap.get(ParametersEnum.SUPPORTEDINPUTTYPE.toString).contains(param.input_type) 47 | } yield (providerValue._1, providerValue._2) 48 | 49 | providerFilter 50 | } 51 | 52 | def getProvider(param: Parameters): Option[ChallengeProvider] = { 53 | val providerConfig = filterProviderByParam(param).toList 54 | if (providerConfig.nonEmpty) { 55 | val randomIndex = HelperFunctions.randomNumber(providerConfig.length) 56 | val providerIndex = providerConfig(randomIndex)._1 57 | val selectedProvider = providers(providerIndex) 58 | selectedProvider.configure(providerConfig(randomIndex)._2) 59 | Some(selectedProvider) 60 | } else { 61 | None 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/scala/lc/core/config.scala: -------------------------------------------------------------------------------- 1 | package lc.core 2 | 3 | import scala.io.Source.fromFile 4 | import org.json4s.{DefaultFormats, JValue, JObject, JField, JString} 5 | import org.json4s.jackson.JsonMethods.{parse, render, pretty} 6 | import org.json4s.JsonDSL._ 7 | import org.json4s.StringInput 8 | import org.json4s.jvalue2monadic 9 | import org.json4s.jvalue2extractable 10 | import java.io.{FileNotFoundException, File, PrintWriter} 11 | import java.{util => ju} 12 | import lc.misc.HelperFunctions 13 | 14 | class Config(configFilePath: String) { 15 | 16 | import Config.formats 17 | 18 | private val configString = 19 | try { 20 | val configFile = fromFile(configFilePath) 21 | val configFileContent = configFile.mkString 22 | configFile.close 23 | configFileContent 24 | } catch { 25 | case _: FileNotFoundException => { 26 | val configFileContent = getDefaultConfig() 27 | val file = if (new File(configFilePath).isDirectory) { 28 | new File(configFilePath.concat("/config.json")) 29 | } else { 30 | new File(configFilePath) 31 | } 32 | val configFile = new PrintWriter(file) 33 | configFile.write(configFileContent) 34 | configFile.close 35 | configFileContent 36 | } 37 | case exception: Exception => { 38 | println(exception.getStackTrace) 39 | throw new Exception(exception.getMessage) 40 | } 41 | } 42 | 43 | private val configJson = parse(configString) 44 | private val configFields: ConfigField = configJson.extract[ConfigField] 45 | 46 | val port: Int = configFields.portInt.getOrElse(8888) 47 | val address: String = configFields.address.getOrElse("0.0.0.0") 48 | val bufferCount: Int = configFields.bufferCountInt.getOrElse(1000) 49 | val seed: Int = configFields.seedInt.getOrElse(375264328) 50 | val captchaExpiryTimeLimit: Int = configFields.captchaExpiryTimeLimitInt.getOrElse(5) 51 | val threadDelay: Int = configFields.threadDelayInt.getOrElse(2) 52 | val playgroundEnabled: Boolean = configFields.playgroundEnabledBool.getOrElse(true) 53 | val corsHeader: String = configFields.corsHeader.getOrElse("") 54 | val maxAttempts: Int = Math.max(1, (configFields.maxAttemptsRatioFloat.getOrElse(0.01f) * bufferCount).toInt) 55 | 56 | private val captchaConfigJson = (configJson \ "captchas") 57 | val captchaConfigTransform: JValue = captchaConfigJson transformField { case JField("config", JObject(config)) => 58 | ("config", JString(config.toString)) 59 | } 60 | val captchaConfig: List[CaptchaConfig] = captchaConfigTransform.extract[List[CaptchaConfig]] 61 | val allowedLevels: Set[String] = captchaConfig.flatMap(_.allowedLevels).toSet 62 | val allowedMedia: Set[String] = captchaConfig.flatMap(_.allowedMedia).toSet 63 | val allowedInputType: Set[String] = captchaConfig.flatMap(_.allowedInputType).toSet 64 | 65 | HelperFunctions.setSeed(seed) 66 | 67 | private def getDefaultConfig(): String = { 68 | val defaultConfigMap = 69 | (AttributesEnum.RANDOM_SEED.toString -> new ju.Random().nextInt()) ~ 70 | (AttributesEnum.PORT.toString -> 8888) ~ 71 | (AttributesEnum.ADDRESS.toString -> "0.0.0.0") ~ 72 | (AttributesEnum.CAPTCHA_EXPIRY_TIME_LIMIT.toString -> 5) ~ 73 | (AttributesEnum.BUFFER_COUNT.toString -> 1000) ~ 74 | (AttributesEnum.THREAD_DELAY.toString -> 2) ~ 75 | (AttributesEnum.PLAYGROUND_ENABLED.toString -> true) ~ 76 | (AttributesEnum.CORS_HEADER.toString -> "") ~ 77 | (AttributesEnum.MAX_ATTEMPTS_RATIO.toString -> 0.01f) ~ 78 | ("captchas" -> List( 79 | ( 80 | (AttributesEnum.NAME.toString -> "FilterChallenge") ~ 81 | (ParametersEnum.ALLOWEDLEVELS.toString -> List("medium", "hard")) ~ 82 | (ParametersEnum.ALLOWEDMEDIA.toString -> List("image/png")) ~ 83 | (ParametersEnum.ALLOWEDINPUTTYPE.toString -> List("text")) ~ 84 | (ParametersEnum.ALLOWEDSIZES.toString -> List("350x100")) ~ 85 | (AttributesEnum.CONFIG.toString -> JObject()) 86 | ), 87 | ( 88 | (AttributesEnum.NAME.toString -> "PoppingCharactersCaptcha") ~ 89 | (ParametersEnum.ALLOWEDLEVELS.toString -> List("hard")) ~ 90 | (ParametersEnum.ALLOWEDMEDIA.toString -> List("image/gif")) ~ 91 | (ParametersEnum.ALLOWEDINPUTTYPE.toString -> List("text")) ~ 92 | (ParametersEnum.ALLOWEDSIZES.toString -> List("350x100")) ~ 93 | (AttributesEnum.CONFIG.toString -> JObject()) 94 | ), 95 | ( 96 | (AttributesEnum.NAME.toString -> "ShadowTextCaptcha") ~ 97 | (ParametersEnum.ALLOWEDLEVELS.toString -> List("easy")) ~ 98 | (ParametersEnum.ALLOWEDMEDIA.toString -> List("image/png")) ~ 99 | (ParametersEnum.ALLOWEDINPUTTYPE.toString -> List("text")) ~ 100 | (ParametersEnum.ALLOWEDSIZES.toString -> List("350x100")) ~ 101 | (AttributesEnum.CONFIG.toString -> JObject()) 102 | ), 103 | ( 104 | (AttributesEnum.NAME.toString -> "RainDropsCaptcha") ~ 105 | (ParametersEnum.ALLOWEDLEVELS.toString -> List("easy", "medium")) ~ 106 | (ParametersEnum.ALLOWEDMEDIA.toString -> List("image/gif")) ~ 107 | (ParametersEnum.ALLOWEDINPUTTYPE.toString -> List("text")) ~ 108 | (ParametersEnum.ALLOWEDSIZES.toString -> List("350x100")) ~ 109 | (AttributesEnum.CONFIG.toString -> JObject()) 110 | ) 111 | )) 112 | 113 | pretty(render(defaultConfigMap)) 114 | } 115 | 116 | } 117 | 118 | object Config { 119 | implicit val formats: DefaultFormats.type = DefaultFormats 120 | } 121 | -------------------------------------------------------------------------------- /src/main/scala/lc/core/models.scala: -------------------------------------------------------------------------------- 1 | package lc.core 2 | 3 | import org.json4s.jackson.Serialization.write 4 | import lc.core.Config.formats 5 | 6 | trait ByteConvert { def toBytes(): Array[Byte] } 7 | case class Size(height: Int, width: Int) 8 | case class Parameters(level: String, media: String, input_type: String, size: String) 9 | case class Id(id: String) extends ByteConvert { def toBytes(): Array[Byte] = { write(this).getBytes } } 10 | case class Image(image: Array[Byte]) extends ByteConvert { def toBytes(): Array[Byte] = { image } } 11 | case class Answer(answer: String, id: String) 12 | case class Success(result: String) extends ByteConvert { def toBytes(): Array[Byte] = { write(this).getBytes } } 13 | case class Error(message: String) extends ByteConvert { def toBytes(): Array[Byte] = { write(this).getBytes } } 14 | case class CaptchaConfig( 15 | name: String, 16 | allowedLevels: List[String], 17 | allowedMedia: List[String], 18 | allowedInputType: List[String], 19 | allowedSizes: List[String], 20 | config: String 21 | ) 22 | case class ConfigField( 23 | port: Option[Integer], 24 | address: Option[String], 25 | bufferCount: Option[Integer], 26 | seed: Option[Integer], 27 | captchaExpiryTimeLimit: Option[Integer], 28 | threadDelay: Option[Integer], 29 | playgroundEnabled: Option[java.lang.Boolean], 30 | corsHeader: Option[String], 31 | maxAttemptsRatio: Option[java.lang.Float] 32 | ) { 33 | lazy val portInt: Option[Int] = mapInt(port) 34 | lazy val bufferCountInt: Option[Int] = mapInt(bufferCount) 35 | lazy val seedInt: Option[Int] = mapInt(seed) 36 | lazy val captchaExpiryTimeLimitInt: Option[Int] = mapInt(captchaExpiryTimeLimit) 37 | lazy val threadDelayInt: Option[Int] = mapInt(threadDelay) 38 | lazy val maxAttemptsRatioFloat: Option[Float] = mapFloat(maxAttemptsRatio) 39 | lazy val playgroundEnabledBool: Option[Boolean] = playgroundEnabled.map(_ || false) 40 | 41 | private def mapInt(x: Option[Integer]): Option[Int] = { 42 | x.map(_ + 0) 43 | } 44 | private def mapFloat(x: Option[java.lang.Float]): Option[Float] = { 45 | x.map(_ + 0.0f) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/scala/lc/database/DB.scala: -------------------------------------------------------------------------------- 1 | package lc.database 2 | 3 | import java.sql.{Connection, DriverManager, Statement} 4 | 5 | class DBConn() { 6 | val con: Connection = 7 | DriverManager.getConnection("jdbc:h2:./data/H2/captcha3;MAX_COMPACT_TIME=8000;DB_CLOSE_ON_EXIT=FALSE", "sa", "") 8 | 9 | def getStatement(): Statement = { 10 | con.createStatement() 11 | } 12 | 13 | def closeConnection(): Unit = { 14 | con.close() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/scala/lc/database/statements.scala: -------------------------------------------------------------------------------- 1 | package lc.database 2 | 3 | import lc.database.DBConn 4 | import java.sql.Statement 5 | import java.sql.PreparedStatement 6 | 7 | class Statements(dbConn: DBConn, maxAttempts: Int) { 8 | 9 | private val stmt = dbConn.getStatement() 10 | 11 | stmt.execute( 12 | "CREATE TABLE IF NOT EXISTS challenge" + 13 | "(token int auto_increment, " + 14 | "id varchar, " + 15 | "secret varchar, " + 16 | "provider varchar, " + 17 | "contentType varchar, " + 18 | "contentLevel varchar, " + 19 | "contentInput varchar, " + 20 | "size varchar, " + 21 | "image blob, " + 22 | "attempted int default 0, " + 23 | "PRIMARY KEY(token));" + 24 | """ 25 | CREATE INDEX IF NOT EXISTS attempted ON challenge(attempted); 26 | """ 27 | ) 28 | stmt.execute( 29 | "CREATE TABLE IF NOT EXISTS mapId" + 30 | "(uuid varchar, " + 31 | "token int, " + 32 | "lastServed timestamp, " + 33 | "PRIMARY KEY(uuid), " + 34 | "FOREIGN KEY(token) " + 35 | "REFERENCES challenge(token) " + 36 | "ON DELETE CASCADE)" 37 | ) 38 | 39 | val insertPstmt: PreparedStatement = dbConn.con.prepareStatement( 40 | "INSERT INTO " + 41 | "challenge(id, secret, provider, contentType, contentLevel, contentInput, size, image) " + 42 | "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 43 | Statement.RETURN_GENERATED_KEYS 44 | ) 45 | 46 | val mapPstmt: PreparedStatement = 47 | dbConn.con.prepareStatement( 48 | "INSERT INTO " + 49 | "mapId(uuid, token, lastServed) " + 50 | "VALUES (?, ?, CURRENT_TIMESTAMP)" 51 | ) 52 | 53 | val selectPstmt: PreparedStatement = dbConn.con.prepareStatement( 54 | "SELECT c.secret, c.provider " + 55 | "FROM challenge c, mapId m " + 56 | "WHERE m.token=c.token AND " + 57 | "DATEDIFF(MINUTE, CURRENT_TIMESTAMP, DATEADD(MINUTE, ?, m.lastServed)) > 0 AND " + 58 | "m.uuid = ?" 59 | ) 60 | 61 | val imagePstmt: PreparedStatement = dbConn.con.prepareStatement( 62 | "SELECT image " + 63 | "FROM challenge c, mapId m " + 64 | "WHERE c.token=m.token AND " + 65 | "m.uuid = ?" 66 | ) 67 | 68 | val updateAttemptedPstmt: PreparedStatement = dbConn.con.prepareStatement( 69 | "UPDATE challenge " + 70 | "SET attempted = attempted+1 " + 71 | "WHERE token = ?;" 72 | ) 73 | 74 | val countForParameterPstmt: PreparedStatement = dbConn.con.prepareStatement( 75 | s""" 76 | SELECT count(*) as count 77 | FROM challenge 78 | WHERE attempted < $maxAttempts AND 79 | contentLevel = ? AND 80 | contentType = ? AND 81 | contentInput = ? AND 82 | size = ? 83 | """ 84 | ) 85 | 86 | val tokenPstmt: PreparedStatement = dbConn.con.prepareStatement( 87 | s""" 88 | SELECT token, attempted 89 | FROM challenge 90 | WHERE attempted < $maxAttempts AND 91 | contentLevel = ? AND 92 | contentType = ? AND 93 | contentInput = ? AND 94 | size = ? 95 | LIMIT 1 96 | OFFSET FLOOR(RAND()*?) 97 | """ 98 | ) 99 | 100 | val deleteAnswerPstmt: PreparedStatement = dbConn.con.prepareStatement( 101 | "DELETE FROM mapId WHERE uuid = ?" 102 | ) 103 | 104 | val challengeGCPstmt: PreparedStatement = dbConn.con.prepareStatement( 105 | s"""DELETE FROM challenge 106 | WHERE attempted >= $maxAttempts AND 107 | token NOT IN (SELECT token FROM mapId)""" 108 | ) 109 | 110 | val mapIdGCPstmt: PreparedStatement = dbConn.con.prepareStatement( 111 | "DELETE FROM mapId WHERE DATEDIFF(MINUTE, CURRENT_TIMESTAMP, DATEADD(MINUTE, ?, lastServed)) < 0" 112 | ) 113 | 114 | val getCountChallengeTable: PreparedStatement = dbConn.con.prepareStatement( 115 | "SELECT COUNT(*) AS total FROM challenge" 116 | ) 117 | 118 | val getChallengeTable: PreparedStatement = dbConn.con.prepareStatement( 119 | "SELECT * FROM challenge" 120 | ) 121 | 122 | val getMapIdTable: PreparedStatement = dbConn.con.prepareStatement( 123 | "SELECT * FROM mapId" 124 | ) 125 | 126 | val shutdown: PreparedStatement = dbConn.con.prepareStatement( 127 | "SHUTDOWN" 128 | ) 129 | 130 | val shutdownCompact: PreparedStatement = dbConn.con.prepareStatement( 131 | "SHUTDOWN COMPACT" 132 | ) 133 | 134 | } 135 | 136 | object Statements { 137 | /* Note: h2 documentation recommends using a separate DB connection per thread 138 | But in practice, as of version 1.4.200, multiple connections occassionally shows error on the console of the form 139 | ``` 140 | org.h2.jdbc.JdbcSQLNonTransientException: General error: "java.lang.NullPointerException"; SQL statement: 141 | SELECT image FROM challenge c, mapId m WHERE c.token=m.token AND m.uuid = ? [50000-200] 142 | ``` 143 | */ 144 | private val dbConn: DBConn = new DBConn() 145 | var maxAttempts: Int = 10 146 | val tlStmts: ThreadLocal[Statements] = ThreadLocal.withInitial(() => new Statements(dbConn, maxAttempts)) 147 | } 148 | -------------------------------------------------------------------------------- /src/main/scala/lc/server/Server.scala: -------------------------------------------------------------------------------- 1 | package lc.server 2 | 3 | import org.json4s.jackson.JsonMethods.parse 4 | import org.json4s.jvalue2extractable 5 | import lc.core.CaptchaManager 6 | import lc.core.ErrorMessageEnum 7 | import lc.core.{Answer, ByteConvert, Error, Id, Parameters} 8 | import lc.core.Config.formats 9 | import org.limium.picoserve 10 | import org.limium.picoserve.Server.{ByteResponse, ServerBuilder, StringResponse} 11 | import scala.io.Source 12 | import java.net.InetSocketAddress 13 | import java.util 14 | import scala.jdk.CollectionConverters._ 15 | 16 | class Server( 17 | address: String, 18 | port: Int, 19 | captchaManager: CaptchaManager, 20 | playgroundEnabled: Boolean, 21 | corsHeader: String 22 | ) { 23 | var headerMap: util.Map[String, util.List[String]] = _ 24 | if (corsHeader.nonEmpty) { 25 | headerMap = Map("Access-Control-Allow-Origin" -> List(corsHeader).asJava).asJava 26 | } 27 | val serverBuilder: ServerBuilder = picoserve.Server 28 | .builder() 29 | .address(new InetSocketAddress(address, port)) 30 | .backlog(32) 31 | .POST( 32 | "/v2/captcha", 33 | (request) => { 34 | val json = parse(request.getBodyString()) 35 | val param = json.extract[Parameters] 36 | val id = captchaManager.getChallenge(param) 37 | getResponse(id, headerMap) 38 | } 39 | ) 40 | .GET( 41 | "/v2/media", 42 | (request) => { 43 | val params = request.getQueryParams() 44 | val result = if (params.containsKey("id")) { 45 | val paramId = params.get("id").get(0) 46 | val id = Id(paramId) 47 | captchaManager.getCaptcha(id) 48 | } else { 49 | Left(Error(ErrorMessageEnum.INVALID_PARAM.toString + "=> id")) 50 | } 51 | getResponse(result, headerMap) 52 | } 53 | ) 54 | .POST( 55 | "/v2/answer", 56 | (request) => { 57 | val json = parse(request.getBodyString()) 58 | val answer = json.extract[Answer] 59 | val result = captchaManager.checkAnswer(answer) 60 | getResponse(result, headerMap) 61 | } 62 | ) 63 | if (playgroundEnabled) { 64 | serverBuilder.GET( 65 | "/demo/index.html", 66 | (_) => { 67 | val resStream = getClass().getResourceAsStream("/index.html") 68 | val str = Source.fromInputStream(resStream).mkString 69 | new StringResponse(200, str) 70 | } 71 | ) 72 | serverBuilder.GET( 73 | "/", 74 | (_) => { 75 | val str = """ 76 | 77 |

Welcome to LibreCaptcha server

78 |

Link to Demo

79 |

API is served at /v2/

80 | 81 | """ 82 | new StringResponse(200, str) 83 | } 84 | ) 85 | println("Playground enabled on /demo/index.html") 86 | } 87 | 88 | val server: picoserve.Server = serverBuilder.build() 89 | 90 | private def getResponse( 91 | response: Either[Error, ByteConvert], 92 | responseHeaders: util.Map[String, util.List[String]] 93 | ): ByteResponse = { 94 | response match { 95 | case Right(value) => { 96 | new ByteResponse(200, value.toBytes(), responseHeaders) 97 | } 98 | case Left(value) => { 99 | new ByteResponse(500, value.toBytes(), responseHeaders) 100 | } 101 | } 102 | } 103 | 104 | def start(): Unit = { 105 | println("Starting server on " + address + ":" + port) 106 | server.start() 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /tests/debug-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "randomSeed" : 20, 3 | "port" : 8888, 4 | "address" : "0.0.0.0", 5 | "captchaExpiryTimeLimit" : 5, 6 | "bufferCount" : 10, 7 | "threadDelay" : 2, 8 | "playgroundEnabled" : false, 9 | "corsHeader" : "*", 10 | "maxAttemptsRatio" : 0.01, 11 | "captchas" : [ { 12 | "name" : "DebugCaptcha", 13 | "allowedLevels" : [ "debug" ], 14 | "allowedMedia" : [ "image/png" ], 15 | "allowedInputType" : [ "text" ], 16 | "allowedSizes" : [ "350x100" ], 17 | "config" : { } 18 | }] 19 | } 20 | -------------------------------------------------------------------------------- /tests/locustfile-functional.py: -------------------------------------------------------------------------------- 1 | from locust import task, between, SequentialTaskSet 2 | from locust.contrib.fasthttp import FastHttpUser 3 | from locust import events 4 | import json 5 | import logging 6 | import subprocess 7 | 8 | @events.quitting.add_listener 9 | def _(environment, **kw): 10 | totalStats = environment.stats.total 11 | if totalStats.fail_ratio > 0.20: 12 | logging.error("Test failed due to failure ratio " + totalStats.fail_ratio + " > 20%") 13 | environment.process_exit_code = 1 14 | elif totalStats.get_response_time_percentile(0.80) > 800: 15 | logging.error("Test failed due to 80th percentile response time > 800 ms") 16 | environment.process_exit_code = 1 17 | else: 18 | environment.process_exit_code = 0 19 | 20 | class QuickStartUser(SequentialTaskSet): 21 | wait_time = between(0.1,0.2) 22 | 23 | @task 24 | def captcha(self): 25 | captcha_params = {"level":"debug","media":"image/png","input_type":"text", "size":"350x100"} 26 | 27 | with self.client.post(path="/v2/captcha", json=captcha_params, name="/captcha", catch_response = True) as resp: 28 | if resp.status_code != 200: 29 | resp.failure("Status was not 200: " + resp.text) 30 | captchaJson = resp.json() 31 | uuid = captchaJson.get("id") 32 | if not uuid: 33 | resp.failure("uuid not returned on /captcha endpoint: " + resp.text) 34 | 35 | with self.client.get(path="/v2/media?id=%s" % uuid, name="/media", stream=True, catch_response = True) as resp: 36 | if resp.status_code != 200: 37 | resp.failure("Status was not 200: " + resp.text) 38 | 39 | media = resp.content 40 | 41 | ocrAnswer = self.solve(uuid, media) 42 | 43 | answerBody = {"answer": ocrAnswer,"id": uuid} 44 | with self.client.post(path='/v2/answer', json=answerBody, name="/answer", catch_response=True) as resp: 45 | if resp.status_code != 200: 46 | resp.failure("Status was not 200: " + resp.text) 47 | else: 48 | if resp.json().get("result") != "True": 49 | resp.failure("Answer was not accepted: " + ocrAnswer) 50 | 51 | def solve(self, uuid, media): 52 | mediaFileName = "tests/test-%s.png" % uuid 53 | with open(mediaFileName, "wb") as f: 54 | f.write(media) 55 | #ocrResult = subprocess.Popen("gocr %s" % mediaFileName, shell=True, stdout=subprocess.PIPE) 56 | ocrResult = subprocess.Popen("tesseract %s stdout -l eng" % mediaFileName, shell=True, stdout=subprocess.PIPE) 57 | ocrAnswer = ocrResult.stdout.readlines()[0].strip().decode() 58 | return ocrAnswer 59 | 60 | 61 | 62 | class User(FastHttpUser): 63 | wait_time = between(0.1,0.2) 64 | tasks = [QuickStartUser] 65 | host = "http://localhost:8888" 66 | -------------------------------------------------------------------------------- /tests/locustfile.py: -------------------------------------------------------------------------------- 1 | from locust import task, between, SequentialTaskSet 2 | from locust.contrib.fasthttp import FastHttpUser 3 | from locust import events 4 | import json 5 | import logging 6 | 7 | @events.quitting.add_listener 8 | def _(environment, **kw): 9 | if environment.stats.total.fail_ratio > 0.02: 10 | logging.error("Test failed due to failure ratio > 2%") 11 | environment.process_exit_code = 1 12 | elif environment.stats.total.avg_response_time > 300: 13 | logging.error("Test failed due to average response time ratio > 300 ms") 14 | environment.process_exit_code = 1 15 | elif environment.stats.total.get_response_time_percentile(0.95) > 800: 16 | logging.error("Test failed due to 95th percentile response time > 800 ms") 17 | environment.process_exit_code = 1 18 | else: 19 | environment.process_exit_code = 0 20 | 21 | class QuickStartUser(SequentialTaskSet): 22 | wait_time = between(0.1,0.2) 23 | 24 | @task 25 | def captcha(self): 26 | # TODO: Iterate over parameters for a more comprehensive test 27 | captcha_params = {"level":"easy","media":"image/png","input_type":"text", "size":"350x100"} 28 | 29 | resp = self.client.post(path="/v2/captcha", json=captcha_params, name="/captcha") 30 | if resp.status_code != 200: 31 | print("\nError on /captcha endpoint: ") 32 | print(resp) 33 | print(resp.text) 34 | print("----------------END.CAPTCHA-------------------\n\n") 35 | 36 | uuid = json.loads(resp.text).get("id") 37 | answerBody = {"answer": "qwer123","id": uuid} 38 | 39 | resp = self.client.get(path="/v2/media?id=%s" % uuid, name="/media") 40 | if resp.status_code != 200: 41 | print("\nError on /media endpoint: ") 42 | print(resp) 43 | print(resp.text) 44 | print("----------------END.MEDIA-------------------\n\n") 45 | 46 | resp = self.client.post(path='/v2/answer', json=answerBody, name="/answer") 47 | if resp.status_code != 200: 48 | print("\nError on /answer endpoint: ") 49 | print(resp) 50 | print(resp.text) 51 | print("----------------END.ANSWER-------------------\n\n") 52 | 53 | 54 | class User(FastHttpUser): 55 | wait_time = between(0.1,0.2) 56 | tasks = [QuickStartUser] 57 | host = "http://localhost:8888" 58 | -------------------------------------------------------------------------------- /tests/run.sh: -------------------------------------------------------------------------------- 1 | set -ex 2 | 3 | python3 -m venv testEnv 4 | source ./testEnv/bin/activate 5 | pip install locust 6 | mkdir -p data/ 7 | java -jar target/scala-3.6.4/LibreCaptcha.jar & 8 | JAVA_PID=$! 9 | sleep 4 10 | 11 | locust --only-summary --headless -u 300 -r 100 --run-time 4m --stop-timeout 30 -f tests/locustfile.py 12 | status=$? 13 | 14 | if [ $status != 0 ]; then 15 | exit $status 16 | fi 17 | 18 | kill $JAVA_PID 19 | sleep 4 20 | 21 | echo Run functional test 22 | cp data/config.json data/config.json.bak 23 | cp tests/debug-config.json data/config.json 24 | 25 | java -jar target/scala-3.6.4/LibreCaptcha.jar & 26 | JAVA_PID=$! 27 | sleep 4 28 | 29 | locust --only-summary --headless -u 1 -r 1 --run-time 1m --stop-timeout 30 -f tests/locustfile-functional.py 30 | status=$? 31 | mv data/config.json.bak data/config.json 32 | 33 | kill $JAVA_PID 34 | exit $status 35 | --------------------------------------------------------------------------------