Tuesday, November 12, 2024

Multi-tenant SaaS (Notes web application) in 200 lines of code

This is a complete SaaS example (Software-as-a-Service) using PostgreSQL as a database, and Gliimly as a web service engine; it includes user signup/login/logout with an email and password, separate user accounts and data, and a notes application. All in about 200 lines of code! Here's a video that shows it - two users sign up and create their own notes:

First create a directory for your application, where the source code will be:
mkdir -p notes
cd notes

Setup Postgres database
Create PostgreSQL user (with the same name as your logged on Linux user, so no password needed), and the database "db_app":
echo "create user $(whoami);
create database db_app with owner=$(whoami);
grant all on database db_app to $(whoami);
\q"  | sudo -u postgres psql

Create a database configuration file to describe your PostgreSQL database above:
echo "user=$(whoami) dbname=db_app" > db_app

Create database objects we'll need - users table for application users, and notes table to hold their notes:
echo "create table if not exists notes (dateOf timestamp, noteId bigserial primary key, userId bigint, note varchar(1000));
create table if not exists users (userId bigserial primary key, email varchar(100), hashed_pwd varchar(100), verified smallint, verify_token varchar(30), session varchar(100));
create unique index if not exists users1 on users (email);" | psql -d db_app

Create Gliimly application
Create application "notes" owned by your Linux user:
sudo mgrg -i -u $(whoami) notes

Source code
This executes before any other handler in an application, making sure all requests are authorized, file "before-handler.gliim":
vi before-handler.gliim

 before-handler
     set-param displayed_logout = false, is_logged_in = false
     call-handler "/session/check"
 end-before-handler


- Signup users, login, logout

This is a generic session management web service that handles user creation, verification, login and logout. Create file "session.gliim":
vi session.gliim

