url-shortener: implements rfd 4 (#65)
* DB Done. Adding new entry in db request Done
* add short url render and redirection
* separate functionality update
* removed global var db
* added two fields in List: owned_id and active?
* fixed minor issues
* db changes
* added NameAlreadyTaken error
* moved address check outside of Create
* changed several minor issues
* chenged opendb func with newsqlitestore
diff --git a/apps/url-shortener/.gitignore b/apps/url-shortener/.gitignore
new file mode 100644
index 0000000..4042c7b
--- /dev/null
+++ b/apps/url-shortener/.gitignore
@@ -0,0 +1,2 @@
+# Exclude SQLite database file
+*.db
diff --git a/apps/url-shortener/go.mod b/apps/url-shortener/go.mod
new file mode 100644
index 0000000..faf35a8
--- /dev/null
+++ b/apps/url-shortener/go.mod
@@ -0,0 +1,5 @@
+module url-shortener
+
+go 1.21.5
+
+require github.com/mattn/go-sqlite3 v1.14.19 // indirect
diff --git a/apps/url-shortener/go.sum b/apps/url-shortener/go.sum
new file mode 100644
index 0000000..3042612
--- /dev/null
+++ b/apps/url-shortener/go.sum
@@ -0,0 +1,2 @@
+github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
+github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
diff --git a/apps/url-shortener/index.html b/apps/url-shortener/index.html
new file mode 100644
index 0000000..15eee68
--- /dev/null
+++ b/apps/url-shortener/index.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>URL Shortener</title>
+</head>
+
+<body>
+ <h1>URL Shortener</h1>
+
+ <form action="/" method="post">
+ <label for="address">Address:</label>
+ <input type="text" id="address" name="address" required>
+
+ <label for="custom">Custom Name (optional):</label>
+ <input type="text" id="custom" name="custom">
+
+ <button type="submit">Shorten URL</button>
+ </form>
+
+ <h2>Named Addresses:</h2>
+ <table>
+ <tr>
+ <th>Name</th>
+ <th>Address</th>
+ <th>Owner</th>
+ <th>Active</th>
+ </tr>
+ {{ range .NamedAddresses }}
+ <tr>
+ <td><a href="{{ .Name }}" target="_blank">{{ .Name }}</a></td>
+ <td>{{ .Address }}</td>
+ <td>{{ .OwnerId }}</td>
+ <td>{{ .Active }}</td>
+ </tr>
+ {{ end }}
+ </table>
+</body>
+
+</html>
diff --git a/apps/url-shortener/main.go b/apps/url-shortener/main.go
new file mode 100644
index 0000000..58c022b
--- /dev/null
+++ b/apps/url-shortener/main.go
@@ -0,0 +1,239 @@
+package main
+
+import (
+ "database/sql"
+ "embed"
+ "flag"
+ "fmt"
+ "html/template"
+ "log"
+ "math/rand"
+ "net/http"
+ "strings"
+
+ "github.com/mattn/go-sqlite3"
+)
+
+var dbPath = flag.String("db-path", "url-shortener.db", "Path to the SQLite file")
+
+//go:embed index.html
+var indexHTML embed.FS
+
+type NamedAddress struct {
+ Name string
+ Address string
+ OwnerId string
+ Active bool
+}
+
+type Store interface {
+ Create(addr NamedAddress) error
+ Get(name string) (NamedAddress, error)
+ Activate(name string) error
+ Deactivate(name string) error
+ ChangeOwner(name, ownerId string) error
+ List(ownerId string) ([]NamedAddress, error)
+}
+
+type NameAlreadyTaken struct {
+ Name string
+}
+
+func (er NameAlreadyTaken) Error() string {
+ return fmt.Sprintf("Name '%s' is already taken", er.Name)
+}
+
+type SQLiteStore struct {
+ db *sql.DB
+}
+
+func NewSQLiteStore(path string) (*SQLiteStore, error) {
+ db, err := sql.Open("sqlite3", path)
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = db.Exec(`
+ CREATE TABLE IF NOT EXISTS named_addresses (
+ name TEXT PRIMARY KEY,
+ address TEXT,
+ owner_id TEXT,
+ active BOOLEAN
+ )
+ `)
+ if err != nil {
+ return nil, err
+ }
+
+ return &SQLiteStore{db: db}, nil
+}
+
+func generateRandomURL() string {
+ const charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ var urlShort string
+ for i := 0; i < 6; i++ {
+ urlShort += string(charset[rand.Intn(len(charset))])
+ }
+ return urlShort
+}
+
+func (s *SQLiteStore) Create(addr NamedAddress) error {
+ _, err := s.db.Exec(`
+ INSERT INTO named_addresses (name, address, owner_id, active)
+ VALUES (?, ?, ?, ?)
+ `, addr.Name, addr.Address, addr.OwnerId, addr.Active)
+ if err != nil {
+ sqliteErr, ok := err.(sqlite3.Error)
+ // sqliteErr.ExtendedCode and sqlite3.ErrConstraintUnique are not the same. probably some lib error.
+ // had to use actual code of unique const error
+ if ok && sqliteErr.ExtendedCode == 1555 {
+ return NameAlreadyTaken{Name: addr.Name}
+ }
+ return err
+ }
+ return nil
+}
+
+func (s *SQLiteStore) Get(name string) (NamedAddress, error) {
+ row := s.db.QueryRow("SELECT name, address, owner_id, active FROM named_addresses WHERE name = ?", name)
+ namedAddress := NamedAddress{}
+ err := row.Scan(&namedAddress.Name, &namedAddress.Address, &namedAddress.OwnerId, &namedAddress.Active)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return NamedAddress{}, fmt.Errorf("No record found for name %s", name)
+ }
+ return NamedAddress{}, err
+ }
+ return namedAddress, nil
+}
+
+func (s *SQLiteStore) Activate(name string) error {
+ //TODO
+ return nil
+}
+
+func (s *SQLiteStore) Deactivate(name string) error {
+ //TODO
+ return nil
+}
+
+func (s *SQLiteStore) ChangeOwner(name, ownerId string) error {
+ //TODO
+ return nil
+}
+
+func (s *SQLiteStore) List(ownerId string) ([]NamedAddress, error) {
+ rows, err := s.db.Query("SELECT name, address, owner_id, active FROM named_addresses WHERE owner_id = ?", ownerId)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var namedAddresses []NamedAddress
+ for rows.Next() {
+ var namedAddress NamedAddress
+ if err := rows.Scan(&namedAddress.Name, &namedAddress.Address, &namedAddress.OwnerId, &namedAddress.Active); err != nil {
+ return nil, err
+ }
+ namedAddresses = append(namedAddresses, namedAddress)
+ }
+ return namedAddresses, nil
+}
+
+type PageVariables struct {
+ NamedAddresses []NamedAddress
+}
+
+func renderHTML(w http.ResponseWriter, r *http.Request, tpl *template.Template, data interface{}) {
+ w.Header().Set("Content-Type", "text/html")
+ err := tpl.Execute(w, data)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+type Server struct {
+ store Store
+}
+
+func (s *Server) Start() {
+ http.HandleFunc("/", s.handler)
+ log.Fatal(http.ListenAndServe(":8080", nil))
+}
+
+func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ customName := r.PostFormValue("custom")
+ address := r.PostFormValue("address")
+ if !strings.HasPrefix(address, "http://") && !strings.HasPrefix(address, "https://") {
+ http.Error(w, "Address must start with http:// or https://", http.StatusBadRequest)
+ return
+ }
+ for {
+ cn := customName
+ if cn == "" {
+ cn = generateRandomURL()
+ }
+ // check if custom exists
+ namedAddress := NamedAddress{
+ Name: cn,
+ Address: address,
+ OwnerId: "tabo", //TODO. Owner ID should be taken from http header
+ Active: true,
+ }
+ if err := s.store.Create(namedAddress); err == nil {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ } else if _, ok := err.(NameAlreadyTaken); ok && customName == "" {
+ continue
+ } else if _, ok := err.(NameAlreadyTaken); ok && customName != "" {
+ http.Error(w, "Name is already taken", http.StatusBadRequest)
+ return
+ } else {
+ http.Error(w, "Try again later", http.StatusInternalServerError)
+ return
+ }
+ }
+ }
+ // Get Name from request path for redirection
+ name := strings.TrimPrefix(r.URL.Path, "/")
+ if name != "" {
+ namedAddress, err := s.store.Get(name)
+ if err != nil {
+ return
+ }
+ // Redirect to the address
+ http.Redirect(w, r, namedAddress.Address, http.StatusSeeOther)
+ return
+ }
+ // Retrieve named addresses for the owner
+ namedAddresses, err := s.store.List("tabo")
+ if err != nil {
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ // Combine data for rendering
+ pageVariables := PageVariables{
+ NamedAddresses: namedAddresses,
+ }
+ indexHtmlContent, err := indexHTML.ReadFile("index.html")
+ if err != nil {
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ tmpl, err := template.New("index").Parse(string(indexHtmlContent))
+ if err != nil {
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ renderHTML(w, r, tmpl, pageVariables)
+}
+
+func main() {
+ flag.Parse()
+ db, err := NewSQLiteStore(*dbPath)
+ if err != nil {
+ panic(err)
+ }
+ s := Server{store: db}
+ s.Start()
+}