├── .dockerignore ├── .env.example ├── .github └── workflows │ ├── ci.yaml │ └── image-push.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── bin ├── build_nginx_conf.sh ├── compile_templates.sh ├── psqldef ├── setup.sh └── start.sh ├── compose.yml ├── misc ├── compile_templates.awk ├── default.css ├── nginx.default.conf.template └── variable_convention_validator.awk ├── schema.sql ├── src ├── controller │ ├── GET.awk │ ├── NOTFOUND.awk │ ├── _.GET.awk │ ├── _.posts._.GET.awk │ ├── _.rss..xml.GET.awk │ ├── _.styles..css.GET.awk │ ├── api.v1.accounts.GET.awk │ ├── api.v1.editor.GET.awk │ ├── api.v1.editor.posts.GET.awk │ ├── api.v1.editor.posts.POST.awk │ ├── api.v1.editor.posts._.GET.awk │ ├── api.v1.images.uploading-sign.POST.awk │ ├── authed.GET.awk │ ├── authed.posts.GET.awk │ ├── authed.posts.delete.GET.awk │ ├── authed.posts.edit.GET.awk │ ├── authed.posts.edit.POST.awk │ ├── authed.posts.new.GET.awk │ ├── authed.posts.new.POST.awk │ ├── authed.settings.GET.awk │ ├── authed.settings.POST.awk │ ├── authed.styles.GET.awk │ ├── authed.styles.POST.awk │ ├── login.GET.awk │ ├── logout.POST.awk │ ├── oauth_callback.GET.awk │ └── test.GET.awk ├── entrypoint.awk ├── lib │ ├── aes256.awk │ ├── awss3.awk │ ├── base64.awk │ ├── datetime.awk │ ├── environ.awk │ ├── error.awk │ ├── github.awk │ ├── hmac.awk │ ├── html.awk │ ├── http.awk │ ├── json.awk │ ├── logger.awk │ ├── pgsql.awk │ ├── router.awk │ ├── shell.awk │ ├── text.awk │ ├── url.awk │ └── uuid.awk ├── middleware │ ├── auth.awk │ ├── httpjson.awk │ └── template.awk ├── model │ ├── account.awk │ ├── blog.awk │ ├── post.awk │ ├── signin.awk │ └── stylesheet.awk ├── vendor │ └── .gitignore └── version.awk ├── static ├── assets │ ├── awkblog-icon.png │ ├── github-mark.svg │ ├── photo-up.svg │ └── uploadImage.js └── favicon.ico ├── test ├── integration │ ├── healthcheck.yaml │ └── start-login-flow.yaml ├── integrationtest.sh ├── lib │ ├── aes256.awk │ ├── awss3.awk │ ├── base64.awk │ ├── hmac.awk │ ├── http.awk │ ├── router.awk │ ├── shell.awk │ ├── url.awk │ └── uuid.awk ├── template │ ├── components │ │ └── head.html │ ├── dist │ │ └── _account_name │ │ │ ├── get.html │ │ │ └── posts │ │ │ └── _id │ │ │ └── get.html │ └── pages │ │ └── _account_name │ │ ├── get.html │ │ └── posts │ │ └── _id │ │ └── get.html ├── templatetest.sh ├── testutil.awk └── unittest.sh └── view ├── components ├── _account_name │ ├── footer.html │ ├── head.html │ └── header.html └── authed │ ├── article_editor.html │ ├── console_head.html │ └── header.html ├── i18n ├── en.yaml └── ja.yaml └── pages ├── 404.html ├── _account_name ├── get.html ├── posts │ └── _id │ │ └── get.html └── rss.xml │ └── get.xml ├── authed ├── get.html ├── posts │ ├── edit │ │ └── get.html │ ├── get.html │ └── new │ │ └── get.html ├── settings │ └── get.html └── styles │ └── get.html └── get.html /.dockerignore: -------------------------------------------------------------------------------- 1 | .env* 2 | .git 3 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | OAUTH_CLIENT_ID=clientkey 2 | OAUTH_CLIENT_SECRET=clientsecret 3 | OAUTH_CALLBACK_URI=http://localhost:4567/oauth-callback 4 | AWKBLOG_HOSTNAME=http://localhost:4567 5 | GITHUB_LOGIN_SERVER=http://localhost:8080 6 | GITHUB_API_SERVER=http://localhost:8080 7 | NOT_USE_AWS_S3=1 8 | AWKBLOG_WORKERS=40 9 | AWKBLOG_LANG=en 10 | # AWS_ACCESS_KEY_ID= 11 | # AWS_BUCKET= 12 | # AWS_REGION= 13 | # AWS_SECRET_ACCESS_KEY= 14 | # S3_BUCKET_ENDPOINT=https://bucketname.s3.amazonaws.com 15 | # S3_ASSET_HOST=https://bucketname.s3.amazonaws.com 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | 4 | name: Unit Tests 5 | 6 | jobs: 7 | ci: 8 | runs-on: ubuntu-latest 9 | container: ghcr.io/yammerjp/gawk-pgsql 10 | 11 | steps: 12 | - name: Check out repository code 13 | uses: actions/checkout@v3 14 | - name: Install dependencies 15 | run: | 16 | apt-get update -y && apt-get install -y uuid-runtime curl 17 | - name: Run unit tests 18 | run: ./test/unittest.sh 19 | - name: Run template tests 20 | run: ./test/templatetest.sh 21 | -------------------------------------------------------------------------------- /.github/workflows/image-push.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | name: Docker Build and Push 7 | 8 | jobs: 9 | publish_docker_image: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | packages: write 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Login to GitHub Container Registry 17 | uses: docker/login-action@v2 18 | with: 19 | registry: ghcr.io 20 | username: ${{ github.actor }} 21 | password: ${{ secrets.GITHUB_TOKEN }} 22 | - name: Build Docker Image 23 | run: | 24 | docker build --tag ghcr.io/$GITHUB_ACTOR/awkblog:latest \ 25 | --tag ghcr.io/$GITHUB_ACTOR/awkblog:$(echo $GITHUB_SHA | head -c7) \ 26 | --build-arg VERSION="0.0.2+$(echo $GITHUB_SHA | head -c7)" \ 27 | . 28 | - name: Push Docker Image 29 | run: | 30 | docker push ghcr.io/$GITHUB_ACTOR/awkblog:latest 31 | docker push ghcr.io/$GITHUB_ACTOR/awkblog:$(echo $GITHUB_SHA | head -c7) 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env* 2 | src/_compiled_templates.awk 3 | supervisord.pid 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/yammerjp/gawk-pgsql 2 | # https://github.com/yammerjp/gawk-pgsql-docker 3 | ARG VERSION="0.0.2-dev+with-nginx" 4 | 5 | RUN apt-get update -y && apt-get install -y \ 6 | uuid-runtime \ 7 | curl \ 8 | nginx \ 9 | supervisor \ 10 | gettext-base\ 11 | && apt-get clean \ 12 | && rm -rf /var/lib/apt/lists/* 13 | 14 | WORKDIR /app 15 | COPY ./ /app 16 | 17 | RUN rm -r /var/www/html 18 | RUN ln -sf /app/static /var/www/html 19 | 20 | RUN /app/bin/setup.sh 21 | 22 | RUN echo "function getAwkblogVersion() { return \""${VERSION}"\" }" > /app/src/version.awk 23 | 24 | STOPSIGNAL SIGQUIT 25 | 26 | ENTRYPOINT "/app/bin/start.sh" 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Keisuke Nakayama 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | up: 2 | docker compose up -d 3 | log: 4 | docker compose logs -f 5 | restart: 6 | docker compose restart gawk 7 | down: 8 | docker compose down 9 | db: 10 | docker compose exec -e PGPASSWORD=passw0rd db /bin/bash -c 'psql -U postgres -d postgres' 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # awkblog 2 | 3 | awkblog is an AWK-based blogging platform that demonstrates AWK's capability to build full-fledged web applications. It features GitHub OAuth, Markdown support, and optional S3 image hosting, showcasing that AWK can be used for more than just text processing. 4 | 5 |
6 | awkblog logo 7 |
8 | 9 | ## Getting Started 10 | 11 | ```shell 12 | 13 | ./bin/setup.sh 14 | cp .env.example .env 15 | # By default, login uses a mock server. 16 | # If you want to use a real GitHub account, set up the GitHub OAuth App and configure your confidential information. 17 | # https://github.com/settings/applications/new 18 | # Authorization callback URL: http://localhost:4567/oauth-callback 19 | # Write OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET 20 | # vim .env 21 | docker compose up -d 22 | # open browser localhost:4567 23 | ``` 24 | 25 | 26 | ## Features 27 | 28 | - Blog platform built entirely with AWK 29 | - GitHub OAuth authentication 30 | - Markdown support for posts 31 | - Custom CSS styling for each blog 32 | - RSS feed generation 33 | - Image uploads to S3 (optional) 34 | - Multi-user support 35 | 36 | ## Architecture 37 | 38 | awkblog is a web application written primarily in AWK, with some shell scripting for setup and management. It uses: 39 | 40 | - AWK for the core application logic and request handling 41 | - PostgreSQL for data storage 42 | - Nginx for reverse proxy 43 | - Docker for containerization and easy deployment 44 | - GitHub OAuth for authentication 45 | - Amazon S3 for image storage (optional) 46 | 47 | ## Configuration 48 | 49 | The main configuration is done through environment variables. Copy `.env.example` to `.env` and adjust the values as needed: 50 | 51 | - `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, and `OAUTH_CALLBACK_URL`: GitHub OAuth configuration 52 | - `GITHUB_LOGIN_SERVER`: The URL of the GitHub login server for GitHub OAuth 53 | - `GITHUB_API_SERVER`: The URL of the GitHub API server for GitHub OAuth 54 | - `AWKBLOG_HOSTNAME`: The public URL of your awkblog instance 55 | - `AWKBLOG_WORKERS`: The number of request handler processes 56 | - `NOT_USE_AWS_S3`: If you don't want to use AWS S3 for image uploads, set this to `1` 57 | - `AWS_*` and `S3_*`: Amazon S3 configuration (if using S3 for image uploads) 58 | - `AWKBLOG_LANG`: The language of the blog. Currently, only `en` (English) and `ja` (Japanese) are supported. 59 | 60 | ## Development 61 | 62 | To set up a development environment: 63 | 64 | 1. Clone the repository 65 | 2. Run `./bin/setup.sh` to install dependencies 66 | 3. Copy `.env.example` to `.env` and configure as needed 67 | 4. Run `docker compose up -d` to start the development environment 68 | 5. Access the application at `http://localhost:4567` 69 | 70 | ## Contributing 71 | 72 | Contributions are welcome! Please feel free to submit a Pull Request. 73 | 74 | ## License 75 | 76 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 77 | 78 | -------------------------------------------------------------------------------- /bin/build_nginx_conf.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | REPOSITORY_ROOT="$(dirname "$0")/.." 6 | cd "$REPOSITORY_ROOT" 7 | 8 | echo "misc/nginx.default.conf.template" | gawk -f misc/compile_templates.awk > /tmp/nginx.default.conf.template.awk 9 | gawk -f /tmp/nginx.default.conf.template.awk -f <(echo 'BEGIN {printf "%s", compiled_templates::render("misc/nginx.default.conf.template")}') 10 | 11 | 12 | -------------------------------------------------------------------------------- /bin/compile_templates.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | REPOSITORY_ROOT="$(dirname "$0")/.." 5 | cd "$REPOSITORY_ROOT/view" 6 | 7 | if [ "$AWKBLOG_LANG" = "ja" ]; then 8 | export AWKBLOG_LANGFILE="i18n/ja.yaml" 9 | else 10 | export AWKBLOG_LANGFILE="i18n/en.yaml" 11 | fi 12 | 13 | find ./ -type f | grep -e '\.html$' | sed 's#^./##g' | awk -f ../misc/compile_templates.awk > "../src/_compiled_templates.awk" 14 | -------------------------------------------------------------------------------- /bin/psqldef: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yammerjp/awkblog/8212140fb4f6444dfb54ef3b53ece29adaef49e0/bin/psqldef -------------------------------------------------------------------------------- /bin/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | REPOSITORY_ROOT="$(dirname "$0")/.." 5 | cd "$REPOSITORY_ROOT" 6 | 7 | curl -sL https://raw.githubusercontent.com/yammerjp/md2html/main/markdown.awk > src/vendor/markdown.awk 8 | -------------------------------------------------------------------------------- /bin/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | REPOSITORY_ROOT="$(dirname "$0")/.." 5 | cd "$REPOSITORY_ROOT" 6 | 7 | if [ "$AWKBLOG_WORKERS" == "" ]; then 8 | AWKBLOG_WORKERS=1 9 | fi 10 | 11 | echo "start.sh: Build templates to src/_compiled_templates.awk" 12 | bin/compile_templates.sh 13 | 14 | echo "start.sh: Migrate Database Schema" 15 | /app/bin/psqldef --user="$POSTGRES_USER" --password="$POSTGRES_PASSWORD" --host="$POSTGRES_HOSTNAME" --file=schema.sql "$POSTGRES_DATABASE" 16 | 17 | echo "start.sh: Start Web Application" 18 | 19 | for AWKBLOG_PORT in $(seq 40001 $((40000 + AWKBLOG_WORKERS))); do 20 | cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 128 | AWKBLOG_PORT=$AWKBLOG_PORT gawk \ 21 | $(find src/ -type f | gawk '/\.awk$/{ printf " -f %s", $0 }') \ 22 | & 23 | echo "start.sh: started gawk process, port: $AWKBLOG_PORT" 24 | done 25 | 26 | ./bin/build_nginx_conf.sh > /etc/nginx/conf.d/default.conf 27 | echo "start.sh: built /etc/nginx/conf.d/default.conf" 28 | 29 | exec nginx -g "daemon off;" 30 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: . 5 | depends_on: 6 | - db 7 | - oauth-mock 8 | volumes: 9 | - ./:/app 10 | ports: 11 | - "0.0.0.0:4567:8080" 12 | env_file: 13 | - .env 14 | environment: 15 | - POSTGRES_HOSTNAME=db 16 | - POSTGRES_USER=postgres 17 | - POSTGRES_PASSWORD=passw0rd 18 | - POSTGRES_DATABASE=postgres 19 | - PORT=8080 20 | - ENCRYPTION_KEY=fvrcWCKkyxsmOkvmaxFLsrEXgrWqGrsh 21 | db: 22 | image: postgres:14 23 | volumes: 24 | - db-store:/var/lib/postgresql/data 25 | environment: 26 | - POSTGRES_PASSWORD=passw0rd 27 | - POSTGRES_USER=postgres 28 | oauth-mock: 29 | # https://github.com/yammerjp/github-oauth2-login-mock-server 30 | image: ghcr.io/yammerjp/github-oauth2-login-mock-server:latest 31 | ports: 32 | - "0.0.0.0:8080:8080" 33 | environment: 34 | - PORT=8080 35 | env_file: 36 | - .env 37 | volumes: 38 | db-store: 39 | -------------------------------------------------------------------------------- /misc/compile_templates.awk: -------------------------------------------------------------------------------- 1 | function codegen_i18n( argname, filename, value, previousFs) { 2 | filename = ENVIRON["AWKBLOG_LANGFILE"] 3 | 4 | if (filename == "") { 5 | return 6 | } 7 | 8 | previousFs = FS 9 | FS=":" 10 | while((getline < filename) > 0) { 11 | argname = $1 12 | value = substr($2, 2) 13 | print " t[\"" argname "\"] = \"" value "\"" 14 | } 15 | FS = previousFs 16 | close(filename) 17 | } 18 | 19 | function codegen_header() { 20 | print "@namespace \"compiled_templates\"" 21 | print "" 22 | print "function render(path, v , ret, t) {" 23 | codegen_i18n() 24 | print " switch(path) {" 25 | } 26 | 27 | function codegen_by_file(filename) { 28 | printf " case \"%s\":\n", filename 29 | 30 | RS = "<%" 31 | 32 | while((getline < filename) > 0) { 33 | if (RS == "<%") { # out of <% %> 34 | # escape charactors 35 | gsub("\\\\", "\\\\", $0) 36 | gsub("\"", "\\\"", $0) 37 | gsub("\n", "\\n", $0) 38 | 39 | print " ret = ret sprintf(\"%s\", \"" $0 "\")" 40 | 41 | RS = "%>" 42 | } else { # in <% %> 43 | switch ($1) { 44 | case "#include": 45 | if (!($2 in TemplateFileMap)) { 46 | printf "unknown file path, %s\n", $2 > "/dev/stderr" 47 | exit 1 48 | } 49 | print " ret = ret render(\"" $2 "\", v)" 50 | break 51 | case "##": 52 | break 53 | case "=": 54 | $1 = "" 55 | print " ret = ret sprintf(\"%s\", " $0 ")" 56 | break 57 | default: 58 | print $0 59 | break 60 | } 61 | RS = "<%" 62 | } 63 | } 64 | close(filename) 65 | 66 | RS="\n" 67 | 68 | print " break" 69 | } 70 | 71 | function codegen_footer() { 72 | print " default:" 73 | print " print \"unknown path\" > \"/dev/stderr\"" 74 | print " exit 1" 75 | print " }" 76 | print " return ret" 77 | print "}" 78 | } 79 | 80 | { 81 | filename = $0 82 | if (filename in TemplateFileMap) { 83 | # detect duplication 84 | next 85 | } 86 | TemplateFileMap[filename] = 1 87 | } 88 | 89 | END { 90 | codegen_header() 91 | for(path in TemplateFileMap) { 92 | codegen_by_file(path) 93 | } 94 | codegen_footer() 95 | } 96 | -------------------------------------------------------------------------------- /misc/default.css: -------------------------------------------------------------------------------- 1 | img { 2 | max-width: 100%; 3 | } 4 | 5 | .blog-page-top { 6 | margin: 0 auto; 7 | max-width: 800px; 8 | padding: 0 16px; 9 | } 10 | 11 | header { 12 | text-align: center; 13 | } 14 | 15 | main > h1 { 16 | color: #111111; 17 | } 18 | 19 | .blog-header-link, .blog-header-link:visited { 20 | text-decoration: none; 21 | color: #111111; 22 | } 23 | 24 | 25 | .blog-header-link:hover, .blog-header-link:focus { 26 | text-decoration: none; 27 | color: #222222; 28 | } 29 | 30 | main { 31 | margin: 24px 0; 32 | } 33 | 34 | .post { 35 | margin: 24px 0; 36 | } 37 | 38 | .post-link { 39 | text-decoration: none; 40 | color: #333333; 41 | } 42 | .post-link:hover, .post-link:focus { 43 | text-decoration: none; 44 | color: #666666; 45 | } 46 | .post-link:visited { 47 | text-decoration: none; 48 | color: #999999; 49 | } 50 | 51 | .post-link .post-title { 52 | font-size: 2em; 53 | text-decoration: underline; 54 | color: #111111; 55 | } 56 | 57 | footer { 58 | margin-top: 72px; 59 | margin-bottom: 24px; 60 | text-align: center; 61 | } 62 | 63 | footer .account-name { 64 | font-size: 1.5em; 65 | } 66 | 67 | .github-link, .github-link:visited { 68 | text-decoration: none; 69 | color: #111111; 70 | } 71 | .github-link:focus, .github-link:hover { 72 | text-decoration: none; 73 | color: #222222; 74 | } 75 | 76 | footer .account-name::before { 77 | content: "@" 78 | } 79 | 80 | footer .powered-by { 81 | margin: 24px; 82 | } 83 | 84 | footer img { 85 | max-width: 120px; 86 | aspect-ratio: 1; 87 | border-radius: 50%; 88 | } 89 | 90 | a { 91 | color: #FFAC1C; 92 | } 93 | a:hover, a:focus { 94 | color: #FFBF00; 95 | } 96 | a:visited { 97 | color: #CC7722; 98 | } 99 | -------------------------------------------------------------------------------- /misc/nginx.default.conf.template: -------------------------------------------------------------------------------- 1 | access_log /dev/stdout; 2 | error_log /dev/stderr; 3 | 4 | client_body_temp_path /var/tmp/nginx; 5 | client_body_buffer_size 1M; 6 | 7 | map $request_body $modified_body { 8 | default "$request_body "; 9 | '' $request_body; 10 | } 11 | 12 | root /var/www/html; 13 | 14 | upstream awkblog { 15 | <% for(i=40001; i<= 40000 + ENVIRON["AWKBLOG_WORKERS"]; i++) { %> 16 | server 127.0.0.1:<%= i %> max_conns=1; 17 | <% } %> 18 | } 19 | 20 | server { 21 | listen <%= ENVIRON["PORT"] %>; 22 | 23 | location /assets { 24 | alias /var/www/html/assets; 25 | } 26 | 27 | location /favicon.ico { 28 | alias /var/www/html/favicon.ico; 29 | } 30 | 31 | location / { 32 | proxy_http_version 1.0; 33 | proxy_pass http://awkblog; 34 | proxy_set_header Host $host; 35 | proxy_set_header X-Real-IP $remote_addr; 36 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 37 | proxy_set_header X-Forwarded-Proto $scheme; 38 | proxy_set_header X-Body-Leftover "512"; 39 | 40 | proxy_set_body $modified_body; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /misc/variable_convention_validator.awk: -------------------------------------------------------------------------------- 1 | 2 | # Usage: 3 | # git ls-files \ 4 | # | grep -e '\.awk$' 5 | # | xargs -I{} gawk -f misc/variable_convention_validator.awk {} 6 | 7 | function find_in_array(target, source, i) { 8 | for (i in source) { 9 | if (target == source[i]) { 10 | return 1 11 | } 12 | } 13 | return 0 14 | } 15 | 16 | # detect function declaration 17 | /^[[:blank:]]*function[[:blank:]]+[a-zA-Z0-9_]+[[:blank:]]*\([a-zA-Z0-9_, \t]*\)/ { 18 | FuncnameBegin = index($0, "function") + 9 19 | ParenBegin = index($0, "(") 20 | ParenEnd = index($0, ")") 21 | Args = substr($0, ParenBegin + 1, ParenEnd - ParenBegin - 1) 22 | 23 | InFunction = 1 24 | FunctionName = substr($0, FuncnameBegin, ParenBegin - FuncnameBegin) 25 | next 26 | } 27 | 28 | # detect function end 29 | InFunction && /^}$/ { 30 | InFunction = 0 31 | 32 | Args = "" 33 | next 34 | } 35 | 36 | InMultiLineString { 37 | # remove escaped double quote 38 | gsub(/[^\\]\\"/g, " ") 39 | 40 | # remove string token 41 | gsub(/"[^"]*"/g, " ") 42 | 43 | if (match($0, "\"")) { 44 | # finish multi-line string 45 | InMultiLineString = 0 46 | gsub(/^[^"]*"/, " ") 47 | } 48 | } 49 | 50 | !InMultiLineString && InFunction { 51 | gsub("@namespace", " ") 52 | 53 | # remove escaped characters 54 | gsub(/\\./, " ") 55 | 56 | StringIsLeftOfRegularExpression = match($0, "\"") < match($0, "/") 57 | if (StringIsLeftOfRegularExpression) { 58 | # remove string token 59 | gsub(/"[^"]*"/, " ") 60 | } 61 | 62 | # remove regular expression 63 | gsub(/\/[^\/\\]*\[[^\]]*\]/, "/ ") 64 | gsub(/\/[^\/\\]*\//, " ") 65 | 66 | if (!StringIsLeftOfRegularExpression) { 67 | # remove string token 68 | gsub(/"[^"]*"/, " ") 69 | } 70 | 71 | 72 | if (match($0, "\"")) { 73 | # start multi-line string 74 | InMultiLineString = 1 75 | gsub(/"[^"]+$/, " ") 76 | } 77 | 78 | # remove comment 79 | gsub(/#.*$/, " ") 80 | 81 | # remove namespace 82 | gsub(/[a-zA-Z0-9_]*::/, "") 83 | 84 | # remove function call 85 | gsub(/[a-zA-Z0-9_]*\(/, "") 86 | 87 | # remove operator 88 | gsub(/[^a-zA-Z0-9_]/, " ") 89 | 90 | for (i = 1; i <= NF; i++) { 91 | switch ($i) { 92 | # reserved words 93 | case "break": 94 | case "case": 95 | case "close": 96 | case "continue": 97 | case "default": 98 | case "delete": 99 | case "else": 100 | case "exit": 101 | case "for": 102 | case "function": 103 | case "getline": 104 | case "if": 105 | case "in": 106 | case "next": 107 | case "print": 108 | case "printf": 109 | case "return": 110 | case "switch": 111 | case "while": 112 | break 113 | 114 | # not reserved words 115 | default: 116 | if (match($i, /^[0-9]+$/)) { 117 | # decimal number 118 | break 119 | } 120 | if (match($i, /^0x[0-9A-Fa-f]+$/)) { 121 | # hexadecimal number 122 | break 123 | } 124 | VariableBeginUpperCase = match($i, /^[A-Z]/) 125 | VariableIncludesFunctionArguments = match(Args, $i) 126 | if (VariableBeginUpperCase && VariableIncludesFunctionArguments) { 127 | # global variable should be started with upper case 128 | print "global variable should start with upper case: \"" $i "\" at " FILENAME ":" NR " in " FunctionName "()" 129 | } 130 | if (!VariableBeginUpperCase && !VariableIncludesFunctionArguments) { 131 | # local variable should be defined in function arguments 132 | print "local variable should be defined in function arguments: \"" $i "\" at " FILENAME ":" NR " in " FunctionName "()" 133 | } 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE accounts ( 2 | id bigint PRIMARY KEY NOT NULL, 3 | name text, 4 | created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP 5 | ); 6 | 7 | CREATE TABLE posts ( 8 | id SERIAL PRIMARY KEY NOT NULL, 9 | account_id bigint NOT NULL REFERENCES accounts(id), 10 | title text, 11 | content text, 12 | created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP 13 | ); 14 | 15 | CREATE TABLE blogs ( 16 | id SERIAL PRIMARY KEY NOT NULL, 17 | account_id bigint NOT NULL REFERENCES accounts(id), 18 | title text, 19 | description text, 20 | author_profile text, 21 | coverimage VARCHAR(255), 22 | created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP 23 | ); 24 | 25 | CREATE UNIQUE INDEX uk_blogs_account_id ON blogs(account_id); 26 | 27 | CREATE TABLE sessions ( 28 | id CHAR(36) PRIMARY KEY, 29 | account_id bigint NOT NULL REFERENCES accounts(id), 30 | created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, 31 | last_accessed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP 32 | ); 33 | 34 | CREATE TABLE stylesheets ( 35 | id SERIAL PRIMARY KEY NOT NULL, 36 | account_id bigint NOT NULL REFERENCES accounts(id), 37 | content text, 38 | created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP 39 | ); 40 | 41 | CREATE UNIQUE INDEX uk_stylesheets_account_id ON stylesheets(account_id); 42 | -------------------------------------------------------------------------------- /src/controller/GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function get() { 4 | auth::redirectIfSuccessToVerify() 5 | 6 | template::render("get.html") 7 | } 8 | -------------------------------------------------------------------------------- /src/controller/NOTFOUND.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function notfound( v) { 4 | template::render("404.html", v, 404); 5 | } 6 | -------------------------------------------------------------------------------- /src/controller/_.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function _account_name__get( splitted, params, query, rows, id, html, blog, posts, templateVars, description, accountName, accountId) { 4 | split(http::getPath(), splitted, "@") 5 | accountName = splitted[2] 6 | 7 | accountId = model::getAccountId(accountName) 8 | if (accountId == "") { 9 | notfound() 10 | return 11 | } 12 | model::getBlog(blog, accountId) 13 | model::getPosts(posts, accountId) 14 | 15 | templateVars["account_name"] = html::escape(accountName) 16 | templateVars["blog_title"] = html::escape(blog["title"]) 17 | templateVars["blog_description"] = markdown::parseMultipleLines(html::escape(blog["description"])) 18 | templateVars["blog_author_profile"] = markdown::parseMultipleLines(html::escape(blog["author_profile"])) 19 | 20 | for(i = 1; i <= length(posts); i++) { 21 | templateVars["posts"][i]["id"] = html::escape(posts[i]["id"]) 22 | templateVars["posts"][i]["title"] = html::escape(posts[i]["title"]) 23 | templateVars["posts"][i]["description"] = text::headAbout500(html::toText(markdown::parseMultipleLines(html::escape(posts[i]["content"])))) "..." 24 | } 25 | 26 | template::render("_account_name/get.html", templateVars); 27 | } 28 | -------------------------------------------------------------------------------- /src/controller/_.posts._.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function _account_name__posts___id__get( splitted, params, query, rows, id, html, blog, post, templateVars, atAccountName, accountName, accountId, at, postId) { 4 | split(http::getPath(), splitted, "/") 5 | atAccountName = splitted[2] 6 | postId = splitted[4] 7 | 8 | at = substr(atAccountName, 1, 1) 9 | if (at != "@") { 10 | notfound() 11 | return 12 | } 13 | accountName = substr(atAccountName, 2) 14 | 15 | accountId = model::getAccountId(accountName) 16 | if (accountId == "") { 17 | notfound() 18 | return 19 | } 20 | 21 | templateVars["account_name"] = html::escape(accountName) 22 | model::getBlog(blog, accountId) 23 | templateVars["blog_title"] = html::escape(blog["title"]) 24 | templateVars["blog_description"] = markdown::parseMultipleLines(html::escape(blog["description"])) 25 | templateVars["blog_author_profile"] = markdown::parseMultipleLines(html::escape(blog["author_profile"])) 26 | 27 | model::getPost(post, postId) 28 | if ("error" in post) { 29 | notfound() 30 | return 31 | } 32 | templateVars["id"] = html::escape(post["id"]) 33 | templateVars["title"] = html::escape(post["title"]) 34 | templateVars["content"] = markdown::parseMultipleLines(html::escape(post["content"])) 35 | templateVars["created_at"] = html::escape(post["created_at"]) 36 | 37 | template::render("_account_name/posts/_id/get.html", templateVars); 38 | } 39 | -------------------------------------------------------------------------------- /src/controller/_.rss..xml.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function _account_name__rss_xml__get( path_parts, splitted, params, query, rows, id, html, result, templateVars, blog, accountName, accountId) { 4 | split(http::getPath(), path_parts, "/") 5 | split(path_parts[2], splitted, "@") 6 | accountName = splitted[2] 7 | 8 | 9 | accountId = model::getAccountId(accountName) 10 | if (accountId == "") { 11 | notfound() 12 | return 13 | } 14 | 15 | model::getBlog(blog, accountId) 16 | templateVars["author_account_name"] = html::escape(accountName) 17 | templateVars["blog_title"] = html::escape(blog["title"]) 18 | templateVars["blog_description"] = html::escape(blog["description"]) 19 | templateVars["copyright"] = html::escape("Copyright 2024 " accountName) 20 | 21 | templateVars["account_url"] = http::getHostName() "/@" accountName 22 | 23 | model::getPosts(result, accountId) 24 | 25 | for(i = 1; i <= length(result); i++) { 26 | templateVars["posts"][i]["id"] = html::escape(result[i]["id"]) 27 | templateVars["posts"][i]["title"] = html::escape(result[i]["title"]) 28 | templateVars["posts"][i]["content"] = html::escape(markdown::parseMultipleLines(result[i]["content"])) 29 | templateVars["posts"][i]["created_at"] = html::escape(result[i]["created_at"]) # TODO: fix format 30 | } 31 | 32 | template::render("_account_name/rss.xml/get.xml", templateVars, 200, "xml"); 33 | } 34 | -------------------------------------------------------------------------------- /src/controller/_.styles..css.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function _account_name__style_css__get( path_parts, splitted, params, query, rows, id, html, result, templateVars, accountName, accountId) { 4 | split(http::getPath(), path_parts, "/") 5 | split(path_parts[2], splitted, "@") 6 | accountName = splitted[2] 7 | 8 | 9 | accountId = model::getAccountId(accountName) 10 | if (accountId == "") { 11 | notfound() 12 | return 13 | } 14 | 15 | http::setHeader("content-type", "text/css") 16 | http::send(200, model::getStylesheet(accountId)) 17 | } 18 | -------------------------------------------------------------------------------- /src/controller/api.v1.accounts.GET.awk: -------------------------------------------------------------------------------- 1 | @load "json" 2 | @namespace "controller" 3 | 4 | function api__v1__accounts__get( result) { 5 | model::getAccounts(result) 6 | if ("error" in result) { 7 | http::send(403) 8 | } 9 | httpjson::render(result) 10 | } 11 | -------------------------------------------------------------------------------- /src/controller/api.v1.editor.GET.awk: -------------------------------------------------------------------------------- 1 | @load "json" 2 | @namespace "controller" 3 | 4 | function api__v1__editor__get( result, accountId) { 5 | auth::forbiddenIfFailedToVerify() 6 | accountId = auth::getAccountId() 7 | 8 | model::getAccount(result, accountId) 9 | if (result["error"] != "" ) { 10 | http::send(403) 11 | } 12 | httpjson::render(result) 13 | } 14 | -------------------------------------------------------------------------------- /src/controller/api.v1.editor.posts.GET.awk: -------------------------------------------------------------------------------- 1 | @load "json" 2 | @namespace "controller" 3 | 4 | function api__v1__editor__posts__get( result, accountId) { 5 | auth::forbiddenIfFailedToVerify() 6 | accountId = auth::getAccountId() 7 | 8 | 9 | 10 | model::getPosts(result, accountId) 11 | httpjson::render(result) 12 | } 13 | -------------------------------------------------------------------------------- /src/controller/api.v1.editor.posts.POST.awk: -------------------------------------------------------------------------------- 1 | @load "json" 2 | @namespace "controller" 3 | 4 | function api__v1__editor__posts__post( req, accountId, title, content) { 5 | auth::forbiddenIfFailedToVerify() 6 | accountId = auth::getAccountId() 7 | 8 | json::from_json(http::HTTP_REQUEST["body"], req) 9 | 10 | title = req["title"] 11 | content = req["content"] 12 | 13 | if (title == "") { 14 | http::send(400) 15 | return 16 | } 17 | if (content == "") { 18 | http::send(400) 19 | return 20 | } 21 | 22 | model::createPost(title, content, accountId) 23 | http::send(201) 24 | return 25 | } 26 | -------------------------------------------------------------------------------- /src/controller/api.v1.editor.posts._.GET.awk: -------------------------------------------------------------------------------- 1 | @load "json" 2 | @namespace "controller" 3 | 4 | function api__v1__editor__posts___id__get( result, accountId, postId, splitted) { 5 | 6 | auth::forbiddenIfFailedToVerify() 7 | accountId = auth::getAccountId() 8 | 9 | split(http::getPath(), splitted, "/") 10 | # ex) /api/v1/editor/posts/33 11 | postId = splitted[6] 12 | 13 | model::getPost(result, postId) 14 | 15 | if (result["account_id"] != accountId) { 16 | http::send(403) 17 | } 18 | 19 | httpjson::render(result) 20 | } 21 | -------------------------------------------------------------------------------- /src/controller/api.v1.images.uploading-sign.POST.awk: -------------------------------------------------------------------------------- 1 | @load "json" 2 | @namespace "controller" 3 | 4 | function api__v1__images__uploading_sign__post( req, accountId, type, extension, now, key, sizeMin, sizeMax) { 5 | auth::forbiddenIfFailedToVerify() 6 | accountId = auth::getAccountId() 7 | 8 | json::from_json(http::HTTP_REQUEST["body"], req) 9 | 10 | type = req["type"] 11 | switch(type) { 12 | case "image/png": 13 | extension = "png" 14 | break 15 | case "image/jpeg": 16 | extension = "jpg" 17 | break 18 | case "image/gif": 19 | extension = "gif" 20 | break 21 | case "image/webp": 22 | extension = "webp" 23 | break 24 | default: 25 | http::send(400, "unknown file type") 26 | return 27 | } 28 | 29 | now = awk::systime() 30 | 31 | key = "img/" accountId "/" now substr(http::getRequestId(), 1, 8) "." extension 32 | 33 | sizeMin = 32 34 | sizeMax = 1024 * 1024 * 3 35 | 36 | http::setHeader("content-type", "application/json") 37 | http::send(200, awss3::buildPreSignedUploadParams(now, key, type, sizeMin, sizeMax )) 38 | } 39 | -------------------------------------------------------------------------------- /src/controller/authed.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function authed__get(variables) { 4 | auth::redirectIfFailedToVerify() 5 | 6 | variables["account_name"] = html::escape(auth::getUsername()) 7 | template::render("authed/get.html", variables) 8 | } 9 | -------------------------------------------------------------------------------- /src/controller/authed.posts.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function authed__posts__get( query, params, html, result, templateVars, accountId, i) { 4 | auth::redirectIfFailedToVerify() 5 | 6 | templateVars["account_name"] = html::escape(auth::getUsername()) 7 | 8 | accountId = auth::getAccountId() 9 | model::getPosts(result, accountId) 10 | 11 | for(i = 1; i <= length(result); i++) { 12 | templateVars["posts"][i]["id"] = html::escape(result[i]["id"]) 13 | templateVars["posts"][i]["title"] = html::escape(result[i]["title"]) 14 | templateVars["posts"][i]["content"] = markdown::parseMultipleLines(html::escape(result[i]["content"])) 15 | templateVars["posts"][i]["created_at"] = html::escape(result[i]["created_at"]) 16 | } 17 | 18 | template::render("authed/posts/get.html", templateVars) 19 | } 20 | -------------------------------------------------------------------------------- /src/controller/authed.posts.delete.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function authed__posts__delete__post( id, title, content, account_id, query, params, result, accountId) { 4 | http::guardCSRF() 5 | auth::redirectIfFailedToVerify() 6 | 7 | url::decodeWwwForm(result, http::HTTP_REQUEST["body"]) 8 | 9 | id = result["post_id"] + 0 10 | accountId = auth::getAccountId() 11 | 12 | model::deletePost(id, accountId) 13 | 14 | # TODO: flash message 15 | 16 | http::sendRedirect("/authed/posts") 17 | } 18 | -------------------------------------------------------------------------------- /src/controller/authed.posts.edit.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function authed__posts__edit__get( postId, result, accountId, variables) { 4 | auth::redirectIfFailedToVerify() 5 | 6 | variables["account_name"] = html::escape(auth::getUsername()) 7 | 8 | postId = http::getParameter("post_id") + 0 9 | accountId = auth::getAccountId() + 0 10 | 11 | model::getPostWithAccountId(result, postId, accountId) 12 | if ("error" in result) { 13 | http::send(403) 14 | return 15 | } 16 | variables["post_id"] = html::escape(result["id"]) 17 | variables["title"] = html::escape(result["title"]) 18 | variables["content"] = html::escape(result["content"]) 19 | 20 | template::render("authed/posts/edit/get.html", variables) 21 | } 22 | -------------------------------------------------------------------------------- /src/controller/authed.posts.edit.POST.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function authed__posts__edit__post( id, title, content, account_id, query, params, result, accountId) { 4 | http::guardCSRF() 5 | auth::redirectIfFailedToVerify() 6 | 7 | url::decodeWwwForm(result, http::HTTP_REQUEST["body"]) 8 | 9 | id = result["post_id"] + 0 10 | title = result["title"] 11 | content = result["content"] 12 | accountId = auth::getAccountId() 13 | 14 | model::updatePost(title, content, id, accountId) 15 | 16 | http::sendRedirect("/authed/posts") 17 | } 18 | -------------------------------------------------------------------------------- /src/controller/authed.posts.new.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function authed__posts__new__get(variables) { 4 | auth::redirectIfFailedToVerify() 5 | 6 | variables["account_name"] = html::escape(auth::getUsername()) 7 | 8 | variables["title"] = "" # default title 9 | variables["content"] = "" # default content 10 | 11 | template::render("authed/posts/new/get.html", variables) 12 | } 13 | -------------------------------------------------------------------------------- /src/controller/authed.posts.new.POST.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function authed__posts__new__post( title, content, accountId, query, params, result) { 4 | http::guardCSRF() 5 | auth::redirectIfFailedToVerify() 6 | 7 | url::decodeWwwForm(result, http::HTTP_REQUEST["body"]) 8 | 9 | title = result["title"] 10 | content = result["content"] 11 | accountId = auth::getAccountId() 12 | 13 | model::createPost(title, content, accountId) 14 | 15 | http::sendRedirect("/authed/posts") 16 | } 17 | -------------------------------------------------------------------------------- /src/controller/authed.settings.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function authed__settings__get(accountId, blog, variables) { 4 | auth::redirectIfFailedToVerify() 5 | 6 | variables["account_name"] = html::escape(auth::getUsername()) 7 | 8 | accountId = auth::getAccountId() 9 | model::getBlog(blog, accountId) 10 | variables["title"] = html::escape(blog["title"]) 11 | variables["description"] = html::escape(blog["description"]) 12 | variables["author_profile"] = html::escape(blog["author_profile"]) 13 | 14 | template::render("authed/settings/get.html", variables) 15 | } 16 | -------------------------------------------------------------------------------- /src/controller/authed.settings.POST.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function authed__settings__post( accountId, blog, result, title, description, authorProfile) { 4 | auth::redirectIfFailedToVerify() 5 | accountId = auth::getAccountId() + 0 6 | 7 | model::getBlog(blog, accountId) 8 | 9 | url::decodeWwwForm(result, http::HTTP_REQUEST["body"]) 10 | 11 | title = result["title"] 12 | description = result["description"] 13 | authorProfile = result["author_profile"] 14 | 15 | model::updateBlog(title, description, authorProfile, accountId) 16 | 17 | http::sendRedirect("/authed/settings") 18 | } 19 | -------------------------------------------------------------------------------- /src/controller/authed.styles.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function authed__styles__get(accountId, blog, variables) { 4 | auth::redirectIfFailedToVerify() 5 | 6 | variables["account_name"] = auth::getUsername() 7 | 8 | accountId = auth::getAccountId() 9 | variables["style"] = html::escape(model::getStylesheet(accountId)) 10 | 11 | template::render("authed/styles/get.html", variables) 12 | } 13 | -------------------------------------------------------------------------------- /src/controller/authed.styles.POST.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function authed__styles__post( accountId, blog, result, title, description, authorProfile) { 4 | auth::redirectIfFailedToVerify() 5 | accountId = auth::getAccountId() + 0 6 | 7 | model::getBlog(blog, accountId) 8 | 9 | url::decodeWwwForm(result, http::HTTP_REQUEST["body"]) 10 | model::updateStylesheet(result["style"], accountId) 11 | 12 | http::sendRedirect("/authed/styles") 13 | } 14 | -------------------------------------------------------------------------------- /src/controller/login.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function login__get( state, url) { 4 | state = uuid::gen() 5 | url = github::redirectUrl(state) 6 | http::setCookie("state", state) 7 | http::sendRedirect(url) 8 | } 9 | -------------------------------------------------------------------------------- /src/controller/logout.POST.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function logout__post() { 4 | auth::logout() 5 | http::sendRedirect("/") 6 | } 7 | -------------------------------------------------------------------------------- /src/controller/oauth_callback.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function oauth_callback__get( code, error, stateOnQuery, stateOnCookie, ret) { 4 | code = http::getParameter("code") 5 | error = http::getParameter("error") 6 | if (code == "" || error != "") { 7 | http::send(400, "failed to login") 8 | print "faild to login. code:" code " error:" error 9 | return 10 | } 11 | stateOnQuery = http::getParameter("state") 12 | stateOnCookie = http::getCookie("state") 13 | if (stateOnQuery == "" || stateOnQuery != stateOnCookie) { 14 | http::send(400, "invalid state") 15 | return 16 | } 17 | 18 | delete ret 19 | github::verify(ret, code) 20 | 21 | if (ret["error"] != "") { 22 | http::send(400, ret["error"]) 23 | return 24 | } 25 | 26 | auth::login(ret["id"], ret["loginname"]) 27 | http::sendRedirect("/authed") 28 | } 29 | -------------------------------------------------------------------------------- /src/controller/test.GET.awk: -------------------------------------------------------------------------------- 1 | @namespace "controller" 2 | 3 | function test__get() { 4 | http::send(200, "Hello, test!") 5 | } 6 | -------------------------------------------------------------------------------- /src/entrypoint.awk: -------------------------------------------------------------------------------- 1 | BEGIN { 2 | logger::setProcessIdentifier(http::getPort()) 3 | pgsql::createConnection() 4 | 5 | query = "SELECT count(id) as ids FROM posts;" 6 | pgsql::exec(query) 7 | rows = pgsql::fetchRows() 8 | logger::info("Database Healthcheck: count(posts.id) (rows:" rows ") ... " pgsql::fetchResult(0, "ids")) 9 | 10 | router::register("GET", "/", "controller::get") 11 | router::register("GET", "/test", "controller::test__get") 12 | router::register("GET", "/login", "controller::login__get") 13 | router::register("POST", "/logout", "controller::logout__post") 14 | router::register("GET", "/oauth-callback", "controller::oauth_callback__get") 15 | router::register("GET", "/authed", "controller::authed__get") 16 | router::register("GET", "/authed/posts/new", "controller::authed__posts__new__get") 17 | router::register("POST", "/authed/posts/new", "controller::authed__posts__new__post") 18 | router::register("GET", "/authed/posts/edit", "controller::authed__posts__edit__get") 19 | router::register("POST", "/authed/posts/edit", "controller::authed__posts__edit__post") 20 | router::register("POST", "/authed/posts/delete", "controller::authed__posts__delete__post") 21 | router::register("GET", "/authed/posts", "controller::authed__posts__get") 22 | router::register("GET", "/authed/settings", "controller::authed__settings__get") 23 | router::register("POST", "/authed/settings", "controller::authed__settings__post") 24 | router::register("GET", "/authed/styles", "controller::authed__styles__get") 25 | router::register("POST", "/authed/styles", "controller::authed__styles__post") 26 | router::register("GET", "/api/v1/editor/posts", "controller::api__v1__editor__posts__get") 27 | router::register("POST", "/api/v1/editor/posts", "controller::api__v1__editor__posts__post") 28 | router::register("GET", "/api/v1/editor/posts/*", "controller::api__v1__editor__posts___id__get") 29 | router::register("GET", "/api/v1/editor", "controller::api__v1__editor__get") 30 | router::register("POST", "/api/v1/images/uploading-sign", "controller::api__v1__images__uploading_sign__post") 31 | router::register("GET", "/api/v1/accounts", "controller::api__v1__accounts__get") 32 | router::register("GET", "/*", "controller::_account_name__get") 33 | router::register("GET", "/*/rss.xml", "controller::_account_name__rss_xml__get") 34 | router::register("GET", "/*/style.css", "controller::_account_name__style_css__get") 35 | router::register("GET", "/*/posts/*", "controller::_account_name__posts___id__get") 36 | router::register_notfound("controller::notfound") 37 | 38 | router::debug_print() 39 | 40 | http::initialize(); 41 | } 42 | 43 | { 44 | pgsql::renewConnection() 45 | http::receiveRequest() 46 | router::call(http::getMethod(), http::getPath()) 47 | } 48 | -------------------------------------------------------------------------------- /src/lib/aes256.awk: -------------------------------------------------------------------------------- 1 | @namespace "aes256" 2 | 3 | function encrypt(str , cmd, ret) { 4 | if (!(str ~ /^[a-zA-Z0-9_ -]+$/)) { 5 | error::raise("invalid charactor is included", "aes256") 6 | } 7 | cmd = "echo '" str "' | openssl enc -A -base64 -aes-256-cbc -salt -pbkdf2 -pass env:ENCRYPTION_KEY" 8 | cmd |& getline ret 9 | close(cmd) 10 | return base64::urlsafe(ret) 11 | } 12 | 13 | function decrypt(str , cmd, ret) { 14 | str = base64::urlunsafe(str) 15 | if (!(str ~ /^[a-zA-Z0-9+/]+=*$/)) { 16 | error::raise("aes256::decrypt", "aes256") 17 | } 18 | cmd = "echo '" str "' | openssl enc -d -base64 -aes-256-cbc -salt -pbkdf2 -pass env:ENCRYPTION_KEY" 19 | cmd |& getline ret 20 | close(cmd) 21 | return ret 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/awss3.awk: -------------------------------------------------------------------------------- 1 | @namespace "awss3" 2 | 3 | BEGIN { 4 | loadEnviron() 5 | } 6 | 7 | function loadEnviron() { 8 | NOT_USE_AWS_S3 = environ::get("NOT_USE_AWS_S3") 9 | if (NOT_USE_AWS_S3) { 10 | logger::info("The environment variable NOT_USE_AWS_S3 is set; remove this environment variable if you want to use S3.") 11 | } else { 12 | logger::info("S3 will be used. If you do not use it, set the environment variable NOT_USE_AWS_S3") 13 | ACCESS_KEY_ID = environ::getOrPanic("AWS_ACCESS_KEY_ID") 14 | BUCKET = environ::getOrPanic("AWS_BUCKET") 15 | REGION = environ::getOrPanic("AWS_REGION") 16 | SECRET_ACCESS_KEY = environ::getOrPanic("AWS_SECRET_ACCESS_KEY") 17 | ENDPOINT = environ::getOrPanic("S3_BUCKET_ENDPOINT") 18 | ASSET_HOST = environ::getOrPanic("S3_ASSET_HOST") 19 | } 20 | } 21 | 22 | function needToUseAwsS3() { 23 | if (NOT_USE_AWS_S3) { 24 | error::raise("need to use aws s3", "awss3") 25 | } 26 | } 27 | 28 | function buildPolicyToUpload(now, key, type, sizeMin, sizeMax , policy) { 29 | policy["expiration"] = datetime::gmdate("%Y-%m-%dT%H:%M:%S.000Z", now + 60) 30 | policy["conditions"][1]["bucket"] = BUCKET 31 | policy["conditions"][2]["key"] = key 32 | policy["conditions"][3]["Content-Type"] = type 33 | policy["conditions"][4][1] = "content-length-range" 34 | policy["conditions"][4][2] = sizeMin 35 | policy["conditions"][4][3] = sizeMax 36 | policy["conditions"][5]["acl"] = "public-read" 37 | policy["conditions"][6]["success_action_status"] = "201" 38 | policy["conditions"][7]["x-amz-algorithm"] = "AWS4-HMAC-SHA256" 39 | policy["conditions"][8]["x-amz-credential"] = ACCESS_KEY_ID "/" datetime::gmdate("%Y%m%d", now) "/" REGION "/s3/aws4_request" 40 | policy["conditions"][9]["x-amz-date"] = datetime::gmdate("%Y%m%dT%H%M%SZ", now) 41 | 42 | return json::to_json(policy, 1) 43 | } 44 | 45 | function buildEncodedPolicyToUpload(now, key, type, sizeMin, sizeMax , policy) { 46 | return base64::encode(buildPolicyToUpload(now, key, type, sizeMin, sizeMax)) 47 | } 48 | 49 | function buildDateRegionKey(now) { 50 | return hmac::sha256(REGION, "hexkey:" hmac::sha256(datetime::gmdate("%Y%m%d", now), "key:AWS4" SECRET_ACCESS_KEY)) 51 | } 52 | 53 | function sign(signee, now, dateRegionKey, dateRegionServiceKey, signingKey) { 54 | dateRegionKey = buildDateRegionKey(now) 55 | dateRegionServiceKey = hmac::sha256("s3", "hexkey:" dateRegionKey) 56 | signingKey = hmac::sha256("aws4_request", "hexkey:" dateRegionServiceKey) 57 | 58 | return hmac::sha256(signee, "hexkey:" signingKey) 59 | } 60 | 61 | function buildPreSignedUploadParams(now, key, type, sizeMin, sizeMax , ret, stringToSign) { 62 | needToUseAwsS3() 63 | 64 | stringToSign = base64::encode(buildPolicyToUpload(now, key, type, sizeMin, sizeMax)) 65 | gsub("\n", "", stringToSign) 66 | 67 | ret["upload_url"] = ENDPOINT # "https://" BUCKET ".s3.amazonaws.com" 68 | ret["public_url"] = ASSET_HOST "/" key # "https://" BUCKET ".s3.amazonaws.com/" key 69 | ret["data"]["bucket"] = BUCKET 70 | ret["data"]["key"] = key 71 | ret["data"]["acl"] = "public-read" 72 | ret["data"]["success_action_status"] = "201" 73 | ret["data"]["policy"] = stringToSign 74 | ret["data"]["x-amz-credential"] = ACCESS_KEY_ID "/" datetime::gmdate("%Y%m%d", now) "/" REGION "/s3/aws4_request" 75 | ret["data"]["x-amz-signature"] = sign(stringToSign, now) 76 | ret["data"]["x-amz-algorithm"] = "AWS4-HMAC-SHA256" 77 | ret["data"]["x-amz-date"] = datetime::gmdate("%Y%m%dT%H%M%SZ", now) 78 | ret["data"]["Content-Type"] = type 79 | return json::to_json(ret) 80 | } 81 | -------------------------------------------------------------------------------- /src/lib/base64.awk: -------------------------------------------------------------------------------- 1 | @namespace "base64" 2 | 3 | function urlsafe(str) { 4 | gsub("+","-", str) 5 | gsub("/","_", str) 6 | gsub("=", "", str) 7 | return str 8 | } 9 | 10 | function urlunsafe(str) { 11 | gsub("-","+", str) 12 | gsub("_","/", str) 13 | switch (length(str) % 4) { 14 | case 0: 15 | return str 16 | case 1: 17 | return str "===" 18 | case 2: 19 | return str "==" 20 | case 3: 21 | return str "=" 22 | } 23 | } 24 | 25 | function encode(str) { 26 | return shell::exec("openssl enc -e -base64 -A", str) 27 | } 28 | 29 | function decode(str) { 30 | return shell::exec("openssl enc -d -base64", str "\n") 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/datetime.awk: -------------------------------------------------------------------------------- 1 | @namespace "datetime" 2 | 3 | function gmdate(format, timestamp , tzstr, operator, diffH, diffM, diffS, offset) { 4 | tzstr = awk::strftime("%z") 5 | if (tzstr !~ /^[-+][0-9]{4}$/) { 6 | error::raise("failure. strftime(\"%z\") is invalid format:" tzstr, "datetime") 7 | } 8 | operator = substr(tzstr,1,1) 9 | diffH = substr(tzstr, 2,2) 10 | diffM = substr(tzstr, 4,2) 11 | diffS = (diffH + 0) * 3600 + (diffS + 0) * 60 12 | 13 | if (operator == "+") { 14 | offset = - diffS 15 | } else { 16 | offset = + diffS 17 | } 18 | if (timestamp == 0) { 19 | timestamp = awk::systime() 20 | } 21 | return awk::strftime(format, timestamp + offset) 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/environ.awk: -------------------------------------------------------------------------------- 1 | @namespace "environ" 2 | 3 | function getOrRaise(name) { 4 | if (has(name)) { 5 | return get(name) 6 | } 7 | error::raise("ENVIRON[\"" name "\"] is not found") 8 | } 9 | 10 | 11 | function getOrPanic(name) { 12 | if (has(name)) { 13 | return get(name) 14 | } 15 | error::panic("ENVIRON[\"" name "\"] is not found") 16 | } 17 | 18 | function get(name) { 19 | return ENVIRON[name] 20 | } 21 | 22 | function has(name) { 23 | return name in ENVIRON 24 | } 25 | 26 | function is(name) { 27 | return has(name) && get(name) 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/error.awk: -------------------------------------------------------------------------------- 1 | @namespace "error" 2 | 3 | function defaultErrorHandler(message) { 4 | print message > "/dev/stderr" 5 | } 6 | 7 | function defaultPanicHandler(message) { 8 | print message > "/dev/stderr" 9 | exit 1 10 | } 11 | 12 | function registerErrorHandler(funcname) { 13 | logger::debug("error::registerErrorHandler(): " funcname) 14 | ERROR_HANDLER = funcname 15 | } 16 | 17 | function currentErrorHandler() { 18 | return ERROR_HANDLER 19 | } 20 | 21 | function currentPanicHandler() { 22 | return PANIC_HANDLER 23 | } 24 | 25 | 26 | function registerPanicHandler(funcname) { 27 | PANIC_HANDLER = funcname 28 | } 29 | 30 | function raise(message, extraContext) { 31 | if (ERROR_HANDLER == "") { 32 | defaultErrorHandler(message) 33 | } else { 34 | @ERROR_HANDLER(message, extraContext) 35 | } 36 | } 37 | 38 | function panic(message, extraContext) { 39 | if (PANIC_HANDLER == "") { 40 | defaultPanicHandler(message) 41 | } else { 42 | @PANIC_HANDLER(message, extraContext) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/github.awk: -------------------------------------------------------------------------------- 1 | @load "json" 2 | @namespace "github" 3 | 4 | BEGIN { 5 | GITHUB_LOGIN_SERVER = environ::getOrPanic("GITHUB_LOGIN_SERVER") 6 | OAUTH_CLIENT_ID = environ::getOrPanic("OAUTH_CLIENT_ID") 7 | OAUTH_CLIENT_SECRET = environ::getOrPanic("OAUTH_CLIENT_SECRET") 8 | OAUTH_CALLBACK_URI = environ::getOrPanic("OAUTH_CALLBACK_URI") 9 | GITHUB_LOGIN_SERVER = environ::getOrPanic("GITHUB_LOGIN_SERVER") 10 | GITHUB_API_SERVER = environ::getOrPanic("GITHUB_API_SERVER") 11 | } 12 | 13 | function redirectUrl(state) { 14 | return GITHUB_LOGIN_SERVER "/login/oauth/authorize?client_id=" OAUTH_CLIENT_ID "&redirect_uri=" OAUTH_CALLBACK_URI "&state=" state 15 | } 16 | 17 | function verify(ret, code , response, res_json, access_token) { 18 | if (!(code ~ /^[a-zA-Z0-9_ -]+$/)) { 19 | error::raise("github::verify", "invalid code") 20 | } 21 | 22 | response = shell::exec("curl -X POST -H 'Accept: application/json' '" GITHUB_LOGIN_SERVER "/login/oauth/access_token?client_id=" OAUTH_CLIENT_ID "&client_secret=" OAUTH_CLIENT_SECRET "&code=" code "'") 23 | json::from_json(response, res_json) 24 | access_token = res_json["access_token"] 25 | if (access_token == "") { 26 | ret["error"] = "access token is not found" 27 | return 28 | } 29 | 30 | if (!(access_token ~ /^[a-zA-Z0-9_ -]+$/)) { 31 | error::raise("github::verify", "invalid access token") 32 | } 33 | 34 | response = shell::exec("curl -H 'Authorization: Bearer " access_token "' -H 'Accept: application/vnd.github+json' -H 'X-GitHub-Api-Version: 2022-11-28' " GITHUB_API_SERVER "/user") 35 | json::from_json(response, res_json) 36 | ret["loginname"] = res_json["login"] 37 | ret["id"] = res_json["id"] 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/hmac.awk: -------------------------------------------------------------------------------- 1 | @namespace "hmac" 2 | 3 | function sha256(value, secret , splitted, ret) { 4 | if (secret !~ /^key:[a-zA-Z0-9+/]+$/ && secret !~/^hexkey:[a-fA-F0-9]+$/) { 5 | error::panic("secret is invalid") 6 | } 7 | ret = shell::exec("openssl dgst -sha256 -mac HMAC -macopt \"" secret "\"", value) 8 | split(ret, splitted, " ") 9 | return splitted[2] 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/html.awk: -------------------------------------------------------------------------------- 1 | @namespace "html" 2 | 3 | function escape(str) { 4 | gsub(/&/, "\\&", str) 5 | gsub(/'/, "\\'", str) 6 | gsub(/"/, "\\"", str) 7 | gsub(//, "\\>", str) 9 | return str 10 | } 11 | 12 | function toText(content , chars, inTag, i ,ret) { 13 | ret = "" 14 | inTag = 0 15 | split(content, chars, "") 16 | for (i in chars) { 17 | if (chars[i] == "<") { 18 | inTag = 1 19 | continue 20 | } 21 | if (chars[i] == ">") { 22 | inTag = 0 23 | continue 24 | } 25 | if (inTag) { 26 | continue 27 | } 28 | ret = ret chars[i] 29 | } 30 | return ret 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/lib/http.awk: -------------------------------------------------------------------------------- 1 | @namespace "http" 2 | 3 | function receiveRequest() { 4 | delete HTTP_REQUEST 5 | delete HTTP_REQUEST_PARAMETERS 6 | delete REQUEST_COOKIES 7 | delete RESPONSE_COOKIES 8 | delete HTTP_RESPONSE_HEADERS 9 | delete HTTP_REQUEST_HEADERS 10 | HTTP_REQUEST["body"] = "" 11 | HTTP_REQUEST["id"] = $0 12 | $0 = ""; 13 | 14 | readFirstLine() 15 | readHeader() 16 | readBody() 17 | 18 | parseCookie() 19 | logRequest() 20 | } 21 | 22 | function readFirstLine( line, splitted, parameters, result) { 23 | # read first line 24 | awk::RS="\n" 25 | INET |& getline line; 26 | logger::debug("readFirstLine: " line, "http") 27 | if (line !~ /^\(HEAD|GET|POST|PUT|DELETE|OPTIONS|PATCH\) \/.* HTTP\/1\.[01]$/) { 28 | send(400) 29 | return 30 | } 31 | split(line, splitted,"[ ?]"); 32 | HTTP_REQUEST["method"] = splitted[1]; 33 | HTTP_REQUEST["path"] = splitted[2]; 34 | 35 | if (splitted[4] == "") { 36 | HTTP_REQUEST["version"] = splitted[3]; 37 | return 38 | } 39 | 40 | parameters = splitted[3]; 41 | HTTP_REQUEST["version"] = splitted[4]; 42 | 43 | if (length(parameters) > 0) { 44 | url::decodeWwwForm(result, parameters) 45 | for (i in result) { 46 | HTTP_REQUEST_PARAMETERS[i] = result[i] 47 | } 48 | } 49 | } 50 | 51 | function readHeader( line, colonSpace, key, value) { 52 | for(i = 1; INET |& getline line > 0; i++) { 53 | logger::debug("readHeader(): " line, "http") 54 | if (line == "" || line == "\r") { 55 | break; 56 | } 57 | gsub(/\r/, "" , line) 58 | colonSpace = index(line, ": ") 59 | key = tolower(substr(line, 1, colonSpace-1)) 60 | value = substr(line, colonSpace+2) 61 | HTTP_REQUEST_HEADERS[key] = value 62 | } 63 | } 64 | 65 | function readBody( contentLength, unread, leftover, reading) { 66 | contentLength = getHeader("content-length") 67 | if (getMethod() == "GET" || getMethod() == "HEAD") { 68 | return 69 | } 70 | if (contentLength !~ /^[0-9]+$/) { 71 | send(411) 72 | } 73 | if (contentLength == 0) { 74 | # body is nothing 75 | return 76 | } 77 | 78 | if (environ::is("AWKBLOG_NO_PROXY") && !hasHeader("x-body-leftover")) { 79 | leftover = length(defaultLeftOverPadding()) 80 | } else { 81 | # The end of the body is not read;\if the entire body is tried to be read, the operation is stalled due to waiting for the next input after the last character. 82 | leftover = getHeader("x-body-leftover") + 0 83 | } 84 | if (leftover < 1) { 85 | setHeader("content-type", "text/plain") 86 | send(400, "the HTTP Header 'X-Body-Leftover' must be greater than 0") 87 | } 88 | unread = contentLength - leftover 89 | while(unread > 0) { 90 | if (unread > 1000) { 91 | reading = 1000 92 | } else { 93 | reading = unread 94 | } 95 | awk::RS = sprintf(".{%d}", reading) 96 | INET |& getline 97 | HTTP_REQUEST["body"] = HTTP_REQUEST["body"] awk::RT 98 | unread -= reading 99 | logger::debug("readBody(): " awk::RT, "http") 100 | } 101 | awk::RS = "\n" 102 | } 103 | 104 | function parseCookie( splitted, i, idx, key, value) { 105 | split(getHeader("cookie"), splitted, "; ") 106 | for(i in splitted) { 107 | idx = index(splitted[i], "=") 108 | key = substr(splitted[i], 1, idx-1) 109 | value = substr(splitted[i], idx+1) 110 | if (value ~ "^\".*\"$") { 111 | value = substr(value, 2, length(value) - 2) 112 | } 113 | 114 | REQUEST_COOKIES[key]["value"] = value; 115 | } 116 | } 117 | 118 | function buildCookieHeader( headerStr, maxAge, secure, i) { 119 | headerStr = "" 120 | for (i in RESPONSE_COOKIES) { 121 | if ("Max-Age" in RESPONSE_COOKIES[i]) { 122 | maxAge = sprintf("; Max-Age=%s;", RESPONSE_COOKIES[i]["Max-Age"]) 123 | } else { 124 | maxAge = "" 125 | } 126 | if (getHostName() ~ /^https:\/\//) { 127 | secure = sprintf("; Secure") 128 | } else { 129 | secure = "" 130 | } 131 | headerStr = sprintf("%sSet-Cookie: %s=%s; SameSite=Lax; HttpOnly%s%s\n", headerStr, i, RESPONSE_COOKIES[i]["value"], maxAge, secure) 132 | } 133 | return headerStr 134 | } 135 | 136 | function logRequest( params, headers, i) { 137 | if (getPath() == "/test") { 138 | logger::debug("request: /test", "http") 139 | return 140 | } 141 | for (i in HTTP_REQUEST_PARAMETERS) { 142 | params = params "\n " i ": " HTTP_REQUEST_PARAMETERS[i] 143 | } 144 | for (i in HTTP_REQUEST_HEADERS) { 145 | headers = headers "\n " i ": " HTTP_REQUEST_HEADERS[i] 146 | } 147 | 148 | logger::debug(sprintf("\ 149 | request:\n\ 150 | method:\n %s\n\ 151 | path:\n %s\n\ 152 | parameter:%s\n\ 153 | header:%s\n\ 154 | body:\n %s\n\ 155 | ", getMethod(), getPath(), params, headers, getBody()), "http") 156 | } 157 | 158 | function getCookie(key) { 159 | if (key in REQUEST_COOKIES) { 160 | return REQUEST_COOKIES[key]["value"] 161 | } 162 | return "" 163 | } 164 | 165 | function setCookie(key, value) { 166 | RESPONSE_COOKIES[key]["value"] = value 167 | } 168 | 169 | function setCookieMaxAge(key, maxAge) { 170 | RESPONSE_COOKIES[key]["Max-Age"] = maxAge 171 | } 172 | 173 | function getPort( port) { 174 | return environ::get("AWKBLOG_PORT") 175 | } 176 | 177 | function initialize( port) { 178 | error::registerErrorHandler("http::internalServerError") 179 | 180 | port = getPort() 181 | 182 | INET = "/inet/tcp/" port "/0/0"; 183 | FS="" 184 | RS = "\n" 185 | 186 | logger::info("Start awkblog. listen port " port " ...") 187 | } 188 | 189 | function internalServerError(message, kind) { 190 | if (kind == "") { 191 | kind = "http_internal_server_error" 192 | logger::error("internal server error has occured", "http_internal_server_error") 193 | } 194 | logger::error(message, kind) 195 | 196 | http::setHeader("content-type", "text/html; charset=UTF-8") 197 | http::send(500, "An error has occured. Please return to the previous page.") 198 | } 199 | 200 | function buildResponse(statusNum, content, headerStr, status, i) { 201 | switch(statusNum) { 202 | case 200: status = "200 OK"; break; 203 | case 201: status = "201 No Content"; break; 204 | case 204: status = "204 OK"; break; 205 | case 302: status = "302 Found"; break; 206 | case 400: status = "400 Bad Request"; break; 207 | case 401: status = "401 Unauthorized"; break; 208 | case 403: status = "403 Forbidden"; break; 209 | case 404: status = "404 Not Found"; break; 210 | case 411: status = "411 Length Required"; break; 211 | default: status = "500 Not Handled";break; 212 | } 213 | 214 | for(i in HTTP_RESPONSE_HEADERS) { 215 | headerStr = headerStr i ": " HTTP_RESPONSE_HEADERS[i] "\n"; 216 | } 217 | headerStr = headerStr buildCookieHeader(); 218 | 219 | return sprintf("HTTP/1.1 %s\n%s\n%s", status, headerStr, content); 220 | } 221 | 222 | function send(statusNum, content) { 223 | setHeader("x-awkblog-version", awk::getAwkblogVersion()) 224 | logger::info(statusNum " flyip:" getHeader("fly-client-ip") " x44ip:" getHeader("x-forwarded-for") " " getMethod() " " getPath() " rf:" getHeader("referer") " ua:" getHeader("user-agent") , "http") 225 | printf "%s", buildResponse(statusNum, content) |& INET; 226 | close(INET); 227 | 228 | RS = "\n" 229 | next 230 | } 231 | 232 | function sendRedirect(url) { 233 | HTTP_RESPONSE_HEADERS["Location"] = url 234 | send(302, "") 235 | } 236 | 237 | function setHeader(key, value) { 238 | HTTP_RESPONSE_HEADERS[key] = value 239 | } 240 | 241 | function getPath() { 242 | return HTTP_REQUEST["path"] 243 | } 244 | 245 | function getMethod() { 246 | return HTTP_REQUEST["method"] 247 | } 248 | 249 | function getBody() { 250 | return HTTP_REQUEST["body"] 251 | } 252 | 253 | function hasHeader(key) { 254 | return key in HTTP_REQUEST_HEADERS 255 | } 256 | 257 | function getHeader(key) { 258 | if (hasHeader(key)) { 259 | return HTTP_REQUEST_HEADERS[key] 260 | } 261 | return "" 262 | } 263 | 264 | function hasParameter(key) { 265 | return key in HTTP_REQUEST_PARAMETERS 266 | } 267 | 268 | function getParameter(key) { 269 | if (hasParameter(key)) { 270 | return HTTP_REQUEST_PARAMETERS[key] 271 | } 272 | return "" 273 | } 274 | 275 | function getRequestId() { 276 | return HTTP_REQUEST["id"] 277 | } 278 | 279 | function guardCSRF() { 280 | if (isCrossSiteRequest()) { 281 | send(400) 282 | return 283 | } 284 | } 285 | 286 | function isCrossSiteRequest() { 287 | return getHeader("origin") != getHostName() 288 | } 289 | 290 | function getHostName() { 291 | return AWKBLOG_HOSTNAME 292 | } 293 | 294 | function defaultLeftOverPadding() { 295 | return "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" 296 | } 297 | 298 | BEGIN { 299 | AWKBLOG_HOSTNAME = environ::getOrPanic("AWKBLOG_HOSTNAME") 300 | } 301 | -------------------------------------------------------------------------------- /src/lib/json.awk: -------------------------------------------------------------------------------- 1 | @load "json" 2 | -------------------------------------------------------------------------------- /src/lib/logger.awk: -------------------------------------------------------------------------------- 1 | @namespace "logger" 2 | 3 | function debug(message, tag) { 4 | output("DEBUG", message, tag) 5 | } 6 | 7 | function info(message, tag) { 8 | output("INFO", message, tag) 9 | } 10 | 11 | function warning(message, tag) { 12 | output("WARNING", message, tag) 13 | } 14 | 15 | function error(message, tag) { 16 | output("ERROR", message, tag) 17 | } 18 | 19 | function output(level, message, tag) { 20 | if (level == "DEBUG" && !environ::get("DEBUG")) { 21 | return 22 | } 23 | if (tag == "") { 24 | tag = "default" 25 | } 26 | 27 | printf "%s %s %s [%s] %s\n", nowISO8601(), level, ProcessIdentifier, tag, message 28 | fflush() 29 | } 30 | 31 | function setProcessIdentifier(identifier) { 32 | ProcessIdentifier = identifier 33 | } 34 | 35 | function nowISO8601() { 36 | return awk::strftime("%Y-%m-%dT%H:%M:%S%z", awk::systime()) 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/pgsql.awk: -------------------------------------------------------------------------------- 1 | @load "pgsql" 2 | 3 | @namespace "pgsql" 4 | 5 | function exec(query, params, response, numberOfFields, col, numberOfRow, row, logstr, extraMessage, columns, numberOfRows, columnName) { 6 | logger::debug("pgsql::exec() query: " query, "pgsql") 7 | response = awk::pg_execparams(Connection, query, length(params), params) 8 | logger::debug("pgsql::exec() response: " response, "pgsql") 9 | delete RESULT 10 | RESULT[0] = "" 11 | delete RESULT[0] 12 | if (response ~ /^OK/) { 13 | awk::pg_clear(response) 14 | return 1 15 | } 16 | if (response ~ /^TUPLES /) { 17 | numberOfFields = awk::pg_nfields(response) 18 | for (col = 0; col < numberOfFields; col++) { 19 | columns[col] = awk::pg_fname(response, col) 20 | o } 21 | numberOfRows = awk::pg_ntuples(response) 22 | for (row = 0; row < numberOfRows; row++) { 23 | for (col = 0; col < numberOfFields; col++) { 24 | RESULT[row][columns[col]] = (awk::pg_getisnull(response,row,col) ? "" : awk::pg_getvalue(response,row,col)) 25 | } 26 | } 27 | 28 | logstr = "\n" 29 | for(i in RESULT) { 30 | logstr = logstr " " i ":\n" 31 | for (columnName in RESULT[i]) { 32 | logstr = logstr " " columnName ": " RESULT[i][columnName] "\n" 33 | } 34 | } 35 | logger::debug(logstr, "pgsql") 36 | 37 | awk::pg_clear(response) 38 | return 1 39 | } 40 | extraMessage = awk::pg_errormessage(Connection) 41 | awk::pg_clear(response) 42 | error::raise("failed to pgsql::exec(): " response ", extraMessage: " extraMessage, "pgsql") 43 | return 0 44 | } 45 | 46 | function createConnection( param) { 47 | POSTGRES_HOSTNAME = environ::getOrPanic("POSTGRES_HOSTNAME") 48 | POSTGRES_DATABASE = environ::getOrPanic("POSTGRES_DATABASE") 49 | POSTGRES_USER = environ::getOrPanic("POSTGRES_USER") 50 | POSTGRES_PASSWORD = environ::getOrPanic("POSTGRES_PASSWORD") 51 | 52 | param = "host=" POSTGRES_HOSTNAME " dbname=" POSTGRES_DATABASE " user=" POSTGRES_USER " password=" POSTGRES_PASSWORD 53 | if (environ::has("POSTGRES_SSLMODE")) { 54 | param = param " sslmode=" environ::get("POSTGRES_SSLMODE") 55 | } 56 | if (environ::has("POSTGRES_OPTIONS")) { 57 | param = param " options=" environ::get("POSTGRES_OPTIONS") 58 | } 59 | if ((Connection = awk::pg_connect(param)) == "" ) { 60 | error::panic("pgsql::createConnection(): pg_connectionect failed: " ERRNO) 61 | } 62 | logger::info("created a postgres connection", "pgsql") 63 | logger::info("pgsql::createConnection(): Connection: " Connection, "pgsql") 64 | } 65 | 66 | function destroyConnection() { 67 | awk::pg_disconnect(Connection) 68 | } 69 | 70 | function renewConnection() { 71 | if (Connection) { 72 | destroyConnection() 73 | } 74 | createConnection() 75 | } 76 | 77 | function fetchRows() { 78 | return length(RESULT) 79 | } 80 | 81 | function fetchResult(row, columnName) { 82 | return RESULT[row][columnName] 83 | } 84 | -------------------------------------------------------------------------------- /src/lib/router.awk: -------------------------------------------------------------------------------- 1 | @namespace "router" 2 | 3 | RoutingNotFoundCallback = "router::default_404" 4 | 5 | function default_404() { 6 | http::send(404, ""); 7 | } 8 | 9 | function register(method, path, callback, key) { 10 | key = method " " path 11 | RoutingTable[key] = callback 12 | 13 | register_wildcard_position(path) 14 | } 15 | 16 | function register_notfound(callback) { 17 | RoutingNotFoundCallback = callback 18 | } 19 | 20 | function find(method, path, key, pos) { 21 | key = method " " path 22 | if (key in RoutingTable) { 23 | return RoutingTable[key] 24 | } 25 | 26 | for (pos in WildcardPositions) { 27 | key = method " " wildcard_compress(path, pos) 28 | if (key in RoutingTable) { 29 | return RoutingTable[key] 30 | } 31 | } 32 | return RoutingNotFoundCallback 33 | } 34 | 35 | function debug_print( i) { 36 | for(i in RoutingTable) { 37 | logger::debug("RoutingTable[\"" i "\"] = " RoutingTable[i], "router") 38 | } 39 | } 40 | 41 | function call(method, path, controller) { 42 | controller = find(method, path) 43 | @controller(); 44 | } 45 | 46 | function register_wildcard_position(path, pos, path_parts, i) { 47 | path = substr(path, 2) # ignore leading a slash 48 | split(path, path_parts, "/") 49 | for (i in path_parts) { 50 | if (path_parts[i] == "*") { 51 | pos = pos " " i 52 | } 53 | } 54 | if (pos) { 55 | WildcardPositions[pos] = 1 56 | } 57 | } 58 | 59 | function wildcard_compress(path, pos, path_parts, masked_path, pos_parts, i) { 60 | path = substr(path, 2) # ignore leading a slash 61 | split(path, path_parts, "/") 62 | split_index(pos, pos_parts, " ") 63 | for(i in path_parts) { 64 | if (i in pos_parts) { 65 | masked_path = masked_path "/*" 66 | } else { 67 | masked_path = masked_path "/" path_parts[i] 68 | } 69 | } 70 | return masked_path 71 | } 72 | 73 | function split_index(str, arr, sep, arr_val, i) { 74 | split(str, arr_val, sep) 75 | for (i in arr_val) 76 | arr[arr_val[i]] = 1 77 | } 78 | -------------------------------------------------------------------------------- /src/lib/shell.awk: -------------------------------------------------------------------------------- 1 | @namespace "shell" 2 | 3 | function exec(cmd ,stdinStr , ret, isFirstLine) { 4 | isFirstLine = 1 5 | logger::debug("shell::exec(\"" cmd "\", \"" stdinStr "\")") 6 | if (stdinStr == "") { 7 | while((cmd | getline) > 0) { 8 | if (isFirstLine) { 9 | isFirstLine = 0 10 | ret = $0 11 | } else { 12 | ret = ret "\n" $0 13 | } 14 | } 15 | } else { 16 | printf "%s", stdinStr |& cmd 17 | close(cmd, "to") 18 | while((cmd |& getline) > 0) { 19 | if (isFirstLine) { 20 | isFirstLine = 0 21 | ret = $0 22 | } else { 23 | ret = ret "\n" $0 24 | } 25 | } 26 | } 27 | if (close(cmd) != 0) { 28 | error::raise("shell::exec() failed", "shell") 29 | } 30 | logger::debug("shell::exec ret: " ret) 31 | return ret 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/lib/text.awk: -------------------------------------------------------------------------------- 1 | @namespace "text" 2 | 3 | function headAbout500(content , splitted, i, description) { 4 | split(content, splitted, " ") 5 | 6 | for(i in splitted) { 7 | if (length(description) > 500) { 8 | break 9 | } 10 | description = description " " splitted[i] 11 | } 12 | return description 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/url.awk: -------------------------------------------------------------------------------- 1 | @namespace "url" 2 | 3 | function decodeWwwForm(result, encodedStr, encodedParts, key, value, equalIndex, i) { 4 | split(encodedStr, encodedParts, "&"); 5 | 6 | for (i in encodedParts) { 7 | if (encodedParts[i] == "") { 8 | continue 9 | } 10 | key = encodedParts[i] 11 | value = "" 12 | 13 | # split = 14 | equalIndex = index(key, "=") 15 | if (equalIndex > 0) { 16 | value = substr(key, equalIndex + 1) 17 | key = substr(key, 1, equalIndex - 1) 18 | } 19 | 20 | # replace + 21 | gsub("+", " ", key) 22 | gsub("+", " ", value) 23 | 24 | # utf-8 percent decode 25 | key = decodeUtf8ParcentEncoding(key) 26 | value = decodeUtf8ParcentEncoding(value) 27 | result[key] = value 28 | } 29 | } 30 | 31 | function decodeUtf8ParcentEncoding(encodedStr, chars, decodedStr, l, n, num, utf32num, p1, p2, p3, p4, b1, b2, b3, b4) { 32 | l = length(encodedStr) 33 | n = 1 34 | decodedStr = "" 35 | split(encodedStr, chars, "") 36 | while (n<=l) { 37 | if (chars[n] != "%" || n+2 > l) { 38 | decodedStr = decodedStr chars[n] 39 | n++ 40 | continue 41 | } 42 | 43 | # ref: 44 | # http://www.unicode.org/versions/Unicode6.0.0/ch03.pdf 45 | # Table 3-7. Well-Formed UTF-8 Byte Sequences 46 | 47 | p1 = chars[n] == "%" 48 | b1 = awk::strtonum("0x" chars[n+1] chars[n+2]) 49 | p2 = chars[n+3] == "%" 50 | b2 = awk::strtonum("0x" chars[n+4] chars[n+5]) 51 | p3 = chars[n+6] == "%" 52 | b3 = awk::strtonum("0x" chars[n+7] chars[n+8]) 53 | p4 = chars[n+9] == "%" 54 | b4 = awk::strtonum("0x" chars[n+10] chars[n+11]) 55 | 56 | if (p1 && 0x00 <= b1 && b1 <= 0x7F) { 57 | # 0xxxxxxx 58 | decodedStr = sprintf("%s%c", decodedStr, b1) 59 | n+=3 60 | continue 61 | } 62 | if (p1 && p2 && \ 63 | 0xC2 <= b1 && b1 <= 0xDF && 0x80 <= b2 && b2 <= 0xBF \ 64 | ) { 65 | # 110xxxxx 10xxxxxx 66 | decodedStr = sprintf("%s%c%c", decodedStr, b1, b2) 67 | n+=6 68 | continue 69 | } 70 | if (p1 && p2 && p3 && (\ 71 | ( b1 == 0xE0 && 0xA0 <= b2 && b2 <= 0xBF && 0x80 <= b3 && b3 <= 0xBF ) || \ 72 | ( 0xE1 <= b1 && b1 <= 0xEC && 0x80 <= b2 && b2 <= 0xBF && 0x80 <= b3 && b3 <= 0xBF ) || \ 73 | ( b1 == 0xED && 0x80 <= b2 && b2 <= 0x9F && 0x80 <= b3 && b3 <= 0xBF ) || \ 74 | ( 0xEE <= b1 && b1 <= 0xEF && 0x80 <= b2 && b2 <= 0xBF && 0x80 <= b3 && b3 <= 0xBF ) \ 75 | )) { 76 | # 1110xxxx 10xxxxxx 10xxxxxx 77 | decodedStr = sprintf("%s%c%c%c", decodedStr, b1, b2, b3) 78 | n+=9 79 | continue 80 | } 81 | if (p1 && p2 && p3 && p4 && ( \ 82 | ( b1 == 0xF0 && 0x90 <= b2 && b2 <= 0xBF && 0x80 <= b3 && b3 <= 0xBF && 0x80 <= b4 && b4 <= 0x80 ) || \ 83 | ( 0xF1 <= b1 && b1 <= 0xF3 && 0x80 <= b2 && b2 <= 0xBF && 0x80 <= b3 && b3 <= 0xBF && 0x80 <= b4 && b4 <= 0x80 ) || \ 84 | ( b1 == 0xF4 && 0x80 <= b2 && b2 <= 0x8F && 0x80 <= b3 && b3 <= 0xBF && 0x80 <= b4 && b4 <= 0x80 ) \ 85 | )) { 86 | # 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 87 | decodedStr = sprintf("%s%c%c%c%c", decodedStr, b1, b2, b3, b4) 88 | n+=12 89 | continue 90 | } 91 | error::raise("failed to decode UTF-8 parcent encoded string", "url") 92 | exit 1 93 | } 94 | return decodedStr 95 | } 96 | -------------------------------------------------------------------------------- /src/lib/uuid.awk: -------------------------------------------------------------------------------- 1 | @namespace "uuid" 2 | 3 | function gen( ret, cmd) { 4 | return tolower(shell::exec("uuidgen")) 5 | } 6 | -------------------------------------------------------------------------------- /src/middleware/auth.awk: -------------------------------------------------------------------------------- 1 | @namespace "auth" 2 | 3 | function verify( encrypted, decrypted, splitted) { 4 | encrypted = http::getCookie("login_session") 5 | if (encrypted == "empty" || encrypted == "") { 6 | logger::info("login_session is empty", "auth") 7 | return 0 8 | } 9 | decrypted = aes256::decrypt(encrypted) 10 | split(decrypted, splitted, " ") 11 | if (splitted[1] != "AWKBLOG_LOGIN_SESSION") { 12 | return 0 13 | } 14 | MIDDLEWARE_AUTH["userid"] = splitted[2] 15 | MIDDLEWARE_AUTH["account_name"] = splitted[3] 16 | return length(MIDDLEWARE_AUTH["userid"]) > 0 && length(MIDDLEWARE_AUTH["account_name"]) > 0 17 | } 18 | 19 | function redirectIfFailedToVerify() { 20 | if (!verify()) { 21 | http::sendRedirect("/") 22 | } 23 | } 24 | 25 | function redirectIfSuccessToVerify() { 26 | if (verify()) { 27 | http::sendRedirect("/authed") 28 | } 29 | } 30 | 31 | function forbiddenIfFailedToVerify() { 32 | if (!verify()) { 33 | http::send(403) 34 | } 35 | } 36 | 37 | 38 | function getUsername() { 39 | return MIDDLEWARE_AUTH["account_name"] 40 | } 41 | 42 | function getAccountId() { 43 | return MIDDLEWARE_AUTH["userid"] 44 | } 45 | 46 | function login(accountId, accountName , loginSessionStr) { 47 | model::signin(accountId, accountName) 48 | loginSessionStr = sprintf("AWKBLOG_LOGIN_SESSION %d %s", accountId, accountName) 49 | http::setCookie("login_session", aes256::encrypt(loginSessionStr)) 50 | } 51 | 52 | function logout() { 53 | http::setCookie("login_session", "empty") 54 | } 55 | -------------------------------------------------------------------------------- /src/middleware/httpjson.awk: -------------------------------------------------------------------------------- 1 | @namespace "httpjson" 2 | 3 | function render(arr, statusNum) { 4 | http::setHeader("content-type", "application/json") 5 | if (statusNum == "") { 6 | statusNum = 200 7 | } 8 | return http::send(statusNum, json::to_json(arr, 1)) 9 | } 10 | 11 | function badRequest(message , arr) { 12 | if (message == "") { 13 | message = "bad request" 14 | } 15 | arr["message"] = message 16 | return render(arr, 400) 17 | } 18 | 19 | function notFound(message , arr) { 20 | if (message == "") { 21 | message = "not found" 22 | } 23 | arr["message"] = message 24 | return render(arr, 404) 25 | } 26 | 27 | function internalError(message , arr) { 28 | if (message == "") { 29 | message = "internal server error" 30 | } 31 | arr["message"] = message 32 | return render(arr, 500) 33 | } 34 | -------------------------------------------------------------------------------- /src/middleware/template.awk: -------------------------------------------------------------------------------- 1 | @namespace "template" 2 | 3 | function render(path, v, statusCode, contentType) { 4 | if (statusCode == "") { 5 | statusCode = 200 6 | } 7 | switch (contentType) { 8 | case "": 9 | case "html": 10 | http::setHeader("content-type", "text/html; charset=UTF-8") 11 | break 12 | case "xml": 13 | http::setHeader("content-type", "application/xml; charset=UTF-8") 14 | break 15 | default: 16 | error::raise("unknown content type", "middleware/template") 17 | } 18 | http::send(statusCode, compiled_templates::render("pages/" path, v)); 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/model/account.awk: -------------------------------------------------------------------------------- 1 | @namespace "model" 2 | 3 | function getAccountId(account_name , params, query, rows) { 4 | params[1] = account_name 5 | query = "SELECT id, name FROM accounts WHERE name = $1;" 6 | pgsql::exec(query, params) 7 | 8 | rows = pgsql::fetchRows() 9 | if (rows == 0 || rows > 1) { 10 | return "" 11 | } 12 | 13 | return pgsql::fetchResult(0, "id") 14 | } 15 | 16 | function getAccount(ret, id , params, query, rows) { 17 | params[1] = id 18 | query = "SELECT id, name FROM accounts WHERE id = $1" 19 | pgsql::exec(query, params) 20 | rows = pgsql::fetchRows() 21 | if (rows != 1) { 22 | ret["error"] = "account not found" 23 | return 24 | } 25 | ret["id"] = pgsql::fetchResult(0, "id") 26 | ret["name"] = pgsql::fetchResult(0, "name") 27 | return 28 | } 29 | 30 | function getAccounts(result , i, query, rows) { 31 | query = "SELECT id, name FROM accounts" 32 | pgsql::exec(query) 33 | rows = pgsql::fetchRows() 34 | for(i = 1; i <= rows; i++) { 35 | result[i]["id"] = pgsql::fetchResult(i-1, "id") 36 | result[i]["name"] = pgsql::fetchResult(i-1, "name") 37 | # TODO: add description 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/model/blog.awk: -------------------------------------------------------------------------------- 1 | @namespace "model" 2 | 3 | function getBlog(ret, accountId , params, query, rows) { 4 | params[1] = accountId 5 | query = "SELECT title, description, author_profile, coverimage FROM blogs WHERE account_id = $1;" 6 | pgsql::exec(query, params) 7 | 8 | rows = pgsql::fetchRows() 9 | if (rows == 0 || rows > 1) { 10 | error::raise("blog record is not found, or found multiple record") 11 | return 12 | } 13 | 14 | ret["title"] = pgsql::fetchResult(0, "title") 15 | ret["description"] = pgsql::fetchResult(0, "description") 16 | ret["author_profile"] = pgsql::fetchResult(0, "author_profile") 17 | logger::debug(ret["author_profile"], "authorprofile") 18 | ret["coverimage"] = pgsql::fetchResult(0, "coverimage") 19 | } 20 | 21 | function updateBlog(title, description, authorProfile, accountId , params, query) { 22 | params[1] = title 23 | params[2] = description 24 | params[3] = authorProfile 25 | params[4] = accountId 26 | query = "UPDATE blogs SET title = $1, description = $2, author_profile = $3 WHERE account_id = $4;" 27 | pgsql::exec(query, params) 28 | } 29 | -------------------------------------------------------------------------------- /src/model/post.awk: -------------------------------------------------------------------------------- 1 | @namespace "model" 2 | 3 | function getPosts(result, id , params, query, html, rows, i) { 4 | params[1] = id 5 | query = "SELECT id, title, content, created_at FROM posts WHERE account_id = $1 ORDER BY created_at DESC;" 6 | pgsql::exec(query, params) 7 | 8 | rows = pgsql::fetchRows() 9 | for(i = 1; i <= rows; i++) { 10 | result[i]["id"] = pgsql::fetchResult(i-1, "id") 11 | result[i]["title"] = pgsql::fetchResult(i-1, "title") 12 | result[i]["content"] = pgsql::fetchResult(i-1, "content") 13 | result[i]["created_at"] = pgsql::fetchResult(i-1, "created_at") 14 | } 15 | } 16 | 17 | function getPostWithAccountId(result, id, accountId , params, query, rows) { 18 | params[1] = id 19 | params[2] = accountId 20 | query = "SELECT id, title, content, created_at FROM posts WHERE id = $1 AND account_id = $2;" 21 | pgsql::exec(query, params) 22 | rows = pgsql::fetchRows() 23 | if (rows != 1) { 24 | result["error"] = "not 1 record" 25 | return 26 | } 27 | result["id"] = pgsql::fetchResult(0, "id") 28 | result["title"] = pgsql::fetchResult(0, "title") 29 | result["content"] = pgsql::fetchResult(0, "content") 30 | result["created_at"] = pgsql::fetchResult(0, "created_at") 31 | } 32 | 33 | 34 | function getPost(result, id , params, query, rows) { 35 | params[1] = id 36 | query = "SELECT id, title, content, account_id, created_at FROM posts WHERE id = $1" 37 | pgsql::exec(query, params) 38 | rows = pgsql::fetchRows() 39 | if (rows != 1) { 40 | result["error"] = "not 1 record" 41 | return 42 | } 43 | result["id"] = pgsql::fetchResult(0, "id") 44 | result["title"] = pgsql::fetchResult(0, "title") 45 | result["content"] = pgsql::fetchResult(0, "content") 46 | result["account_id"] = pgsql::fetchResult(0, "account_id") 47 | result["created_at"] = pgsql::fetchResult(0, "created_at") 48 | # TODO: add coverimage 49 | } 50 | 51 | function createPost(title, content, accountId , params, query) { 52 | query = "INSERT INTO posts ( account_id, title, content ) VALUES ($1, $2, $3);" 53 | params[1] = accountId 54 | params[2] = title 55 | params[3] = content 56 | pgsql::exec(query, params) 57 | } 58 | 59 | function updatePost(title, content, id, accountId , params, query) { 60 | logger::info("updatePost(" title ", " content ", " id ", " accountId) 61 | query = "UPDATE posts SET title = $1, content = $2 WHERE id = $3 AND account_id = $4;" 62 | params[1] = title 63 | params[2] = content 64 | params[3] = id 65 | params[4] = accountId 66 | pgsql::exec(query, params) 67 | } 68 | 69 | function deletePost(id, accountId , params, query) { 70 | logger::info("deletePost(" id ", " accountId ")") 71 | query = "DELETE FROM posts WHERE id = $1 AND account_id = $2;" 72 | params[1] = id 73 | params[2] = accountId 74 | pgsql::exec(query, params) 75 | } 76 | -------------------------------------------------------------------------------- /src/model/signin.awk: -------------------------------------------------------------------------------- 1 | @namespace "model" 2 | 3 | function signin(accountId, accountName , query, params) { 4 | query = "INSERT INTO accounts( id, name ) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;" 5 | params[1] = accountId 6 | params[2] = accountName 7 | pgsql::exec(query, params) 8 | 9 | delete params 10 | query = "INSERT INTO stylesheets (account_id, content) SELECT $1, $2 WHERE NOT EXISTS (SELECT account_id FROM stylesheets WHERE account_id = $3);" 11 | params[1] = accountId 12 | params[2] = shell::exec("cat misc/default.css") 13 | params[3] = accountId 14 | pgsql::exec(query, params) 15 | 16 | delete params 17 | query = "INSERT INTO blogs (account_id, title, description, author_profile) SELECT $1, $2, $3, $4 WHERE NOT EXISTS (SELECT account_id FROM blogs WHERE account_id = $5);" 18 | params[1] = accountId 19 | params[2] = accountName "'s Blog" 20 | params[3] = "" 21 | params[4] = "" 22 | params[5] = accountId 23 | pgsql::exec(query, params) 24 | 25 | delete params 26 | params[1] = accountId 27 | params[2] = accountName 28 | query = "SELECT id, name FROM accounts WHERE id = $1 AND name = $2;" 29 | pgsql::exec(query, params) 30 | if (pgsql::fetchRows() != 1) { 31 | error::raise("failed to find account") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/model/stylesheet.awk: -------------------------------------------------------------------------------- 1 | @namespace "model" 2 | 3 | function getStylesheet(accountId , params, query, rows) { 4 | params[1] = accountId + 0 5 | query = "SELECT content FROM stylesheets WHERE account_id = $1;" 6 | pgsql::exec(query, params) 7 | 8 | rows = pgsql::fetchRows() 9 | if (rows == 0 || rows > 1) { 10 | return "" 11 | } 12 | 13 | return pgsql::fetchResult(0, "content") 14 | } 15 | 16 | function insertStylesheet(accountId , params, query, rows, content) { 17 | content = "" 18 | while (("misc/default.css" | getline) > 0) { 19 | content = content $0 "\n" 20 | } 21 | params[1] = accountId 22 | params[2] = content 23 | query = "INSERT INTO stylesheets (account_id, content) VALUES($1, $2)" 24 | pgsql::exec(query, params) 25 | } 26 | 27 | function updateStylesheet(content, accountId , params, query) { 28 | params[1] = content 29 | params[2] = accountId 30 | query = "UPDATE stylesheets SET content = $1 WHERE account_id = $2" 31 | pgsql::exec(query, params) 32 | } 33 | -------------------------------------------------------------------------------- /src/vendor/.gitignore: -------------------------------------------------------------------------------- 1 | *.awk 2 | -------------------------------------------------------------------------------- /src/version.awk: -------------------------------------------------------------------------------- 1 | function getAwkblogVersion() { return "unknown" } 2 | -------------------------------------------------------------------------------- /static/assets/awkblog-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yammerjp/awkblog/8212140fb4f6444dfb54ef3b53ece29adaef49e0/static/assets/awkblog-icon.png -------------------------------------------------------------------------------- /static/assets/github-mark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/assets/photo-up.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/assets/uploadImage.js: -------------------------------------------------------------------------------- 1 | // Need to load `browser-image-compression` 2 | 3 | async function uploadImage(originalFile, printLog) { 4 | const file = await imageCompression(originalFile, {maxSizeMB: 1, maxWidthOrHeight: 1920}) 5 | printLog(`Form Submitted! Timestamp: ${event.timeStamp} (file name:${file.name}, size: ${file.size}, type: ${file.type})`); 6 | printLog("start to fetch presigned upload url") 7 | const res = await fetch("/api/v1/images/uploading-sign", { 8 | headers: { "Content-Type": "application/json;" }, 9 | body: JSON.stringify({ type: file.type }), 10 | method: 'POST', 11 | }).then(r => { 12 | if (!r.ok) { 13 | throw new Error("failed to communicate with presign endpoint") 14 | } 15 | return r.json() 16 | }) 17 | printLog("success to fetch presigned upload url") 18 | printLog(`response: ${JSON.stringify(res)}`); 19 | 20 | const postData = new FormData() 21 | for(const key in res.data) { 22 | postData.append(key, res.data[key]); 23 | } 24 | postData.append('file', file); 25 | 26 | printLog("start to post to S3") 27 | const resAws = await fetch(res.upload_url, { 28 | method: 'POST', 29 | body: postData, 30 | }) 31 | 32 | printLog("success to post to S3") 33 | printLog(`response: ${await resAws.text()}`) 34 | printLog(`uploaded_url: ${res.public_url}`) 35 | if (!resAws.ok) { 36 | throw new Error("failed to communicate with aws") 37 | } 38 | return res.public_url 39 | } 40 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yammerjp/awkblog/8212140fb4f6444dfb54ef3b53ece29adaef49e0/static/favicon.ico -------------------------------------------------------------------------------- /test/integration/healthcheck.yaml: -------------------------------------------------------------------------------- 1 | desc: Render top page 2 | runners: 3 | req: http://localhost:4567 4 | steps: 5 | login: 6 | req: 7 | /: 8 | get: 9 | body: null 10 | test: | 11 | current.res.status == 200 12 | -------------------------------------------------------------------------------- /test/integration/start-login-flow.yaml: -------------------------------------------------------------------------------- 1 | desc: Redirect to host.docker.internal:8080 for login 2 | runners: 3 | req: 4 | endpoint: http://localhost:4567 5 | notFollowRedirect: true 6 | steps: 7 | login: 8 | req: 9 | /login: 10 | get: 11 | body: null 12 | test: | 13 | current.res.body == null && current.res.status == 302 && current.res.headers["Location"][0] matches "^http://host.docker.internal:8080/login/oauth/authorize\\?client_id=clientkey&redirect_uri=http://localhost:4567/oauth-callback&state=[0-9a-f-]+$" 14 | -------------------------------------------------------------------------------- /test/integrationtest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | REPOSITORY_ROOT="$(dirname "$0")/.." 4 | cd "$REPOSITORY_ROOT" 5 | 6 | export ENCRYPTION_KEY="passw0rd" 7 | 8 | find test/integration -type f | grep -e '\.yaml$' | xargs runn run 9 | -------------------------------------------------------------------------------- /test/lib/aes256.awk: -------------------------------------------------------------------------------- 1 | @include "src/lib/aes256.awk" 2 | @include "src/lib/base64.awk" 3 | @include "test/testutil.awk" 4 | 5 | "aes256Encrypt" { 6 | encrypted = aes256::encrypt("secret information") 7 | decrypted = aes256::decrypt(encrypted) 8 | assertEqual("secret information", decrypted) 9 | } 10 | 11 | "aes256Decrypt" { 12 | encrypted = "U2FsdGVkX1-GoTdd5307fxvzI5Yqu7wFMFXv6-lwDAW8E8AYSWs__bGzsVlL4nQG" 13 | # U2FsdGVkX1+GoTdd5307fxvzI5Yqu7wFMFXv6+lwDAW8E8AYSWs//bGzsVlL4nQG 14 | decrypted = aes256::decrypt(encrypted) 15 | assertEqual("secret information", decrypted) 16 | } 17 | -------------------------------------------------------------------------------- /test/lib/awss3.awk: -------------------------------------------------------------------------------- 1 | @include "test/testutil.awk" 2 | @include "src/lib/datetime.awk" 3 | @include "src/lib/awss3.awk" 4 | @include "src/lib/shell.awk" 5 | @include "src/lib/logger.awk" 6 | @include "src/lib/json.awk" 7 | @include "src/lib/base64.awk" 8 | @include "src/lib/hmac.awk" 9 | @include "src/lib/environ.awk" 10 | @include "src/lib/error.awk" 11 | 12 | "buildPolicyJson" { 13 | ENVIRON["AWS_ACCESS_KEY_ID"] = "accesskeyid" 14 | ENVIRON["AWS_BUCKET"] = "bucketname" 15 | ENVIRON["AWS_REGION"] = "ap-northeast-1" 16 | awss3::loadEnviron() 17 | 18 | result = awss3::buildPolicyToUpload(1704377552, "path/to/image.jpg", "image/jpeg", 1234, 1234) 19 | assertEqual("{\"conditions\":[{\"bucket\":\"bucketname\"},{\"key\":\"path/to/image.jpg\"},{\"Content-Type\":\"image/jpeg\"},[\"content-length-range\",1234,1234],{\"acl\":\"public-read\"},{\"success_action_status\":\"201\"},{\"x-amz-algorithm\":\"AWS4-HMAC-SHA256\"},{\"x-amz-credential\":\"accesskeyid/20240104/ap-northeast-1/s3/aws4_request\"},{\"x-amz-date\":\"20240104T141232Z\"}],\"expiration\":\"2024-01-04T14:13:32.000Z\"}", result) 20 | } 21 | 22 | "buildPolicyJsonEncoded" { 23 | ENVIRON["AWS_ACCESS_KEY_ID"] = "accesskeyid" 24 | ENVIRON["AWS_BUCKET"] = "bucketname" 25 | ENVIRON["AWS_REGION"] = "ap-northeast-1" 26 | awss3::loadEnviron() 27 | 28 | result = base64::encode(awss3::buildPolicyToUpload(1704377552, "path/to/image.jpg", "image/jpeg", 1234, 1234)) 29 | 30 | assertEqual("eyJjb25kaXRpb25zIjpbeyJidWNrZXQiOiJidWNrZXRuYW1lIn0seyJrZXkiOiJwYXRoL3RvL2ltYWdlLmpwZyJ9LHsiQ29udGVudC1UeXBlIjoiaW1hZ2UvanBlZyJ9LFsiY29udGVudC1sZW5ndGgtcmFuZ2UiLDEyMzQsMTIzNF0seyJhY2wiOiJwdWJsaWMtcmVhZCJ9LHsic3VjY2Vzc19hY3Rpb25fc3RhdHVzIjoiMjAxIn0seyJ4LWFtei1hbGdvcml0aG0iOiJBV1M0LUhNQUMtU0hBMjU2In0seyJ4LWFtei1jcmVkZW50aWFsIjoiYWNjZXNza2V5aWQvMjAyNDAxMDQvYXAtbm9ydGhlYXN0LTEvczMvYXdzNF9yZXF1ZXN0In0seyJ4LWFtei1kYXRlIjoiMjAyNDAxMDRUMTQxMjMyWiJ9XSwiZXhwaXJhdGlvbiI6IjIwMjQtMDEtMDRUMTQ6MTM6MzIuMDAwWiJ9", result) 31 | 32 | } 33 | 34 | "buildDateRegionKey" { 35 | ENVIRON["AWS_REGION"] = "ap-northeast-1" 36 | ENVIRON["AWS_SECRET_ACCESS_KEY"] = "secretkey" 37 | awss3::loadEnviron() 38 | 39 | assertEqual("835ae2b2d0f4fbecaf27c58d608b4ffa7d1c248bf0d908255c27f9882926c731", awss3::buildDateRegionKey(1704377552)) 40 | } 41 | 42 | "sign" { 43 | ENVIRON["AWS_ACCESS_KEY_ID"] = "accesskeyid" 44 | ENVIRON["AWS_BUCKET"] = "bucketname" 45 | ENVIRON["AWS_REGION"] = "ap-northeast-1" 46 | ENVIRON["AWS_SECRET_ACCESS_KEY"] = "secretkey" 47 | awss3::loadEnviron() 48 | 49 | result = awss3::sign("eyJleHBpcmF0aW9uIjoiMjAyNC0wMS0wNFQxNDoxMzozMi4wMDBaIiwiY29uZGl0aW9ucyI6W3siYnVja2V0IjoiYnVja2V0bmFtZSJ9LHsia2V5IjoicGF0aFwvdG9cL2ltYWdlLmpwZyJ9LHsiQ29udGVudC1UeXBlIjoiaW1hZ2VcL2pwZWcifSxbImNvbnRlbnQtbGVuZ3RoLXJhbmdlIiwxMjM0LDEyMzRdLHsiYWNsIjoicHVibGljLXJlYWQifSx7InN1Y2Nlc3NfYWN0aW9uX3N0YXR1cyI6IjIwMSJ9LHsieC1hbXotYWxnb3JpdGhtIjoiQVdTNC1ITUFDLVNIQTI1NiJ9LHsieC1hbXotY3JlZGVudGlhbCI6ImFjY2Vzc2tleWlkXC8yMDI0MDEwNFwvYXAtbm9ydGhlYXN0LTFcL3MzXC9hd3M0X3JlcXVlc3QifSx7IngtYW16LWRhdGUiOiIyMDI0MDEwNFQxNDEyMzJaIn1dfQ==", 1704377552) 50 | assertEqual("9025496f853e2eadf981e9a3a86a013919e553f1859340b4ce9e50c711f8dc83", result) 51 | 52 | } 53 | 54 | "sign" { 55 | ENVIRON["AWS_ACCESS_KEY_ID"] = "accesskeyid" 56 | ENVIRON["AWS_BUCKET"] = "bucketname" 57 | ENVIRON["AWS_REGION"] = "ap-northeast-1" 58 | ENVIRON["AWS_SECRET_ACCESS_KEY"] = "secretkey" 59 | awss3::loadEnviron() 60 | 61 | result = awss3::sign("eyJleHBpcmF0aW9uIjoiMjAyNC0wMS0wNFQxNDoxMzozMi4wMDBaIiwiY29uZGl0aW9ucyI6W3siYnVja2V0IjoiYnVja2V0bmFtZSJ9LHsia2V5IjoicGF0aFwvdG9cL2ltYWdlLmpwZyJ9LHsiQ29udGVudC1UeXBlIjoiaW1hZ2VcL2pwZWcifSxbImNvbnRlbnQtbGVuZ3RoLXJhbmdlIiwxMjM0LDEyMzRdLHsiYWNsIjoicHVibGljLXJlYWQifSx7InN1Y2Nlc3NfYWN0aW9uX3N0YXR1cyI6IjIwMSJ9LHsieC1hbXotYWxnb3JpdGhtIjoiQVdTNC1ITUFDLVNIQTI1NiJ9LHsieC1hbXotY3JlZGVudGlhbCI6ImFjY2Vzc2tleWlkXC8yMDI0MDEwNFwvYXAtbm9ydGhlYXN0LTFcL3MzXC9hd3M0X3JlcXVlc3QifSx7IngtYW16LWRhdGUiOiIyMDI0MDEwNFQxNDEyMzJaIn1dfQ", 1704377552) 62 | assertEqual("8066001e912f98d4b2cff80ce945eff014fb0535a21326a2858fbc3ac2ff484d", result) 63 | 64 | } 65 | 66 | "buildPreSignedUploadParams" { 67 | ENVIRON["AWS_ACCESS_KEY_ID"] = "accesskeyid" 68 | ENVIRON["AWS_BUCKET"] = "bucketname" 69 | ENVIRON["AWS_REGION"] = "ap-northeast-1" 70 | ENVIRON["AWS_SECRET_ACCESS_KEY"] = "secretkey" 71 | ENVIRON["S3_BUCKET_ENDPOINT"] = "https://bucketname.s3.amazonaws.com" 72 | ENVIRON["S3_ASSET_HOST"] = "https://bucketname.s3.amazonaws.com" 73 | awss3::loadEnviron() 74 | 75 | result = awss3::buildPreSignedUploadParams(1704377552, "path/to/image.jpg", "image/jpeg", 1234, 1234) 76 | assertEqual("{\"public_url\":\"https://bucketname.s3.amazonaws.com/path/to/image.jpg\",\"data\":{\"x-amz-algorithm\":\"AWS4-HMAC-SHA256\",\"acl\":\"public-read\",\"key\":\"path/to/image.jpg\",\"Content-Type\":\"image/jpeg\",\"x-amz-credential\":\"accesskeyid/20240104/ap-northeast-1/s3/aws4_request\",\"bucket\":\"bucketname\",\"policy\":\"eyJjb25kaXRpb25zIjpbeyJidWNrZXQiOiJidWNrZXRuYW1lIn0seyJrZXkiOiJwYXRoL3RvL2ltYWdlLmpwZyJ9LHsiQ29udGVudC1UeXBlIjoiaW1hZ2UvanBlZyJ9LFsiY29udGVudC1sZW5ndGgtcmFuZ2UiLDEyMzQsMTIzNF0seyJhY2wiOiJwdWJsaWMtcmVhZCJ9LHsic3VjY2Vzc19hY3Rpb25fc3RhdHVzIjoiMjAxIn0seyJ4LWFtei1hbGdvcml0aG0iOiJBV1M0LUhNQUMtU0hBMjU2In0seyJ4LWFtei1jcmVkZW50aWFsIjoiYWNjZXNza2V5aWQvMjAyNDAxMDQvYXAtbm9ydGhlYXN0LTEvczMvYXdzNF9yZXF1ZXN0In0seyJ4LWFtei1kYXRlIjoiMjAyNDAxMDRUMTQxMjMyWiJ9XSwiZXhwaXJhdGlvbiI6IjIwMjQtMDEtMDRUMTQ6MTM6MzIuMDAwWiJ9\",\"x-amz-signature\":\"025c2c3800b438b4e8633b0a925f260b5c4b1a3d48fd8c6b18965a902a32d414\",\"x-amz-date\":\"20240104T141232Z\",\"success_action_status\":\"201\"},\"upload_url\":\"https://bucketname.s3.amazonaws.com\"}", result) 77 | } 78 | -------------------------------------------------------------------------------- /test/lib/base64.awk: -------------------------------------------------------------------------------- 1 | @include "src/lib/logger.awk" 2 | @include "src/lib/shell.awk" 3 | @include "src/lib/base64.awk" 4 | @include "test/testutil.awk" 5 | @include "src/lib/environ.awk" 6 | 7 | "base64Encode" { 8 | encoded = base64::encode("hello, world!") 9 | assertEqual("aGVsbG8sIHdvcmxkIQ==", encoded) 10 | } 11 | 12 | "base64Decode" { 13 | decoded = base64::decode("aGVsbG8sIHdvcmxkIQ==") 14 | assertEqual("hello, world!", decoded) 15 | } 16 | 17 | "base64EncodeAndDecode" { 18 | encoded = base64::encode("secret information") 19 | decoded = base64::decode(encoded) 20 | assertEqual("secret information", decoded) 21 | } 22 | 23 | "base64ToUrlsafe" { 24 | base64 = "U2FsdGVkX1+T/ZtrrVXETUKzKs0DjeeKSeAEF6G+rdY=" 25 | base64url = "U2FsdGVkX1-T_ZtrrVXETUKzKs0DjeeKSeAEF6G-rdY" 26 | assertEqual(base64url, base64::urlsafe(base64)) 27 | } 28 | 29 | "base64ToUrlunsafe" { 30 | base64 = "U2FsdGVkX1+T/ZtrrVXETUKzKs0DjeeKSeAEF6G+rdY=" 31 | base64url = "U2FsdGVkX1-T_ZtrrVXETUKzKs0DjeeKSeAEF6G-rdY" 32 | assertEqual(base64, base64::urlunsafe(base64url)) 33 | } 34 | -------------------------------------------------------------------------------- /test/lib/hmac.awk: -------------------------------------------------------------------------------- 1 | @include "test/testutil.awk" 2 | @include "src/lib/shell.awk" 3 | @include "src/lib/logger.awk" 4 | @include "src/lib/json.awk" 5 | @include "src/lib/base64.awk" 6 | @include "src/lib/hmac.awk" 7 | @include "src/lib/environ.awk" 8 | 9 | "hashHex" { 10 | assertEqual("a0f43328cd36e3d02970fafc2649f4b43edb81b399f615e334be2b4c3ec14302", hmac::sha256("20240104", "key:AWS4secretKey")) 11 | } 12 | 13 | "hmacTwice" { 14 | assertEqual("195f75652ec679550c3eea897a42ccb812d76991c5e5803aab1f2c01d99e6412", hmac::sha256("ap-northeast-1", "hexkey:a0f43328cd36e3d02970fafc2649f4b43edb81b399f615e334be2b4c3ec14302")) 15 | 16 | } 17 | 18 | 19 | -------------------------------------------------------------------------------- /test/lib/http.awk: -------------------------------------------------------------------------------- 1 | @include "src/lib/http.awk" 2 | @include "src/lib/environ.awk" 3 | @include "test/testutil.awk" 4 | 5 | "buildResponse" { 6 | assertEqual("HTTP/1.1 200 OK\n\nok", http::buildResponse(200, "ok")) 7 | } 8 | -------------------------------------------------------------------------------- /test/lib/router.awk: -------------------------------------------------------------------------------- 1 | @include "src/lib/router.awk" 2 | @include "test/testutil.awk" 3 | 4 | "wildcard_compress" { 5 | replaced = router::wildcard_compress("/hello/world", 1) 6 | assertEqual("/*/world", replaced) 7 | 8 | replaced = router::wildcard_compress("/hello/world", 2) 9 | assertEqual("/hello/*", replaced) 10 | 11 | replaced = router::wildcard_compress("/@yammerjp", 1) 12 | assertEqual("/*", replaced) 13 | 14 | } 15 | 16 | "routing" { 17 | router::register("GET", "/", "controller::get") 18 | router::register("GET", "/test", "controller::test__get") 19 | router::register("GET", "/login", "controller::login__get") 20 | router::register("GET", "/oauth-callback", "controller::oauth_callback__get") 21 | router::register("GET", "/authed", "controller::authed__get") 22 | router::register("GET", "/authed/posts/new", "controller::authed__posts__new__get") 23 | router::register("GET", "/authed/posts", "controller::authed__posts__get") 24 | router::register("POST", "/authed/posts", "controller::authed__posts__post") 25 | router::register("GET", "/*", "controller::_account_id__get") 26 | router::register("GET", "/accounts/*/posts/*", "controller::_accounts__account_id__posts__post_id__get") 27 | router::register_notfound("controller::notfound") 28 | 29 | assertEqual("controller::authed__posts__post", router::find("POST", "/authed/posts")) 30 | assertEqual("controller::_account_id__get", router::find("GET", "/@yammerjp")) 31 | assertEqual("controller::notfound", router::find("POST", "/path/to/notfound")) 32 | assertEqual("controller::_accounts__account_id__posts__post_id__get", router::find("GET", "/accounts/13/posts/15")) 33 | } 34 | -------------------------------------------------------------------------------- /test/lib/shell.awk: -------------------------------------------------------------------------------- 1 | @include "src/lib/shell.awk" 2 | @include "src/lib/logger.awk" 3 | @include "test/testutil.awk" 4 | @include "src/lib/environ.awk" 5 | 6 | { 7 | assertEqual("hoge\nfuga", shell::exec("echo \"hoge\nfuga\"")) 8 | } 9 | -------------------------------------------------------------------------------- /test/lib/url.awk: -------------------------------------------------------------------------------- 1 | @include "src/lib/url.awk" 2 | @include "src/lib/logger.awk" 3 | @include "src/lib/environ.awk" 4 | @include "src/lib/error.awk" 5 | @include "test/testutil.awk" 6 | 7 | "decodeUtf8ParcentEncoding" { 8 | assertEqual("hello", url::decodeUtf8ParcentEncoding("hello")) 9 | assertEqual(" ", url::decodeUtf8ParcentEncoding("%20")) 10 | assertEqual("あ", url::decodeUtf8ParcentEncoding("%E3%81%82")) 11 | assertEqual("😀", url::decodeUtf8ParcentEncoding("%F0%9F%98%80")) 12 | } 13 | 14 | "decodewwwform" { 15 | url::decodeWwwForm(result, "foo=bar&hello=world&jp=%e3%81%82") 16 | assertEqual(3, length(result)) 17 | assertEqual("bar", result["foo"]) 18 | assertEqual("world", result["hello"]) 19 | assertEqual("あ", result["jp"]) 20 | } 21 | -------------------------------------------------------------------------------- /test/lib/uuid.awk: -------------------------------------------------------------------------------- 1 | @include "src/lib/uuid.awk" 2 | @include "src/lib/shell.awk" 3 | @include "src/lib/logger.awk" 4 | @include "test/testutil.awk" 5 | @include "src/lib/environ.awk" 6 | 7 | "uuid" { 8 | generated1 = uuid::gen() 9 | assertEqual(1, generated1 ~ /^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$/) 10 | } 11 | -------------------------------------------------------------------------------- /test/template/components/head.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/template/dist/_account_name/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 |

