Compare commits

...
4 Commits
Author SHA1 Message Date
syedm b28cf38e99 Update README.md 2026-10-01 14:08:17 +01:00
syedm 8823fa808a updates and cleanup. 2026-09-24 18:49:34 +01:00
syedm 1e27d7629f Minor fixes. 2026-09-24 16:45:18 +01:00
syedm cefa98cdec Switch to using nix. 2026-09-24 13:17:17 +01:00
16 changed files with 234 additions and 189 deletions
-11
View File
@@ -1,11 +0,0 @@
source "https://rubygems.org"
gem "base64"
gem "erb"
gem "json"
gem "net-http"
gem "sequel"
gem "sinatra"
gem "uri"
gem "xxhash"
gem "zlib"
-52
View File
@@ -1,52 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
base64 (0.3.0)
bigdecimal (3.2.2)
erb (5.0.1)
json (2.12.2)
logger (1.7.0)
mustermann (3.0.3)
ruby2_keywords (~> 0.0.1)
net-http (0.6.0)
uri
rack (3.1.16)
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)
sequel (5.93.0)
bigdecimal
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)
uri (1.0.3)
xxhash (0.6.0)
zlib (3.2.1)
PLATFORMS
ruby
x86_64-linux
DEPENDENCIES
base64
erb
json
net-http
sequel
sinatra
uri
xxhash
zlib
BUNDLED WITH
2.6.9
+2 -2
View File
@@ -1,8 +1,8 @@
# Infinsweeper Game. # Infinsweeper Game.
It's like minesweeper, but with an infinite board. It's like minesweeper, but with an infinite board.
The static web-app is here: [https://portfolio.syedm.dev/infinsweeper/](https://portfolio.syedm.dev/infinsweeper/) The web-app is here: [infinsweeper.syedm.dev](https://infinsweeper.syedm.dev/)
(This version is without any signing up or logging in.)
## Working ## Working
-30
View File
@@ -1,30 +0,0 @@
set pipe_path /tmp/infin
set oldsum (cat (find . \( -name '*.rb' -o -name '*.js' \) | sort) | md5sum)
if not test -e infinsweeper.db
sqlite3 infinsweeper.db < schema.sql
end
pkill ruby
ruby main.rb -p8080 &
echo "" > $pipe_path
while true
sleep 1
set newsum (cat (find . \( -name '*.rb' -o -name '*.js' \) | sort) | md5sum)
echo $newsum $oldsum
if test "$oldsum" != "$newsum"
set oldsum $newsum
pkill ruby
ruby main.rb -p8080 &
end
if set line (cat $pipe_path)
echo $line
if test "$line" = "die"
pkill ruby
echo "" > $pipe_path
exit
end
if test "$line" = "open"
echo "" > $pipe_path
xdg-open http://localhost:8080
end
end
end
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1790185690,
"narHash": "sha256-xJ+X4hBtOcAFGBOe5nAMyMUeF9foJBmIOu3NjBqBycU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4975466d324710c576dc11ad614684e6bd8cad8e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+62
View File
@@ -0,0 +1,62 @@
{
description = "Infinsweeper";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ nixpkgs, ... }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
ruby = pkgs.ruby.withPackages (
p: with p; [
sqlite3
sequel
rackup
puma
sinatra
]
);
app = pkgs.stdenv.mkDerivation {
pname = "infinsweeper";
version = "0.1.0";
src = ./.;
buildInputs = [ ruby ];
installPhase = ''
mkdir -p $out
cp -r . $out/
'';
};
server = pkgs.writeShellApplication {
name = "infinsweeper";
runtimeInputs = [ ruby ];
text = ''
set -euo pipefail
export DB="''${INFINSWEEPER_DB:-/var/lib/infinsweeper.db}"
exec ruby ${app}/main.rb
'';
};
in
{
packages.${system}.default = app;
apps.${system}.default = {
type = "app";
program = "${server}/bin/infinsweeper";
};
devShells.${system}.default = pkgs.mkShell {
packages = [
ruby
pkgs.sqlite
];
env.INFINSWEEPER_DB = "./infinsweeper.db";
};
};
}
-2
View File
@@ -1,5 +1,3 @@
require "xxhash"
# Game logic main class # Game logic main class
class GameLogic class GameLogic
attr_reader :pos attr_reader :pos
+1
View File
@@ -29,6 +29,7 @@
/> />
</a> </a>
<div class="header-right"> <div class="header-right">
<button class="account-button" id="reset-button">RESET GAME</button>
<button class="account-button" id="logout-button">LOGOUT</button> <button class="account-button" id="logout-button">LOGOUT</button>
<button class="account-button" id="login-button">LOGIN</button> <button class="account-button" id="login-button">LOGIN</button>
<button class="account-button" id="signup-button">SIGN UP</button> <button class="account-button" id="signup-button">SIGN UP</button>
+2 -2
View File
@@ -2,10 +2,10 @@
module Logman module Logman
def self.log(log) def self.log(log)
file, line = caller(1, 1)[0].split(":") file, line = caller(1, 1)[0].split(":")
File.write("log/main.log", "[#{Time.now}] {#{file}:#{line}} #{log}\n", mode: "a") if ENV_HASH["ENV"] == "dev" File.write("log/main.log", "[#{Time.now}] {#{file}:#{line}} #{log}\n", mode: "a") if ENV["ENV"] == "dev"
end end
def self.imp(log) def self.imp(log)
File.write("log/imp.log", "[#{Time.now}] #{log}\n", mode: "a") if ENV_HASH["ENV"] == "dev" File.write("log/imp.log", "[#{Time.now}] #{log}\n", mode: "a") if ENV["ENV"] == "dev"
end end
end end
+4 -2
View File
@@ -1,7 +1,8 @@
# Mailer module # Mailer module
module Mail module Mail
def send(to, subject, body) def send(to, subject, body)
from_email = "infinsweeper@syedm.dev" from_email = "infinsweeper@domain.com"
from_name = "InfinSweeper" from_name = "InfinSweeper"
to = Array(to).map { |addr| { email_address: { address: addr, name: "" } } } to = Array(to).map { |addr| { email_address: { address: addr, name: "" } } }
@@ -17,7 +18,7 @@ module Mail
uri = URI("https://api.zeptomail.com/v1.1/email") uri = URI("https://api.zeptomail.com/v1.1/email")
req = Net::HTTP::Post.new(uri) req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Zoho-enczapikey #{ENV_HASH["ZOHO_PASS"]}" req["Authorization"] = "Zoho-enczapikey #{ENV["ZOHO_PASS"]}"
req["Content-Type"] = "application/json" req["Content-Type"] = "application/json"
req.body = payload.to_json req.body = payload.to_json
@@ -27,5 +28,6 @@ module Mail
return if response.is_a?(Net::HTTPSuccess) return if response.is_a?(Net::HTTPSuccess)
Logman.imp "[ZeptoMail ERROR] #{response.body}" Logman.imp "[ZeptoMail ERROR] #{response.body}"
raise "Email failed."
end end
end end
+25 -13
View File
@@ -5,31 +5,42 @@ require "net/http"
require "sequel" require "sequel"
require "sinatra" require "sinatra"
require "uri" require "uri"
require "xxhash"
require "zlib" require "zlib"
ALPHANUM = [*"0".."9", *"A".."Z", *"a".."z", "-", "_"].freeze ALPHANUM = [*"0".."9", *"A".."Z", *"a".."z", "-", "_"].freeze
env_data = File.exist?(".env") ? File.read(".env") : ""
ENV_HASH = {}
env_data.each_line do |line|
next unless (match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/))
ENV_HASH[match[1]] = match[2]
end
load "logman.rb" load "logman.rb"
# Logman.log ENV_HASH.inspect # Logman.log ENV.inspect
db_file = File.expand_path("infinsweeper.db") db_file = File.expand_path(ENV.fetch("INFINSWEEPER_DB", "infinsweeper.db"))
puts db_file
DB = Sequel.connect("sqlite:///#{db_file}", single_threaded: false) DB = Sequel.connect("sqlite:///#{db_file}", single_threaded: false)
DB.run("PRAGMA foreign_keys = ON;") DB.run("PRAGMA foreign_keys = ON;")
DB.create_table? :Players do
String :username, primary_key: true
String :email, unique: true
File :digest
File :data
String :activation_code
String :new_pass_code
DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
end
DB.create_table? :SignedInUsers do
String :code, primary_key: true
String :player
DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
DateTime :last_used_at, default: Sequel::CURRENT_TIMESTAMP
foreign_key [:player], :Players, key: :username, on_delete: :cascade
end
$active_users = DB[:SignedInUsers].all.map { |x| [x[:code], x[:player]] }.to_h $active_users = DB[:SignedInUsers].all.map { |x| [x[:code], x[:player]] }.to_h
load "mailer.rb" load "mailer.rb"
load "players.rb" load "players.rb"
load "session.rb" load "session.rb"
set :port, ENV.fetch("PORT", 5161)
set :bind, '0.0.0.0'
set :host_authorization, { permitted_hosts: ["infinsweeper.syedm.dev"] }
set :public_folder, "public" set :public_folder, "public"
get "/" do get "/" do
@@ -57,6 +68,8 @@ post "/signup" do
return { "message" => "Unauthorized (invalid CSRF token)!" }.to_json return { "message" => "Unauthorized (invalid CSRF token)!" }.to_json
end end
session["csrf_token"] = Array.new(32) { ALPHANUM.sample }.join session["csrf_token"] = Array.new(32) { ALPHANUM.sample }.join
status 401
return { "message" => "New signups are disabled right now.", "csrf_token" => session["csrf_token"] }.to_json
if data["email"].nil? || data["pass"].nil? || data["username"].nil? if data["email"].nil? || data["pass"].nil? || data["username"].nil?
status 400 status 400
return { "message" => "Bad request made!", "csrf_token" => session["csrf_token"] }.to_json return { "message" => "Bad request made!", "csrf_token" => session["csrf_token"] }.to_json
@@ -104,11 +117,10 @@ post "/login" do
return { "message" => "Bad request made!", "csrf_token" => session["csrf_token"] }.to_json return { "message" => "Bad request made!", "csrf_token" => session["csrf_token"] }.to_json
end end
login_status = session.login(data["username"], data["pass"]) login_status = session.login(data["username"], data["pass"])
status login_status[0]
if login_status[0] == 200 if login_status[0] == 200
status 200
return { "message" => login_status[1], "success" => "true", "csrf_token" => session["csrf_token"] }.to_json return { "message" => login_status[1], "success" => "true", "csrf_token" => session["csrf_token"] }.to_json
else else
status login_status[0]
return { "message" => login_status[1], "csrf_token" => session["csrf_token"] }.to_json return { "message" => login_status[1], "csrf_token" => session["csrf_token"] }.to_json
end end
end end
+51 -8
View File
@@ -18,15 +18,15 @@ module Players
raise ArgumentError, "Password must be at least 8 characters and of valid format." unless raise ArgumentError, "Password must be at least 8 characters and of valid format." unless
pass.match?(/\A[a-zA-Z0-9_.!?@#$%^&*()+=-]+\z/) && pass.length >= 8 pass.match?(/\A[a-zA-Z0-9_.!?@#$%^&*()+=-]+\z/) && pass.length >= 8
digest = XXhash.xxh32(pass, ENV_HASH["SALT"]) digest = Zlib.crc32(pass, ENV["SALT"])
code = ENV_HASH["ENV"] == "prod" ? Array.new(24) { ALPHANUM.sample }.join : "!" code = ENV["ENV"] == "prod" ? Array.new(24) { ALPHANUM.sample }.join : "!"
DB[ DB[
"insert into Players (username, digest, email, activation_code) values (?, ?, ?, ?)", "insert into Players (username, digest, email, activation_code) values (?, ?, ?, ?)",
username, digest, email, code username, digest, email, code
].insert ].insert
send_email(:new, email, username, code) if ENV_HASH["ENV"] == "prod" send_email(:new, email, username, code) if ENV["ENV"] == "prod"
[200, "Successfully signed up!"] [200, "Successfully signed up!"]
rescue ArgumentError => e rescue ArgumentError => e
@@ -51,13 +51,13 @@ module Players
Logman.log "Pass req: #{email} & #{code}" Logman.log "Pass req: #{email} & #{code}"
true send_email(:pass_req, email, code)
# send_email(:pass_req, email, code) true
end end
def self.pass_reset(new_pass, code) def self.pass_reset(pass, code)
digest = XXhash.xxh32(new_pass, ENV_HASH["SALT"]) digest = Zlib.crc32(pass, ENV["SALT"])
DB["update Players set digest = ?, new_pass_code = ? where new_pass_code = ?", digest, "", code].update != 0 DB["update Players set digest = ?, new_pass_code = ? where new_pass_code = ?", digest, "", code].update != 0
end end
@@ -78,7 +78,7 @@ module Players
end end
def self.authorized?(username, pass) def self.authorized?(username, pass)
digest = XXhash.xxh32(pass, ENV_HASH["SALT"]) digest = Zlib.crc32(pass, ENV["SALT"])
Logman.log "Authorized: #{username} & #{digest}" Logman.log "Authorized: #{username} & #{digest}"
player = self[username] player = self[username]
player && player[:digest].to_i == digest.to_i ? player : false player && player[:digest].to_i == digest.to_i ? player : false
@@ -90,6 +90,49 @@ module Players
player && player[:activation_code] == "!" player && player[:activation_code] == "!"
end end
def self.send_email(type, email, *args)
case type
when :new
username, code = args
Mail.send(
email,
"Verify your InfinSweeper account",
<<~HTML
<h2>Welcome to InfinSweeper, #{ERB::Util.html_escape(username)}!</h2>
<p>Thanks for signing up.</p>
<p>
Please verify your email by clicking
<a href="https://your-domain.com/verify/#{ERB::Util.url_encode(code)}">
this link
</a>.
</p>
HTML
)
when :pass_req
code = args.first
Mail.send(
email,
"Reset your InfinSweeper password",
<<~HTML
<h2>Password reset</h2>
<p>You requested a password reset for your InfinSweeper account.</p>
<p>
<a href="https://your-domain.com/reset_password/#{ERB::Util.url_encode(code)}">
Click here to reset your password
</a>
</p>
<p>If you didn't request this, you can safely ignore this email.</p>
HTML
)
else
raise ArgumentError, "Unknown email type: #{type.inspect}"
end
end
Thread.new do Thread.new do
loop do loop do
unverified.each do |player| unverified.each do |player|
+4
View File
@@ -18,4 +18,8 @@ document.addEventListener("DOMContentLoaded", () => {
*/ */
window.game = new GameController(); window.game = new GameController();
window.game.init(); window.game.init();
document.getElementById("reset-button").addEventListener("click", () => {
window.game.reset();
});
}); });
+1
View File
@@ -147,6 +147,7 @@ export default class GameController {
* @param {number} tries - Number of times the game has been reset * @param {number} tries - Number of times the game has been reset
*/ */
async reset(tries = 0) { async reset(tries = 0) {
this.bus.emit("clear_save");
this._clearGameState(); this._clearGameState();
this._generateNewSeed(); this._generateNewSeed();
await this.start(tries); await this.start(tries);
+10
View File
@@ -39,6 +39,8 @@ export default class Saver {
this.bus.on("load_save", this.load.bind(this)); this.bus.on("load_save", this.load.bind(this));
/** @listens EventBus#delete_save */ /** @listens EventBus#delete_save */
this.bus.on("delete_save", this.deleteSave.bind(this)); this.bus.on("delete_save", this.deleteSave.bind(this));
/** @listens EventBus#clear_save */
this.bus.on("clear_save", this.clearSave.bind(this));
/** @listens EventBus#start_autosaver */ /** @listens EventBus#start_autosaver */
this.bus.on("start_autosaver", this.startAutosaver.bind(this)); this.bus.on("start_autosaver", this.startAutosaver.bind(this));
/** @listens EventBus#stop_autosaver */ /** @listens EventBus#stop_autosaver */
@@ -154,6 +156,14 @@ export default class Saver {
this.delete_save = true; this.delete_save = true;
this.stopAutosaver(); this.stopAutosaver();
} }
/**
* @function clearSave
* @description clears the save
*/
clearSave() {
localStorage.removeItem("save");
this.delete_save = false;
}
/** /**
* @function loadFromFile * @function loadFromFile
* @description Loads the game data from a file * @description Loads the game data from a file
+44 -66
View File
@@ -3,6 +3,36 @@ class Sessions
def initialize(request, response) def initialize(request, response)
@request = request @request = request
@response = response @response = response
@session = load_session
end
def save
compressed = Zlib::Deflate.deflate(JSON.generate(@session))
encoded = Base64.strict_encode64(compressed)
@response.set_cookie(
"session",
value: encoded,
path: "/",
expires: Time.now + 360 * 24 * 60 * 60,
httponly: true,
secure: ENV["ENV"] == "prod",
samesite: :strict
)
uid = @session["user"]
if uid
DB["UPDATE SignedInUsers SET last_used_at = CURRENT_TIMESTAMP WHERE code = ?", uid].update
end
rescue Sequel::Error => e
Logman.log "DB Error: #{e.message} when updating last_used_at for #{uid}"
end
def load_session
cookie = @request.cookies["session"]
return {} unless cookie
JSON.parse(Zlib::Inflate.inflate(Base64.strict_decode64(cookie)))
rescue JSON::ParserError, Zlib::Error, ArgumentError
@response.delete_cookie("session")
{}
end end
def signed_in? def signed_in?
@@ -21,7 +51,6 @@ class Sessions
end end
def login(username, pass) def login(username, pass)
Logman.log "Logging in: #{username} & #{pass} #{ENV_HASH["SALT"]}"
player = Players.authorized?(username, pass) player = Players.authorized?(username, pass)
if player if player
code = Array.new(24) { ALPHANUM.sample }.join code = Array.new(24) { ALPHANUM.sample }.join
@@ -36,48 +65,21 @@ class Sessions
return [200, "Remember to verify your email!"] unless Players.verified?(username) return [200, "Remember to verify your email!"] unless Players.verified?(username)
[200, "Signed in successfully!"] [200, "Signed in successfully!"]
else else
[200, "Couldn't sign you in (Username or password incorrect)!"] [401, "Couldn't sign you in (Username or password incorrect)!"]
end end
end end
# TODO: Use .all here
def []=(key, val)
session = @request.cookies["session"]
session = session.nil? ? "{}" : Zlib::Inflate.inflate(Base64.decode64(session))
session = JSON.parse(session)
session[key] = val
Logman.log "Updated: #{key} to #{val}"
compressed = Zlib::Deflate.deflate(JSON.generate(session))
encoded = Base64.encode64(compressed)
@response.set_cookie("session",
value: encoded,
path: "/",
expires: Time.now + 360 * 24 * 60 * 60,
httponly: true,
secure: ENV_HASH["ENV"] == "prod",
samesite: :strict)
uid = session["user"]
DB["UPDATE SignedInUsers SET last_used_at = CURRENT_TIMESTAMP WHERE code = ?", uid].update if uid
rescue JSON::ParserError, Zlib::Error
@response.delete_cookie("session")
rescue Sequel::Error => e
Logman.log "DB Error: #{e.message} when updating last_used_at for #{uid}"
end
def [](key) def [](key)
session = @request.cookies["session"] @session[key]
session = session.nil? ? "{}" : Zlib::Inflate.inflate(Base64.decode64(session))
session = JSON.parse(session)
begin
uid = session["user"]
DB["UPDATE SignedInUsers SET last_used_at = CURRENT_TIMESTAMP WHERE code = ?", uid].update if uid
rescue Sequel::Error => e
Logman.log "DB Error: #{e.message} when updating last_used_at for #{uid}"
end end
session[key]
rescue JSON::ParserError, Zlib::Error def []=(key, val)
@response.delete_cookie("session") @session[key] = val
"" save
end
def all
@session
end end
def message=(val) def message=(val)
@@ -85,7 +87,7 @@ class Sessions
value: val, value: val,
path: "/", path: "/",
expires: Time.now + 360 * 24 * 60 * 60, expires: Time.now + 360 * 24 * 60 * 60,
secure: ENV_HASH["ENV"] == "prod", secure: ENV["ENV"] == "prod",
samesite: :strict) samesite: :strict)
end end
@@ -97,33 +99,9 @@ class Sessions
@request.env["HTTP_X_CSRF_TOKEN"] == self["csrf_token"] @request.env["HTTP_X_CSRF_TOKEN"] == self["csrf_token"]
end end
def all
session = @request.cookies["session"]
session = session.nil? ? "{}" : Zlib::Inflate.inflate(Base64.decode64(session))
JSON.parse(session)
rescue JSON::ParserError, Zlib::Error
@response.delete_cookie("session")
{}
end
def delete(key) def delete(key)
session = @request.cookies["session"] @session.delete(key)
session = session.nil? ? "{}" : Zlib::Inflate.inflate(Base64.decode64(session)) save
session = JSON.parse(session)
session.delete(key)
Logman.log "Deleted: #{key}"
Logman.log session.inspect
compressed = Zlib::Deflate.deflate(JSON.generate(session))
encoded = Base64.encode64(compressed)
@response.set_cookie("session",
value: encoded,
path: "/",
expires: Time.now + 360 * 24 * 60 * 60,
httponly: true,
secure: ENV_HASH["ENV"] == "prod",
samesite: :strict)
rescue JSON::ParserError, Zlib::Error
@response.delete_cookie("session")
end end
Thread.new do Thread.new do
@@ -140,7 +118,7 @@ class Sessions
Logman.log "Thread DB error: #{e.message} on #{session[:code]} for #{session[:player]}" Logman.log "Thread DB error: #{e.message} on #{session[:code]} for #{session[:player]}"
end end
$active_users.delete(session[:code]) $active_users.delete(session[:code])
puts "Auto-logged out: #{session[:player]} (expired session)" Logman.log "Auto-logged out: #{session[:player]} (expired session)"
end end
rescue StandardError => e rescue StandardError => e
Logman.log "Thread error: #{e.message}" Logman.log "Thread error: #{e.message}"