1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
// osgameclones-server serves the OSS games page.
//
// Usage: osgameclones-server -db <path> -addr <host:port>
//
// Routes:
//
//	GET /              HTML page with game grids by genre
//	GET /image/<id>    Hero image blob from the database
package main

import (
	"database/sql"
	"flag"
	"fmt"
	"net/http"
	"os"
	"strings"

	_ "modernc.org/sqlite"
)

func main() {
	dbPath := flag.String("db", "", "Path to the osgameclones SQLite database (required)")
	addr := flag.String("addr", "127.0.0.1:8770", "Listen address")
	flag.Parse()

	if *dbPath == "" {
		fmt.Fprintln(os.Stderr, "osgameclones-server: -db is required")
		os.Exit(1)
	}

	// Pragmas travel in the DSN so they apply to every pooled connection:
	// busy_timeout (a writer waits for the lock instead of failing with
	// SQLITE_BUSY), and WAL so readers do not block the writer. A post-open
	// db.Exec("PRAGMA …") would only configure whichever single pooled
	// connection ran it, leaving the others at SQLite's defaults.
	db, err := sql.Open("sqlite",
		"file:"+*dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
	if err != nil {
		fmt.Fprintf(os.Stderr, "osgameclones-server: open db: %v\n", err)
		os.Exit(1)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/" {
			http.NotFound(w, r)
			return
		}
		if err := serveIndex(w, db); err != nil {
			fmt.Fprintf(os.Stderr, "serveIndex: %v\n", err)
			http.Error(w, "internal error", 500)
		}
	})
	mux.HandleFunc("GET /image/", func(w http.ResponseWriter, r *http.Request) {
		id := strings.TrimPrefix(r.URL.Path, "/image/")
		serveImage(w, r, db, id)
	})

	fmt.Fprintf(os.Stderr, "osgameclones-server: listening on %s\n", *addr)
	if err := http.ListenAndServe(*addr, mux); err != nil {
		fmt.Fprintf(os.Stderr, "osgameclones-server: %v\n", err)
		os.Exit(1)
	}
}

// ── /image/<id> ──────────────────────────────────────────────────────────────

func serveImage(w http.ResponseWriter, r *http.Request, db *sql.DB, id string) {
	var mime string
	var data []byte
	err := db.QueryRow(
		`SELECT mime_type, data FROM game_hero_images WHERE game_id = ?`, id,
	).Scan(&mime, &data)
	if err == sql.ErrNoRows {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		http.Error(w, "db error", 500)
		return
	}
	w.Header().Set("Content-Type", mime)
	w.Header().Set("Cache-Control", "public, max-age=86400")
	w.Write(data)
}

// ── / ────────────────────────────────────────────────────────────────────────

type pageGame struct {
	ID   int64
	Name string
	URL  string
	Repo string
}

func serveIndex(w http.ResponseWriter, db *sql.DB) error {
	rows, err := db.Query(`
		WITH
		shuffled AS (
		    SELECT
		        g.id       AS game_id,
		        g.name     AS game_name,
		        g.url,
		        g.repo,
		        random()   AS rnd
		    FROM games g
		    JOIN game_hero_images h  ON h.game_id  = g.id
		    WHERE g.status     = 'playable'
		      AND g.development IN ('complete', 'very active', 'active')
		      AND g.content    = 'open'
		      AND NOT EXISTS (SELECT 1 FROM engines e WHERE e.game_id = g.id)
		),
		one_genre AS (
		    SELECT * FROM (
		        SELECT
		            s.*,
		            COALESCE(
		                (SELECT go.genre FROM genre_overrides go
		                 JOIN game_originals gap ON gap.game_id = s.game_id
		                 JOIN originals o ON o.id = gap.original_id
		                 WHERE o.name = go.original_name
		                 LIMIT 1),
		                (SELECT MIN(genre) FROM game_genres WHERE game_id = s.game_id)
		            ) AS genre
		        FROM shuffled s
		    ) WHERE genre IS NOT NULL
		),
		ranked AS (
		    SELECT *,
		        ROW_NUMBER() OVER (PARTITION BY genre ORDER BY rnd) AS rank_in_genre
		    FROM one_genre
		)
		SELECT genre, game_id, game_name, url, repo
		FROM ranked
		WHERE rank_in_genre <= 10
		ORDER BY genre, rank_in_genre
	`)
	if err != nil {
		return err
	}
	defer rows.Close()

	byGenre := map[string][]pageGame{}
	genreOrder := []string{}
	for rows.Next() {
		var genre, name string
		var id int64
		var url, repo sql.NullString
		if err := rows.Scan(&genre, &id, &name, &url, &repo); err != nil {
			return err
		}
		if _, seen := byGenre[genre]; !seen {
			genreOrder = append(genreOrder, genre)
		}
		if len(byGenre[genre]) < 10 {
			byGenre[genre] = append(byGenre[genre], pageGame{ID: id, Name: name, URL: url.String, Repo: repo.String})
		}
	}
	if err := rows.Err(); err != nil {
		return err
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	fmt.Fprint(w, htmlHeader)

	for _, genre := range genreOrder {
		games := byGenre[genre]
		fmt.Fprintf(w, "<h2>%s</h2>\n<div class=\"grid\">\n", he(genre))
		for _, g := range games {
			link := g.URL
			if link == "" {
				link = g.Repo
			}
			if link == "" {
				link = "#"
			}
			fmt.Fprintf(w,
				"  <div class=\"card\"><a href=\"%s\" target=\"_blank\" rel=\"noopener\">"+
					"<img src=\"/image/%d\" alt=\"%s\" loading=\"lazy\">"+
					"<div class=\"card-name\">%s</div>"+
					"</a></div>\n",
				he(link), g.ID, he(g.Name), he(g.Name),
			)
		}
		fmt.Fprint(w, "</div>\n")
	}

	fmt.Fprint(w, "</body>\n</html>\n")
	return nil
}

func he(s string) string {
	s = strings.ReplaceAll(s, "&", "&amp;")
	s = strings.ReplaceAll(s, "<", "&lt;")
	s = strings.ReplaceAll(s, ">", "&gt;")
	s = strings.ReplaceAll(s, "\"", "&#34;")
	return s
}

const htmlHeader = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Open Source Games — What to play today?</title>
<style>
  body { font-family: system-ui, sans-serif; background: #111; color: #eee; margin: 0; padding: 2rem; }
  h1 { color: #fff; margin-bottom: 2rem; }
  h2 { color: #adf; border-bottom: 1px solid #333; padding-bottom: .4rem; margin-top: 2.5rem; }
  .grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 1rem;
    max-width: 900px;
  }
  .card {
    background: #1e1e1e;
    border-radius: 6px;
    overflow: hidden;
  }
  .card a { text-decoration: none; color: inherit; display: block; position: relative; }
  .card img {
    width: 100%;
    height: 150px;
    object-fit: cover;
    object-position: top;
    display: block;
    background: #2a2a2a;
  }
  .card-name {
    position: absolute;
    top: 0; right: 0;
    max-width: 400px;
    padding: .25rem .5rem;
    font-size: .8rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    color: #fff;
    background: rgba(0,0,0,.55);
    backdrop-filter: blur(2px);
    border-bottom-left-radius: 4px;
  }
</style>
</head>
<body>
<h1>Open Source Games — What to play today?</h1>
`