posts of

13 | 14 |
15 | タイトル 空白が 入っていても 大丈夫 16 |
17 | 18 |
19 | 次の記事 20 |
21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /test/template/dist/_account_name/posts/_id/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | 21 | 22 |

タイトル 空白が 入っていても 大丈夫

23 |
posted: 2023-12-23 11:25:00
24 |
25 | hello, world! 26 | 27 | foo 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /test/template/pages/_account_name/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%#include components/head.html %> 4 | 9 | 10 | 11 |

posts of <%= v["account_name"] %>

12 | <% for(i in v["posts"]) { %> 13 |
14 | /posts/<%= v["posts"][i]["id"] %>"><%= v["posts"][i]["title"] %> 15 |
16 | <% } %> 17 | 18 | 19 | -------------------------------------------------------------------------------- /test/template/pages/_account_name/posts/_id/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | 21 | 22 |

<%= v["title"] %>

23 |
posted: <%= v["created_at"] %>
24 |
25 | <%= v["content"] %> 26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /test/templatetest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set pipefail 5 | 6 | TEMPLATE_DIR="$(cd $(dirname $0); pwd)" 7 | cd "$TEMPLATE_DIR/template" 8 | 9 | COMPILED_TEMPLATE="/tmp/compiled_templates.awk" 10 | 11 | find ./ -type f | sed 's#^./##g'| awk -f ../../misc/compile_templates.awk > "$COMPILED_TEMPLATE" 12 | 13 | diff <(echo '' | awk -f "$COMPILED_TEMPLATE" -f <(echo '{ 14 | v["posts"][1]["title"] = "タイトル 空白が 入っていても 大丈夫"; 15 | v["posts"][1]["account_name"] = "yammerjp"; 16 | v["posts"][1]["id"] = 33; 17 | 18 | v["posts"][2]["title"] = "次の記事"; 19 | v["posts"][2]["account_name"] = "yammerjp"; 20 | v["posts"][2]["id"] = 33; 21 | 22 | print compiled_templates::render("pages/_account_name/get.html", v) 23 | }')) dist/_account_name/get.html 24 | 25 | 26 | cmp <(echo '' | awk -f "$COMPILED_TEMPLATE" -f <(echo '{ 27 | v["title"] = "タイトル 空白が 入っていても 大丈夫"; 28 | v["content"] = "hello, world!\n\nfoo\n"; 29 | v["created_at"] = "2023-12-23 11:25:00"; 30 | 31 | print compiled_templates::render("pages/_account_name/posts/_id/get.html", v) 32 | }')) dist/_account_name/posts/_id/get.html 33 | -------------------------------------------------------------------------------- /test/testutil.awk: -------------------------------------------------------------------------------- 1 | function assertEqual(expected, needle) { 2 | if (expected == needle) { 3 | printf(".") 4 | } else { 5 | printf("expected: %s\nactual: %s\n", expected, needle) 6 | ExitCode = 1 7 | } 8 | } 9 | 10 | END { 11 | exit ExitCode 12 | } 13 | -------------------------------------------------------------------------------- /test/unittest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | REPOSITORY_ROOT="$(dirname "$0")/.." 4 | cd "$REPOSITORY_ROOT" 5 | 6 | export ENCRYPTION_KEY="passw0rd" 7 | export OAUTH_CLIENT_ID=xxxx 8 | export OAUTH_CLIENT_SECRET=xxxx 9 | export AWKBLOG_HOSTNAME="http://localhost:4567" 10 | export POSTGRES_HOSTNAME=db 11 | export POSTGRES_USER=postgres 12 | export POSTGRES_PASSWORD=passw0rd 13 | export POSTGRES_DATABASE=postgres 14 | export OAUTH_CALLBACK_URI="http://localhost:4567/oauth-callback" 15 | export GITHUB_API_SERVER="https://api.github.com" 16 | export GITHUB_LOGIN_SERVER="https://github.com" 17 | export AWS_REGION=ap-northeast-1 18 | export AWS_BUCKET=bucketname 19 | export AWS_ACCESS_KEY_ID=xxxx 20 | export AWS_SECRET_ACCESS_KEY=xxxx 21 | export S3_BUCKET_ENDPOINT="https://bucketname.s3.amazonaws.com" 22 | export S3_ASSET_HOST="https://bucketname.s3.amazonaws.com" 23 | export PORT="8080" 24 | export AWKBLOG_INTERNAL_HOSTNAME="127.0.0.1" 25 | unset NOT_USE_AWS_S3 26 | 27 | 28 | find test/lib -type f | grep -e '\.awk$' | while read awkfile 29 | do 30 | echo '' | gawk -f $awkfile 31 | done 32 | -------------------------------------------------------------------------------- /view/components/_account_name/footer.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /view/components/_account_name/head.html: -------------------------------------------------------------------------------- 1 | 2 | /style.css"> 3 | " href="/@<%= v["account_name"] %>/rss.xml" /> 4 | 5 | -------------------------------------------------------------------------------- /view/components/_account_name/header.html: -------------------------------------------------------------------------------- 1 |
2 | "> 3 |
4 |