Copy and paste:
 // Display link to login or signup
 %% /session/login-or-signup private
     @<a href="<<p-path "/session/user/login">>">Login</a> &nbsp; &nbsp; <a href="<<p-path "/session/user/new/form">>">Sign Up</a><hr/>
 %%
 // Login with email and password, and create a new session, then display home pag
 %% /session/login public
     get-param pwd, email
     hash-string pwd to hashed_pwd
     random-string to sess_id length 30
     run-query @db_app = "select userId from users where email='%s' and hashed_pwd='%s'" output sess_user_id : email, hashed_pwd
         run-query @db_app no-loop = "update users set session='%s' where userId='%s'" input sess_id, sess_user_id affected-rows arows
         if-true arows not-equal 1
             @Could not create a session. Please try again. <<call-handler "/session/login-or-signup">> <hr/>
             exit-handler
         end-if
         set-cookie "sess_user_id" = sess_user_id path "/", "sess_id" = sess_id path "/"
         call-handler "/session/check"
         call-handler "/session/show-home"
         exit-handler
     end-query
     @Email or password are not correct. <<call-handler "/session/login-or-signup">><hr/>
 %%
 // Starting point of the application. Either display login form or a home page:
 %% /session/start public
     get-param action, is_logged_in type bool
     if-true is_logged_in equal true
         if-true action not-equal "logout"
             call-handler "/session/show-home"
             exit-handler
         end-if
     end-if
     call-handler "/session/user/login"
 %%
 // Generic home page, you can call anything from here, in this case a list of note
 %% /session/show-home private
     call-handler "/notes/list"
 %%
 // Logout user and display home, which will ask to either login or signup
 %% /session/logout public
     get-param is_logged_in type bool
     if-true is_logged_in equal true
         get-param sess_user_id
         run-query @db_app = "update users set session='' where userId='%s'" input sess_user_id no-loop affected-rows arows
         if-true arows equal 1
             set-param is_logged_in = false
             @You have been logged out.<hr/>
             commit-transaction @db_app
         end-if
     end-if
     call-handler "/session/show-home"
 %%
 // Check session based on session cookie. If session cookie corresponds to the email address, the request is a part of an authorized session
 %% /session/check private
     get-cookie sess_user_id="sess_user_id", sess_id="sess_id"
     set-param sess_id, sess_user_id
     if-true sess_id not-equal ""
         set-param is_logged_in = false
         run-query @db_app = "select email from users where userId='%s' and session='%s'" output email input sess_user_id, sess_id row-count rcount
             set-param is_logged_in = true
             get-param displayed_logout type bool
             if-true displayed_logout equal false
                 get-param action
                 if-true action not-equal "logout"
                     @Hi <<p-out email>>! <a href="<<p-path "/session/logout">>">Logout</a><br/>
                 end-if
                 set-param displayed_logout = true
             end-if
         end-query
         if-true rcount not-equal 1
             set-param is_logged_in = false
         end-if
     end-if
 %%
 // Check that email verification token is the one actually sent to the email address
 %% /session/verify-signup public
     get-param code, email
     run-query @db_app = "select verify_token from users where email='%s'" output db_verify : email
         if-true  code equal db_verify
             @Your email has been verifed. Please <a href="<<p-path "/session/user/login">>">Login</a>.
             run-query @db_app no-loop = "update users set verified=1 where email='%s'" : email
             exit-handler
         end-if
     end-query
     @Could not verify the code. Please try <a href="<<p-path "/session/user/new/verify-form">>">again</a>.
     exit-handler
 %%
 // Display login form that asks for email and password
 %% /session/user/login public
     call-handler "/session/login-or-signup"
     @Please Login:<hr/>
     @<form action="<<p-path "/session/login">>" method="POST">
     @<input name="email" type="text" value="" size="50" maxlength="50" required autofocus placeholder="Email">
     @<input name="pwd" type="password" value="" size="50" maxlength="50" required placeholder="Password">
     @<button type="submit">Go</button>
     @</form>
 %%
 // Display form for a new user, asking for an email and password
 %% /session/user/new/form public
     @Create New User<hr/>
     @<form action="<<p-path "/session/user/new/create">>" method="POST">
     @<input name="email" type="text" value="" size="50" maxlength="50" required autofocus placeholder="Email">
     @<input name="pwd" type="password" value="" size="50" maxlength="50" required placeholder="Password">
     @<input type="submit" value="Sign Up">
     @</form>
 %%
 // Send verification email
 %% /session/user/new/send-verify private
     get-param email, verify
     write-string msg
         @From: service@your-service.com
         @To: <<p-out email>>
         @Subject: verify your account
         @
         @Your verification code is: <<p-out verify>>
     end-write-string
     exec-program "/usr/sbin/sendmail" args "-i", "-t" input msg status st
     if-true st not-equal 0 or true equal false
         @Could not send email to <<p-out email>>, code is <<p-out verify>>
         set-param verify_sent = false
     else-if
         set-param verify_sent = true
     end-if
 %%
 // Create new user from email and password
 %% /session/user/new/create public
     get-param email, pwd
     hash-string pwd to hashed_pwd
     random-string to verify length 5 number
     begin-transaction @db_app
     run-query @db_app no-loop = "insert into users (email, hashed_pwd, verified, verify_token, session) values ('%s', '%s', '0', '%s', '')" input email, hashed_pwd, verify affected-rows arows error err on-error-continue
     if-true err not-equal "0" or arows not-equal 1
         call-handler "/session/login-or-signup"
         @User with this email already exists.
         rollback-transaction @db_app
     else-if
         set-param email, verify
         call-handler "/session/user/new/send-verify"
         get-param verify_sent type bool
         if-true verify_sent equal false
             rollback-transaction @db_app
             exit-handler
         end-if
         commit-transaction @db_app
         call-handler "/session/user/new/verify-form"
     end-if
 %%
 // Display form to enter the code emailed to user to verify the email address
 %% /session/user/new/verify-form public
     get-param email
     @Please check your email and enter verification code here:
     @<form action="<<p-path "/session/verify-signup">>" method="POST">
     @<input name="email" type="hidden" value="<<p-out email>>">
     @<input name="code" type="text" value="" size="50" maxlength="50" required autofocus placeholder="Verification code">
     @<button type="submit">Verify</button>
     @</form>
 %%

