Compare commits

...

4 commits

Author SHA1 Message Date
d377c9419d
docs: update README
Some checks are pending
Go / build (push) Waiting to run
2025-02-10 05:32:28 +03:00
6393fd1e4d
meta: integrate editorconfig 2025-02-10 05:29:09 +03:00
7d76753050
better caching 2025-02-10 05:28:54 +03:00
05843107d6
ci: update workflows 2025-02-10 05:25:53 +03:00
4 changed files with 82 additions and 35 deletions

3
.editorconfig Normal file
View file

@ -0,0 +1,3 @@
[{Makefile,go.mod,go.sum,*.go,.gitmodules}]
indent_style = tab
indent_size = 4

View file

@ -21,9 +21,7 @@ jobs:
run: go build -v ./.. run: go build -v ./..
- name: Upload a Build Artifact - name: Upload a Build Artifact
uses: actions/upload-artifact@v3.1.3 uses: actions/upload-artifact@v4
with: with:
# Artifact name
name: "catApi" name: "catApi"
# A file, directory or wildcard pattern that describes what to upload
path: "catApi" path: "catApi"

View file

@ -1,39 +1,40 @@
# 🐾 catApi # 🐾 catApi
> **catApi** is a minimal, self-hostable API endpoint for serving pictures of, you guessed it, cats! catApi is a minimal, self-hostable API (and frontend) for serving pictures of...
> but it could be used to serve anything, really you guessed, it cats! Who doesn't like cats?
## Usage ## API Documentation
There are two ways to "use" **catApi** - you can either serve it, blessing the world with pictures of your cat There are several API endpoints that you may query.
or be served. Below is the API documentation for visiting an existing instance of catApi.
### API Documentation ### `/api/id`
**catApi** exposes several endpoints.
#### ID
`/api/id` will return the image with the associated ID. `/api/id` will return the image with the associated ID.
For example **`http://localhost:3000/api/id?id=3`** will return the image with the ID of "3". For example **`http://localhost:3000/api/id?id=3`** will return the image with
the ID of "3".
#### List ### `/api/list`
`/api/list` will return eturn a JSON object containing data about the images within the /images directory `/api/list` will return return a JSON object containing data about the images
within the /images directory
For example, **`http://localhost:3000/api/random`** will return a JSON object that might be as follows For example, querying **`http://localhost:3000/api/random`** will return a JSON
object that might be as follows
> `[{"id":"0","url":"/api/id?id=0"},{"id":"1","url":"/api/id?id=1"},{"id":"2","url":"/api/id?id=2"}]` ```json
[
{ "filename": "0.jpg", "id": "0", "url": "/api/id?id=0" },
{ "filename": "1.jpg", "id": "1", "url": "/api/id?id=1" },
{ "filename": "10.jpg", "id": "2", "url": "/api/id?id=2" },
{ "filename": "11.jpg", "id": "3", "url": "/api/id?id=3" }
]
```
#### Random ### `/api/random`
`/api/random` will return a random image from the list of available images `/api/random` will return a random image from the list of available images
### Self-hosting
TODO
## License ## License
> **catApi** is licensed under the [MIT](https://github.com/NotAShelf/catApi/blob/v2/LICENSE) license. **catApi** is licensed under the [MIT License](./LICENSE)

63
main.go
View file

@ -2,8 +2,8 @@ package main
import ( import (
"encoding/json" "encoding/json"
"flag"
"html/template" "html/template"
"image"
"log" "log"
"math/rand" "math/rand"
"net/http" "net/http"
@ -20,6 +20,13 @@ var images []string
var logger = logrus.New() var logger = logrus.New()
var port string var port string
// Cache for image list, it should expire every 10 minutes
// but until it does, images should load faster in the frontend.
var cachedImages struct {
images []string
expiry time.Time
}
// Okay I admit, this is bad. Just a workaround for now, until I figure out a clean // Okay I admit, this is bad. Just a workaround for now, until I figure out a clean
// way of displaying all images in a grid. // way of displaying all images in a grid.
var tmpl = template.Must(template.New("index").Parse(` var tmpl = template.Must(template.New("index").Parse(`
@ -78,13 +85,12 @@ func main() {
viper.SetConfigName("config") // name of config file (without extension) viper.SetConfigName("config") // name of config file (without extension)
viper.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the name viper.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the name
viper.AddConfigPath(".") // path to look for the config file in viper.AddConfigPath(".") // path to look for the config file in
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
log.Fatalf("Error reading configuration file: %v", err) log.Fatalf("Error reading configuration file: %v", err)
} }
port = viper.GetString("server.port") port = viper.GetString("server.port")
flag.Parse()
images = getImages() images = getImages()
// Add request logging middleware // Add request logging middleware
@ -104,6 +110,14 @@ func main() {
log.Fatal(http.ListenAndServe(":"+port, nil)) log.Fatal(http.ListenAndServe(":"+port, nil))
} }
func getCachedImages() []string {
if time.Now().After(cachedImages.expiry) {
cachedImages.images = getImages()
cachedImages.expiry = time.Now().Add(10 * time.Minute)
}
return cachedImages.images
}
func getImages() []string { func getImages() []string {
files, err := os.ReadDir("images/") files, err := os.ReadDir("images/")
if err != nil { if err != nil {
@ -126,11 +140,12 @@ func homeHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
tmpl.Execute(w, struct { tmpl.Execute(w, struct {
Images []string Images []string
}{Images: images}) }{Images: getCachedImages()})
} }
func idHandler(w http.ResponseWriter, r *http.Request) { func idHandler(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id") id := r.URL.Query().Get("id")
if id == "" { if id == "" {
http.Error(w, "Missing id", http.StatusBadRequest) http.Error(w, "Missing id", http.StatusBadRequest)
return return
@ -159,11 +174,13 @@ func isValidImagePath(path string) bool {
} }
func listHandler(w http.ResponseWriter, r *http.Request) { func listHandler(w http.ResponseWriter, r *http.Request) {
imageList := []map[string]string{} imageList := []map[string]interface{}{}
for i := range images { for i := range getCachedImages() {
imageInfo := map[string]string{ imageInfo := map[string]interface{}{
"id": strconv.Itoa(i), "id": strconv.Itoa(i),
"url": "/api/id?id=" + strconv.Itoa(i), "url": "/api/id?id=" + strconv.Itoa(i),
"filename": images[i],
"size": getImageSize("images/" + images[i]),
} }
imageList = append(imageList, imageInfo) imageList = append(imageList, imageInfo)
} }
@ -178,6 +195,35 @@ func listHandler(w http.ResponseWriter, r *http.Request) {
w.Write(jsonData) w.Write(jsonData)
} }
func getImageSize(path string) map[string]interface{} {
file, err := os.Open(path)
if err != nil {
logger.WithError(err).Error("Error opening file for size")
return nil
}
defer file.Close()
// Decode image to get dimensions (JPEG/PNG only)
img, _, err := image.Decode(file)
if err != nil {
logger.WithError(err).Error("Error decoding image")
return nil
}
// Get file info for size
fileInfo, err := file.Stat()
if err != nil {
logger.WithError(err).Error("Error getting file info")
return nil
}
return map[string]interface{}{
"width": img.Bounds().Dx(),
"height": img.Bounds().Dy(),
"size": fileInfo.Size(),
}
}
func logRequest(next http.Handler) http.Handler { func logRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now() start := time.Now()
@ -189,8 +235,7 @@ func logRequest(next http.Handler) http.Handler {
} }
func randomHandler(w http.ResponseWriter, r *http.Request) { func randomHandler(w http.ResponseWriter, r *http.Request) {
source := rand.NewSource(time.Now().UnixNano()) rand.New(rand.NewSource(time.Now().UnixNano()))
rand.New(source)
i := rand.Intn(len(images)) i := rand.Intn(len(images))
http.Redirect(w, r, "/api/id?id="+strconv.Itoa(i), http.StatusSeeOther) http.Redirect(w, r, "/api/id?id="+strconv.Itoa(i), http.StatusSeeOther)