<%= v["blog_title"] %>

5 |
6 |
7 |
<%= v["blog_description"] %>
8 |
9 |
10 |
11 | 12 | -------------------------------------------------------------------------------- /view/components/authed/article_editor.html: -------------------------------------------------------------------------------- 1 |
" class="title-input">
2 |
3 |
4 | 7 |
8 |
9 |
10 |
11 |
12 | 13 | 14 | 15 |
16 | 17 |
18 | 19 | 116 | 117 | 118 | 119 | 188 | -------------------------------------------------------------------------------- /view/components/authed/console_head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 55 | 56 | -------------------------------------------------------------------------------- /view/components/authed/header.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | AWKBLOG 7 |
8 |
9 |
10 | <% if ("account_name" in v) { %> 11 |
12 | 26 |
27 | <% } else { %> 28 | 29 | 35 | 36 | <% } %> 37 |
38 | 39 | 134 | 135 | -------------------------------------------------------------------------------- /view/i18n/en.yaml: -------------------------------------------------------------------------------- 1 | logout: Logout 2 | view_blog: View Blog 3 | blog_settings: Blog Settings 4 | new_post: New Post 5 | posts: Posts 6 | style_settings: Style Settings 7 | confirm_delete: Are you sure you want to delete this post? This action cannot be undone. 8 | blog_settings: Blog Settings 9 | blog_settings_description: You can change the title, description, and author profile that are displayed at the top of the public page. 10 | style_settings_title: Style Settings 11 | style_settings_description: You can change the CSS that is loaded on all pages of the public blog. 12 | console_title: hello, awkblog! 13 | descriptive_list_posts: View Posts 14 | descriptive_new_post: New Post 15 | descriptive_blog_settings: Blog Settings 16 | descriptive_style_settings: Style Settings 17 | not_found: 404 Not Found 18 | welcome: hello, awkblog! 19 | welcome_message_1: A blog system implemented with awk. 20 | welcome_message_2: Sign in with your GitHub account and start using it! 21 | placeholder_blog_title: Blog Title 22 | placeholder_blog_description: Blog Description 23 | placeholder_author_profile: Author Profile 24 | -------------------------------------------------------------------------------- /view/i18n/ja.yaml: -------------------------------------------------------------------------------- 1 | logout: ログアウト 2 | view_blog: ブログを確認 3 | blog_settings: ブログ設定 4 | new_post: 新規投稿 5 | posts: 記事一覧 6 | style_settings: スタイル設定 7 | confirm_delete: 記事を削除しても良いですか?この操作は取り消せません 8 | blog_settings: ブログ設定 9 | blog_settings_description: 公開ページの上部に表示されるタイトルや説明、公開ページの下部に紹介される著者の自己紹介の内容を変更できます。 10 | style_settings_title: スタイル設定 11 | style_settings_description: 公開されるブログの全てのページで読み込まれるCSSを変更することができます。 12 | console_title: hello, awkblog! 13 | descriptive_list_posts: 記事の一覧をみる 14 | descriptive_new_post: 記事を投稿する 15 | descriptive_blog_settings: ブログのタイトルや著者欄を変更する 16 | descriptive_style_settings: 公開ページのスタイルを変更する 17 | not_found: 404 Not Found 18 | welcome: hello, awkblog! 19 | welcome_message_1: gawkで実装されたブログシステムです。 20 | welcome_message_2: GitHubアカウントで早速ログインし、使ってみましょう! 21 | placeholder_blog_title: ブログのタイトル 22 | placeholder_blog_description: ブログの説明 23 | placeholder_author_profile: 著者の自己紹介 24 | -------------------------------------------------------------------------------- /view/pages/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= t["not_found"] %> 5 | 6 | 7 | -------------------------------------------------------------------------------- /view/pages/_account_name/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%#include components/_account_name/head.html %> 4 | 5 |
6 | <%#include components/_account_name/header.html %> 7 |
8 | <% for(i in v["posts"]) { %> 9 |
10 | /posts/<%= v["posts"][i]["id"] %>"> 11 |
12 |
">
13 |
<%= v["posts"][i]["title"] %>
14 |
<%= v["posts"][i]["description"] %>
15 |
16 |
17 |
18 | <% } %> 19 |
20 | <%#include components/_account_name/footer.html %> 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /view/pages/_account_name/posts/_id/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%#include components/_account_name/head.html %> 4 | 5 |
6 | <%#include components/_account_name/header.html %> 7 |
8 |

