This commit is contained in:
Ole-Morten Duesund 2025-05-13 07:58:31 +02:00
commit 871d8975ca
13 changed files with 653 additions and 0 deletions

10
Containerfile Normal file
View file

@ -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", "::"]

8
Gemfile Normal file
View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
source "https://rubygems.org"
# gem "rails"
gem "sinatra", "~> 4.1"
gem "rack-accept", "~> 0.4.5"

37
Gemfile.lock Normal file
View file

@ -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

77
README.md Normal file
View file

@ -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.

139
app.rb Normal file
View file

@ -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.",
"Im 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
"<!DOCTYPE html>
<html lang='#{lang}'>
<head>
<meta charset='UTF-8'>
<title>No as a Service</title>
</head>
<body style='font-family: sans-serif; text-align: center; margin-top: 20%; font-size: 2em;'>
<p>#{message}</p>
</body>
</html>"
end
end

7
extra/no-as-a-service/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
*.log
*.gem
.bundle
vendor/
.DS_Store
.env

View file

@ -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"]

View file

@ -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"]

View file

@ -0,0 +1,3 @@
source 'https://rubygems.org'
gem 'sinatra'

View file

@ -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...

View file

@ -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

View file

@ -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
"<ul>" + filtered.map { |p| "<li><strong>#{p[:language]}</strong> (#{p[:style]}, #{p[:tone]}): #{p[:phrase]}</li>" }.join + "</ul>"
else
JSON.pretty_generate(filtered)
end
end

13
no-as-a-service.service Normal file
View file

@ -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