- Notes application

This is the actual application that uses above session management services. Create file "notes.gliim":
vi notes.gliim

Copy and paste:
 // Delete a note
 %% /notes/delete public
     call-handler "/notes/header"
     get-param sess_user_id, note_id
     run-query @db_app = "delete from notes where noteId='%s' and userId='%s'" : note_id, sess_user_id \
             affected-rows arows no-loop error errnote
     if-true arows equal 1
         @Note deleted
     else-if
         @Could not delete note (<<p-out errnote>>)
     end-if
 %%
 // Display a form to add a note
 %% /notes/form-add  public
     call-handler "/notes/header"
     @Add New Note
     @<form action="<<p-path "/notes/add">>" method="POST">
     @<textarea name="note" rows="5" cols="50" required autofocus placeholder="Enter Note"></textarea>
     @<button type="submit">Create</button>
     @</form>
 %%
 // Add a note
 %% /notes/add public
     call-handler "/notes/header"
     get-param note, sess_user_id
     run-query @db_app = "insert into notes (dateOf, userId, note) values (now(), '%s', '%s')" : sess_user_id, note \
             affected-rows arows no-loop error errnote
     if-true arows equal 1
         @Note added
     else-if
         @Could not add note (<<p-out errnote>>)
     end-if
 %%
 // List all notes
 %% /notes/list public
     call-handler "/notes/header"
     get-param sess_user_id
     run-query @db_app = "select dateOf, note, noteId from notes where userId='%s' order by dateOf desc" \
             input sess_user_id output dateOf, note, noteId
         match-regex "\n" in note replace-with "<br/>\n" result with_breaks status st cache
         if-true st equal 0
             set-string with_breaks = note
         end-if
         @Date: <<p-out dateOf>> (<a href="<<p-path "/notes/ask-delete">>?note_id=<<p-out noteId>>">delete note</a>)<br/>
         @Note: <<p-out with_breaks>><br/>
         @<hr/>
     end-query
 %%
 // Display a question whether to delete a note or not
 %% /notes/ask-delete public
     call-handler "/notes/header"
     get-param note_id
     @Are you sure you want to delete a note? Use Back button to go back,\
        or <a href="<<p-path "/notes/delete">>?note_id=<<p-out note_id>>">delete note now</a>.
 %%
 // Check if session is authorized, and display an appropriate header
 %% /notes/header private
     get-param is_logged_in type bool
     if-true is_logged_in equal false
         call-handler "/session/login-or-signup"
     end-if
     @<h1>Welcome to Notes!</h1><hr/>
     if-true is_logged_in equal false
         exit-handler
     end-if
     @<a href="<<p-path "/notes/form-add">>">Add Note</a> <a href="<<p-path "/notes/list">>">List Notes</a><hr/>
 %%

Build application
gg -q --db=postgres:db_app

Run web services application server
mgrg notes

Emailing
In order to use this example, you need to be able to email local users, which means email addresses such as \"myuser@localhost\". To do that, install postfix (or sendmail). On Debian systems (like Ubuntu):
sudo apt install postfix
sudo systemctl start postfix

and on Fedora systems (like RedHat):
sudo dnf install postfix
sudo systemctl start postfix

When the application sends an email to a local user, such as <OS user>@localhost, then you can see the email sent at:
sudo vi /var/mail/<OS user>

Setup Nginx
A web server sits in front of Gliimly application server, so it needs to be setup. This example is for Ubuntu, so edit Nginx config file there:
sudo vi /etc/nginx/sites-enabled/default

Add this in "server {}" section:
location /notes/ { include /etc/nginx/fastcgi_params; fastcgi_pass  unix:///var/lib/gg/notes/sock/sock; }

Restart Nginx:
sudo systemctl restart nginx

You're done, run it!
Go to your web browser, and enter:
http://127.0.0.1/notes/session/start