<%= v["title"] %>

9 |
<%= v["created_at"] %>
10 |
11 | <%= v["content"] %> 12 |
13 |
14 | <%#include components/_account_name/footer.html %> 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /view/pages/_account_name/rss.xml/get.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= v["account_url"] %> 4 | <%= v["blog_title"] %> 5 | <%= v["blog_description"] %> 6 | <%= v["copyright"] %> 7 | https://www.rssboard.org/rss-specification 8 | awkblog <%= awk::getAwkblogVersion() %> 9 | ja 10 | <%= awk::strftime() %> 11 | <% for(key in v["posts"]) { %> 12 | 13 | <%= v["posts"][key]["title"] %> 14 | <%= v["account_url"] %>/posts/<%= v["posts"][key]["id"] %> 15 | <%= v["posts"][key]["content"] %> 16 | <%= v["account_url"] %>/posts/<%= v["posts"][key]["id"] %> 17 | <%= v["posts"][key]["created_at"] %> 18 | 19 | <% } %> 20 | 21 | 22 | -------------------------------------------------------------------------------- /view/pages/authed/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%#include components/authed/console_head.html %> 5 | 6 | 7 | <%#include components/authed/header.html %> 8 |
9 |

<%= t["console_title"] %>

10 |

<%= t["descriptive_list_posts"] %>: /authed/posts

