URL Shortener. https://fsh.ee/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

303 lines
7.1 KiB

  1. // A URL Shortener called Link.
  2. // Copyright (C) 2021 i@fsh.ee
  3. //
  4. // This program is free software: you can redistribute it and/or modify
  5. // it under the terms of the GNU Affero General Public License as published by
  6. // the Free Software Foundation, either version 3 of the License, or
  7. // (at your option) any later version.
  8. //
  9. // This program is distributed in the hope that it will be useful,
  10. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. // GNU Affero General Public License for more details.
  13. //
  14. // You should have received a copy of the GNU Affero General Public License
  15. // along with this program. If not, see <https://www.gnu.org/licenses/>.
  16. package main
  17. import (
  18. "crypto/md5"
  19. _ "embed"
  20. "errors"
  21. "flag"
  22. "fmt"
  23. "hash/maphash"
  24. "html/template"
  25. "io/ioutil"
  26. "log"
  27. "net/http"
  28. "net/url"
  29. "os"
  30. "strconv"
  31. "strings"
  32. "time"
  33. "gorm.io/driver/sqlite"
  34. "gorm.io/gorm"
  35. "gorm.io/gorm/logger"
  36. )
  37. //go:embed index.html
  38. var indexTemplate string
  39. type Retry struct {
  40. retryAttemptCount int
  41. }
  42. func NewRetry(retryAttemptCount int) (Retry, error) {
  43. if retryAttemptCount < 1 {
  44. return Retry{}, errors.New("retry attempt count must be greater than zero")
  45. }
  46. return Retry{retryAttemptCount}, nil
  47. }
  48. func (r Retry) Do(f func() error) (err error) {
  49. for i := 0; i < r.retryAttemptCount; i++ {
  50. err = f()
  51. if err == nil {
  52. return nil
  53. }
  54. }
  55. return err
  56. }
  57. type DB struct {
  58. *gorm.DB
  59. log *log.Logger
  60. hashSeed string
  61. retry Retry
  62. }
  63. func NewDB(l *log.Logger, dbFilePath, hashSeed string, retry Retry) (DB, error) {
  64. _, err := os.Stat(dbFilePath)
  65. if os.IsNotExist(err) {
  66. err := ioutil.WriteFile(dbFilePath, []byte{}, 0600)
  67. if err != nil {
  68. return DB{}, err
  69. }
  70. }
  71. db, err := gorm.Open(sqlite.Open(dbFilePath), &gorm.Config{
  72. NowFunc: func() time.Time { return time.Now().UTC() },
  73. Logger: logger.Default.LogMode(logger.Silent),
  74. })
  75. if err != nil {
  76. return DB{}, err
  77. }
  78. return DB{db, l, hashSeed, retry}, db.AutoMigrate(&Link{})
  79. }
  80. type Link struct {
  81. gorm.Model
  82. Big string
  83. Smol string `gorm:"unique"`
  84. Del string `gorm:"unique"`
  85. }
  86. func (db DB) getHashShortLink(s fmt.Stringer) (string, error) {
  87. var (
  88. h = maphash.Hash{}
  89. _, err = h.WriteString(s.String())
  90. )
  91. if err != nil {
  92. return "", err
  93. }
  94. return strings.TrimSpace(strings.TrimLeft(fmt.Sprintf("%#x\n", h.Sum64()), "0x")), nil
  95. }
  96. func (db DB) getHashDeleteKey(s fmt.Stringer) string {
  97. return strings.TrimSpace(fmt.Sprintf("%x", md5.Sum([]byte(db.hashSeed+s.String()+strconv.FormatInt(time.Now().Unix(), 10)))))
  98. }
  99. func (db DB) NewLink(u *url.URL) (Link, error) {
  100. h, err := db.getHashShortLink(u)
  101. if err != nil {
  102. return Link{}, err
  103. }
  104. return db.NewLinkWithShortLink(u, h)
  105. }
  106. func (db DB) NewLinkWithShortLink(u *url.URL, hash string) (link Link, err error) {
  107. // Retry for unique errors.
  108. err = db.retry.Do(func() error {
  109. link = Link{Big: u.String(), Smol: hash, Del: db.getHashDeleteKey(u)}
  110. return db.Create(&link).Error
  111. })
  112. return
  113. }
  114. func (db DB) GetLink(smol string) (l Link, e error) {
  115. res := db.Where(&Link{Smol: smol}).First(&l)
  116. return l, res.Error
  117. }
  118. func (db DB) DelLink(smol, del string) error {
  119. link, err := db.GetLink(smol)
  120. if err != nil {
  121. return err
  122. }
  123. res := db.Where(&Link{Del: del}).Delete(&link)
  124. if res.RowsAffected < 1 {
  125. return gorm.ErrRecordNotFound
  126. }
  127. return res.Error
  128. }
  129. type controller struct {
  130. log *log.Logger
  131. db DB
  132. demo bool
  133. url, copy string
  134. tmpl *template.Template
  135. }
  136. func NewController(logger *log.Logger, db DB, demo bool, url, copy string, tmpl *template.Template) controller {
  137. return controller{logger, db, demo, strings.TrimRight(url, "/"), copy, tmpl}
  138. }
  139. func (c controller) Err(rw http.ResponseWriter, r *http.Request, err error) {
  140. if errors.Is(err, gorm.ErrRecordNotFound) {
  141. rw.WriteHeader(http.StatusNotFound)
  142. fmt.Fprintf(rw, "%s", err)
  143. return
  144. }
  145. c.log.Println(err)
  146. rw.WriteHeader(http.StatusInternalServerError)
  147. fmt.Fprintf(rw, "%s", err)
  148. }
  149. func (c controller) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
  150. switch r.Method {
  151. case http.MethodGet:
  152. switch strings.TrimRight(r.URL.Path, "/") {
  153. case "":
  154. data := map[string]interface{}{
  155. "URL": c.url,
  156. "Demo": c.demo,
  157. "Copy": c.copy,
  158. }
  159. if err := c.tmpl.Execute(rw, data); err != nil {
  160. c.Err(rw, r, err)
  161. return
  162. }
  163. return
  164. case "/favicon.ico":
  165. http.NotFound(rw, r)
  166. return
  167. default:
  168. link, err := c.db.GetLink(strings.TrimLeft(r.URL.Path, "/"))
  169. if err != nil {
  170. c.Err(rw, r, err)
  171. return
  172. }
  173. http.Redirect(rw, r, link.Big, http.StatusPermanentRedirect)
  174. return
  175. }
  176. case http.MethodPost:
  177. b, err := ioutil.ReadAll(r.Body)
  178. if err != nil {
  179. c.Err(rw, r, err)
  180. return
  181. }
  182. u, err := url.Parse(string(b))
  183. if err != nil {
  184. c.Err(rw, r, err)
  185. return
  186. }
  187. if u.Scheme != "http" && u.Scheme != "https" {
  188. rw.WriteHeader(http.StatusBadRequest)
  189. fmt.Fprintf(rw, "URL must contain scheme. E.G. missing `http://` or `https://`.")
  190. return
  191. }
  192. var (
  193. link Link
  194. h = strings.Trim(r.URL.Path, "/")
  195. )
  196. if h != "" {
  197. link, err = c.db.NewLinkWithShortLink(u, h)
  198. } else {
  199. link, err = c.db.NewLink(u)
  200. }
  201. if err != nil {
  202. c.Err(rw, r, err)
  203. return
  204. }
  205. rw.Header().Set("X-Delete-With", link.Del)
  206. rw.WriteHeader(http.StatusFound)
  207. fmt.Fprintf(rw, "%s/%s", c.url, link.Smol)
  208. return
  209. case http.MethodDelete:
  210. b, err := ioutil.ReadAll(r.Body)
  211. if err != nil {
  212. c.Err(rw, r, err)
  213. return
  214. }
  215. if len(b) < 1 {
  216. rw.WriteHeader(http.StatusBadRequest)
  217. fmt.Fprintf(rw, "Must include deletion key in DELETE body.")
  218. return
  219. }
  220. var (
  221. smol = strings.TrimSpace(strings.TrimLeft(r.URL.Path, "/"))
  222. del = strings.TrimSpace(string(b))
  223. )
  224. if err := c.db.DelLink(smol, del); err != nil {
  225. c.Err(rw, r, err)
  226. return
  227. }
  228. rw.WriteHeader(http.StatusNoContent)
  229. return
  230. }
  231. http.NotFound(rw, r)
  232. }
  233. func main() {
  234. var (
  235. logPrefix = "link: "
  236. startupLogger = log.New(os.Stdout, logPrefix, 0)
  237. applicationLogger = log.New(ioutil.Discard, logPrefix, 0)
  238. v = flag.Bool("v", false, "verbose logging")
  239. demo = flag.Bool("demo", false, "turn on demo mode")
  240. port = flag.Uint("port", 8080, "port to listen on")
  241. dbFilePath = flag.String("db", "", "sqlite database filepath: required")
  242. url = flag.String("url", "", "service url: required")
  243. hashSeed = flag.String("seed", "", "hash seed: required")
  244. copy = flag.String("copy", "", "copyright information")
  245. )
  246. flag.Parse()
  247. if *dbFilePath == "" || *url == "" || *hashSeed == "" {
  248. flag.Usage()
  249. return
  250. }
  251. if *v {
  252. applicationLogger = log.New(os.Stdout, logPrefix, 0)
  253. }
  254. retry, err := NewRetry(3)
  255. if err != nil {
  256. startupLogger.Fatal(err)
  257. return
  258. }
  259. db, err := NewDB(applicationLogger, *dbFilePath, *hashSeed, retry)
  260. if err != nil {
  261. startupLogger.Fatal(err)
  262. return
  263. }
  264. tmpl, err := template.New("").Parse(indexTemplate)
  265. if err != nil {
  266. startupLogger.Fatal(err)
  267. return
  268. }
  269. http.Handle("/", NewController(applicationLogger, db, *demo, *url, *copy, tmpl))
  270. startupLogger.Println("listening on port", *port)
  271. startupLogger.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
  272. }