From 871d8975ca373f297dd3792dd14b49aff9591dd8 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Tue, 13 May 2025 07:58:31 +0200 Subject: [PATCH] initial --- Containerfile | 10 + Gemfile | 8 + Gemfile.lock | 37 ++++ README.md | 77 +++++++ app.rb | 139 +++++++++++++ extra/no-as-a-service/.gitignore | 7 + extra/no-as-a-service/Containerfile | 19 ++ extra/no-as-a-service/Dockerfile | 8 + extra/no-as-a-service/Gemfile | 3 + extra/no-as-a-service/License | 12 ++ extra/no-as-a-service/README.md | 18 ++ extra/no-as-a-service/app.rb | 302 ++++++++++++++++++++++++++++ no-as-a-service.service | 13 ++ 13 files changed, 653 insertions(+) create mode 100644 Containerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 README.md create mode 100644 app.rb create mode 100644 extra/no-as-a-service/.gitignore create mode 100644 extra/no-as-a-service/Containerfile create mode 100644 extra/no-as-a-service/Dockerfile create mode 100644 extra/no-as-a-service/Gemfile create mode 100644 extra/no-as-a-service/License create mode 100644 extra/no-as-a-service/README.md create mode 100644 extra/no-as-a-service/app.rb create mode 100644 no-as-a-service.service diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..08a31b6 --- /dev/null +++ b/Containerfile @@ -0,0 +1,10 @@ +FROM ruby:3.4 + +WORKDIR /app +COPY app.rb . + +RUN bundle init +RUN bundle add sinatra rack-accept rackup puma + +EXPOSE 4567 +CMD ["ruby", "app.rb", "-o", "::"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..fc1c19b --- /dev/null +++ b/Gemfile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# gem "rails" + +gem "sinatra", "~> 4.1" +gem "rack-accept", "~> 0.4.5" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..95e9208 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,37 @@ +GEM + remote: https://rubygems.org/ + specs: + base64 (0.2.0) + logger (1.7.0) + mustermann (3.0.3) + ruby2_keywords (~> 0.0.1) + rack (3.1.14) + rack-accept (0.4.5) + rack (>= 0.4) + rack-protection (4.1.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + ruby2_keywords (0.0.5) + sinatra (4.1.1) + logger (>= 1.6.0) + mustermann (~> 3.0) + rack (>= 3.0.0, < 4) + rack-protection (= 4.1.1) + rack-session (>= 2.0.0, < 3) + tilt (~> 2.0) + tilt (2.6.0) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + rack-accept (~> 0.4.5) + sinatra (~> 4.1) + +BUNDLED WITH + 2.6.8 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2dab29 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ + +# No-as-a-Service + +This is a simple Ruby web service that responds with "No" in various languages. It's designed to accept both HTML and JSON requests and includes rate limiting, structured logging, and support for deployment using **Podman** and **systemd**. + +## Features +- Returns "No" in multiple languages and styles. +- Supports both HTML and JSON response formats. +- Rate limiting to avoid abuse (30 requests per minute per IP). +- Logs structured output, forwarding logs to **journald** for integration with **systemd**. + +## Files +- **app.rb**: The main Ruby application file with Sinatra web service. +- **Containerfile**: Instructions for building the container image using Podman. +- **no-as-a-service.service**: systemd service configuration for managing the application. + +## Installation & Deployment + +### Prerequisites +- **Podman**: To build and run the container. +- **systemd**: For managing the service. +- **Journald**: For logging. + +### Step 1: Build the Container Image + +Clone the repository or download the files, and navigate to the folder containing the `Containerfile` and `app.rb`. + +Run the following command to build the container image with Podman: + +```bash +podman build -t noaas . +``` + +### Step 2: Create the systemd Service + +Copy the **`no-as-a-service.service`** file to your systemd service directory: + +```bash +sudo cp no-as-a-service.service /etc/systemd/system/ +``` + +Enable and start the service: + +```bash +sudo systemctl daemon-reexec +sudo systemctl daemon-reload +sudo systemctl enable --now no-as-a-service +``` + +### Step 3: Accessing the Service + +Once the service is running, you can access it at: + +- **HTML Response**: Open a browser and visit `http://localhost:8080/`. +- **JSON Response**: Send a GET request to `http://localhost:8080/` with an `Accept: application/json` header, or append `?format=json` to the URL. + +### Logs + +Logs are forwarded to **Journald**. You can view logs using: + +```bash +journalctl -u no-as-a-service -t noaas -f +``` + +### Rate Limiting + +Each IP is limited to 30 requests per minute. If this limit is exceeded, the response will be a `429 Too Many Requests` status with a JSON error message. + +### Customizing Language Responses + +You can customize the "No" responses in various languages by modifying the `NO_RESPONSES` hash in `app.rb`. + +--- + +### License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/app.rb b/app.rb new file mode 100644 index 0000000..af9f69a --- /dev/null +++ b/app.rb @@ -0,0 +1,139 @@ + +require 'sinatra' +require 'json' +require 'rack/accept' +require 'time' + +use Rack::Accept + +NO_RESPONSES = { + "en" => [ + "No.", "Nope.", "Nah.", "Nuh-uh.", "Negative.", "Nay.", + "Hell no.", "Not in a million years.", "Get lost.", + "Not happening, buddy.", "Why don't you try asking someone else?", + "Are you kidding me?", "Absolutely not, go away.", + "I’m terribly sorry, but no.", "I regret to inform you that I cannot comply.", + "Unfortunately, that will not be possible at this time.", + "I must respectfully decline your request.", "I wish I could, but sadly I cannot.", + "It would be my pleasure to help, but I must regretfully decline.", + "With the greatest respect, I must say no.", + "I apologize, but I must kindly refuse your request." + ], + "de" => ["Nein.", "Keineswegs."], + "fr" => ["Non.", "Jamais."], + "ja" => ["いいえ", "ダメです。"], + "ru" => ["Нет", "Никак нет"], + "pt" => ["Não.", "De jeito nenhum."], + "es" => ["No.", "Para nada."], + "pl" => ["Nie.", "W żadnym wypadku."], + "fi" => ["Ei.", "Ei todellakaan."], + "tr" => ["Hayır.", "Bu değil."], + "he" => ["לא"], + "hi" => ["नहीं", "बिलकुल नहीं"], + "no" => ["Nei.", "Absolutt ikke.", "Ikke tale om."], + "sv" => ["Nej.", "Inte alls."], + "da" => ["Nej.", "Ikke en chance."], + "it" => ["No.", "Assolutamente no."], + "nl" => ["Nee.", "Absoluut niet."], + "cs" => ["Ne.", "Rozhodně ne."], + "ko" => ["아니요.", "절대 안 돼요."], + "zh" => ["不。", "绝对不行。"] +} + +RATE_LIMIT = { + window: 60, # seconds + limit: 60 # requests per IP per window +} + +$requests = {} + +helpers do + def client_ip + request.env['HTTP_X_FORWARDED_FOR'] || request.ip + end + + def log_to_journald(message) + IO.popen(["/usr/bin/systemd-cat", "--identifier=noaas"], "w") { |io| io.puts message } + rescue + puts message # fallback to stdout + end + + def log_structured(lang:, format:, status:, duration_ms:) + log_entry = { + timestamp: Time.now.utc.iso8601, + ip: client_ip, + method: request.request_method, + path: request.path, + user_agent: request.user_agent, + language: lang, + format: format, + status: status, + duration_ms: duration_ms + } + log_to_journald(JSON.generate(log_entry)) + end + + def rate_limited? + ip = client_ip + now = Time.now.to_i + $requests[ip] ||= [] + $requests[ip].reject! { |timestamp| timestamp < now - RATE_LIMIT[:window] } + if $requests[ip].size >= RATE_LIMIT[:limit] + true + else + $requests[ip] << now + false + end + end + + def preferred_language + params["lang"] || request.env["HTTP_ACCEPT_LANGUAGE"].to_s.scan(/[a-z]{2}/).find do |lang| + NO_RESPONSES.key?(lang) + end || "en" + end + + def no_message(lang) + NO_RESPONSES[lang].sample + end +end + +before do + @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) +end + +after do + duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time) * 1000).round(2) + lang = preferred_language + format = request.preferred_type&.to_s || request.accept.first.to_s + log_structured(lang: lang, format: format, status: response.status, duration_ms: duration) +end + +get '/' do + if rate_limited? + status 429 + content_type :json + return { error: "Too Many Requests" }.to_json + end + + lang = preferred_language + message = no_message(lang) + + if request.preferred_type.to_s == 'application/json' || request.accept.include?('application/json') + content_type :json + status 200 + { message: message, language: lang }.to_json + else + content_type :html + status 200 + " + + + + No as a Service + + +

#{message}

+ + " + end +end diff --git a/extra/no-as-a-service/.gitignore b/extra/no-as-a-service/.gitignore new file mode 100644 index 0000000..aa982cd --- /dev/null +++ b/extra/no-as-a-service/.gitignore @@ -0,0 +1,7 @@ +*.log +*.gem +.bundle +vendor/ +.DS_Store +.env + diff --git a/extra/no-as-a-service/Containerfile b/extra/no-as-a-service/Containerfile new file mode 100644 index 0000000..6a72c20 --- /dev/null +++ b/extra/no-as-a-service/Containerfile @@ -0,0 +1,19 @@ +FROM ruby:3.2 + +# Set working directory +WORKDIR /app + +# Copy dependencies +COPY Gemfile . + +# Install Sinatra and other gems +RUN bundle install + +# Copy application +COPY app.rb . + +# Expose Sinatra port +EXPOSE 4567 + +# Run the app +CMD ["ruby", "app.rb"] diff --git a/extra/no-as-a-service/Dockerfile b/extra/no-as-a-service/Dockerfile new file mode 100644 index 0000000..4bc05c4 --- /dev/null +++ b/extra/no-as-a-service/Dockerfile @@ -0,0 +1,8 @@ +FROM ruby:3.2 +WORKDIR /usr/src/app +COPY Gemfile . +RUN bundle install +COPY app.rb . +EXPOSE 4567 +CMD ["ruby", "app.rb"] + diff --git a/extra/no-as-a-service/Gemfile b/extra/no-as-a-service/Gemfile new file mode 100644 index 0000000..f4ede88 --- /dev/null +++ b/extra/no-as-a-service/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' +gem 'sinatra' + diff --git a/extra/no-as-a-service/License b/extra/no-as-a-service/License new file mode 100644 index 0000000..acc4e15 --- /dev/null +++ b/extra/no-as-a-service/License @@ -0,0 +1,12 @@ + +--- + +### 📄 `LICENSE` + +You can use the [MIT License](https://opensource.org/license/mit/) if you want to keep it simple: + +```text +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy... + diff --git a/extra/no-as-a-service/README.md b/extra/no-as-a-service/README.md new file mode 100644 index 0000000..6db851f --- /dev/null +++ b/extra/no-as-a-service/README.md @@ -0,0 +1,18 @@ +# Polite No Service + +A simple Ruby + Sinatra microservice that returns polite (or impolite) ways to say "No" in various languages. + +## 🌐 Example + +JSON: +http://localhost:4567/?language=Japanese&tone=Polite + +HTML: +http://localhost:4567/?style=Blunt&format=html + +## 🚀 Run with Docker + +```bash +docker build -t polite-no-service . +docker run -p 4567:4567 polite-no-service + diff --git a/extra/no-as-a-service/app.rb b/extra/no-as-a-service/app.rb new file mode 100644 index 0000000..a419e19 --- /dev/null +++ b/extra/no-as-a-service/app.rb @@ -0,0 +1,302 @@ +require 'sinatra' +require 'json' + +set :port, 4567 + +PHRASES = [ + # English Phrases + { + language: "English", + style: "Formal", + tone: "Polite", + phrase: "I'm afraid I must decline, but thank you for asking." + }, + { + language: "English", + style: "Informal", + tone: "Polite", + phrase: "Nah, but I appreciate it!" + }, + { + language: "English", + style: "Blunt", + tone: "Impolite", + phrase: "Nope. Not interested." + }, + { + language: "English", + style: "Sarcastic", + tone: "Impolite", + phrase: "Oh sure, let me cancel everything for *that*." + }, + { + language: "English", + style: "Apologetic", + tone: "Empathetic", + phrase: "I'm so sorry, but I really can't manage this right now." + }, + { + language: "English", + style: "Professional", + tone: "Neutral", + phrase: "After careful consideration, I must respectfully decline." + }, + { + language: "English", + style: "Witty", + tone: "Playful", + phrase: "I'd love to, but my schedule is already having a midlife crisis." + }, + { + language: "English", + style: "Overexplained", + tone: "Anxious", + phrase: "I've thought about this for hours, analyzed every possible angle, and I just can't see a way to make this work." + }, + { + language: "English", + style: "Dramatic", + tone: "Emotional", + phrase: "The universe is conspiring to prevent me from saying yes!" + }, + { + language: "English", + style: "Cryptic", + tone: "Mysterious", + phrase: "Some things are not meant to be, and this is one of them." + }, + + # French Phrases + { + language: "French", + style: "Formal", + tone: "Polite", + phrase: "Je suis désolé, mais je dois refuser poliment." + }, + { + language: "French", + style: "Informal", + tone: "Polite", + phrase: "Non merci, pas aujourd'hui." + }, + { + language: "French", + style: "Blunt", + tone: "Impolite", + phrase: "Non. C'est tout." + }, + { + language: "French", + style: "Sarcastic", + tone: "Impolite", + phrase: "Ah oui, bien sûr... Et pendant qu'on y est!" + }, + { + language: "French", + style: "Poetic", + tone: "Refined", + phrase: "Hélas, mon cœur doit décliner votre proposition." + }, + { + language: "French", + style: "Diplomatic", + tone: "Neutral", + phrase: "Je comprends votre proposition, mais je ne peux pas accepter." + }, + { + language: "French", + style: "Dramatic", + tone: "Emotional", + phrase: "Mon Dieu! C'est absolument impossible!" + }, + { + language: "French", + style: "Philosophical", + tone: "Reflective", + phrase: "Les circonstances de la vie me poussent à refuser." + }, + { + language: "French", + style: "Witty", + tone: "Playful", + phrase: "Mon agenda et moi sommes en pleine négociation, et la réponse est non." + }, + { + language: "French", + style: "Overexplained", + tone: "Anxious", + phrase: "J'ai passé des heures à réfléchir, et franchement, c'est un non catégorique." + }, + + # Japanese Phrases + { + language: "Japanese", + style: "Formal", + tone: "Polite", + phrase: "申し訳ありませんが、お断りさせていただきます。" + }, + { + language: "Japanese", + style: "Informal", + tone: "Polite", + phrase: "ごめん、ちょっと無理かも。" + }, + { + language: "Japanese", + style: "Cold", + tone: "Impolite", + phrase: "無理です。以上。" + }, + { + language: "Japanese", + style: "Humble", + tone: "Polite", + phrase: "誠に恐縮ですが、お断りさせていただきます。" + }, + { + language: "Japanese", + style: "Indirect", + tone: "Diplomatic", + phrase: "少し難しい状況でして..." + }, + { + language: "Japanese", + style: "Apologetic", + tone: "Empathetic", + phrase: "本当に申し訳ありません。" + }, + { + language: "Japanese", + style: "Poetic", + tone: "Refined", + phrase: "心の中で、静かに拒絶の花が咲きます。" + }, + { + language: "Japanese", + style: "Dramatic", + tone: "Emotional", + phrase: "天と地が、この申し出を許しません!" + }, + { + language: "Japanese", + style: "Cryptic", + tone: "Mysterious", + phrase: "運命は別の道を示しています。" + }, + { + language: "Japanese", + style: "Overexplained", + tone: "Anxious", + phrase: "深く考えた末、どうしても受け入れられない理由が山ほどあります。" + }, + + # Sign Language Conceptual Variations + { + language: "Sign Language", + style: "Formal", + tone: "Polite", + phrase: "Respectful decline with a gentle head shake and apologetic facial expression." + }, + { + language: "Sign Language", + style: "Blunt", + tone: "Impolite", + phrase: "Sharp, abrupt hand wave signaling absolute rejection." + }, + { + language: "Sign Language", + style: "Dramatic", + tone: "Emotional", + phrase: "Exaggerated full-body 'No' with wide eyes and dramatic hand gesture." + }, + { + language: "Sign Language", + style: "Playful", + tone: "Humorous", + phrase: "Cheeky wink followed by a playful 'No' sign." + }, + { + language: "Sign Language", + style: "Subtle", + tone: "Diplomatic", + phrase: "Soft, almost imperceptible head tilt with a nuanced hand movement." + }, + + # Emoji Communication + { + language: "Emoji", + style: "Formal", + tone: "Polite", + phrase: "🙇‍♀️🚫 (Bowing apologetically with a stop sign)" + }, + { + language: "Emoji", + style: "Casual", + tone: "Impolite", + phrase: "🙅‍♂️😤 (Crossed arms with frustrated face)" + }, + { + language: "Emoji", + style: "Dramatic", + tone: "Emotional", + phrase: "😱🚫🌋 (Shocked face, stop sign, volcano)" + }, + { + language: "Emoji", + style: "Witty", + tone: "Playful", + phrase: "🤷‍♀️🤦‍♂️😂 (Shrug, facepalm, laughing)" + }, + { + language: "Emoji", + style: "Cryptic", + tone: "Mysterious", + phrase: "🕳️🔮❌ (Hole, crystal ball, cancel)" + } +] + +# Method to find phrases by language or style +def find_phrases(language: nil, style: nil) + PHRASES.select do |phrase| + (language.nil? || phrase[:language] == language) && + (style.nil? || phrase[:style] == style) + end +end + +# Method to generate a random decline phrase +def random_decline + PHRASES.sample +end + +# Method to find most dramatic phrases +def dramatic_phrases + find_phrases(style: "Dramatic") +end + +# Method to get unique languages +def languages + PHRASES.map { |phrase| phrase[:language] }.uniq +end + +get '/' do + content_type :json + language = params['language'] + style = params['style'] + tone = params['tone'] + format = params['format'] || 'json' + + filtered = PHRASES.select do |phrase| + (language.nil? || phrase[:language].casecmp(language) == 0) && + (style.nil? || phrase[:style].casecmp(style) == 0) && + (tone.nil? || phrase[:tone].casecmp(tone) == 0) + end + + case format + when 'html' + content_type :html + "" + else + JSON.pretty_generate(filtered) + end +end + diff --git a/no-as-a-service.service b/no-as-a-service.service new file mode 100644 index 0000000..e5cab4c --- /dev/null +++ b/no-as-a-service.service @@ -0,0 +1,13 @@ +[Unit] +Description=No as a Service +After=network.target + +[Service] +ExecStart=/usr/bin/podman run --rm --name noaas -p 8080:4567 noaas +Restart=always +SyslogIdentifier=noaas +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target