11 |

<%= t["descriptive_new_post"] %>: /authed/posts/new

12 |

<%= t["descriptive_blog_settings"] %>: /authed/settings

13 |

<%= t["descriptive_style_settings"] %>: /authed/styles

14 |
15 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /view/pages/authed/posts/edit/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%#include components/authed/console_head.html %> 5 | 6 | 7 | <%#include components/authed/header.html %> 8 |
9 |
10 | "> 11 | <%#include components/authed/article_editor.html %> 12 |
13 |
14 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /view/pages/authed/posts/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%#include components/authed/console_head.html %> 5 | 6 | 7 | <%#include components/authed/header.html %> 8 |
9 |

posts

10 | <% for(i in v["posts"]) { %> 11 |
12 |

<%= v["posts"][i]["title"] %>

13 |
<%= v["posts"][i]["created_at"] %>
14 |

<%= v["posts"][i]["content"] %>

15 |
16 |
17 | "> 18 | 19 |
20 |
')" style="padding: 4px"> 21 | "> 22 | 23 |
24 |
25 |
26 |
27 | <% } %> 28 |
29 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /view/pages/authed/posts/new/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%#include components/authed/console_head.html %> 5 | 6 | 7 | <%#include components/authed/header.html %> 8 |
9 |
10 | <%#include components/authed/article_editor.html %> 11 |
12 |
13 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /view/pages/authed/settings/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%#include components/authed/console_head.html %> 5 | 6 | 7 | <%#include components/authed/header.html %> 8 |
9 |

<%= t["blog_settings"] %>

10 |

11 | <%= t["blog_settings_description"] %> 12 |

13 |
14 |
" value="<%= v["title"] %>">
15 |
" value="<%= v["description"] %>">
16 |
" value="<%= v["author_profile"] %>">
17 |
">
18 |
19 |
20 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /view/pages/authed/styles/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%#include components/authed/console_head.html %> 5 | 11 | 12 | 13 | <%#include components/authed/header.html %> 14 |
15 |

<%= t["style_settings"] %>

16 |

17 | <%= t["style_settings_description"] %> 18 |

19 |
20 |
21 |
22 |
23 |
24 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /view/pages/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%#include components/authed/console_head.html %> 5 | 6 | 7 | <%#include components/authed/header.html %> 8 |
9 |

<%= t["welcome"] %>

10 |

11 | <%= t["welcome_message_1"] %> 12 |

13 |

14 | <%= t["welcome_message_2"] %> 15 |

16 |
17 | 19 | 20 | 21 | --------------------------------------------------------------------------------