initial commit

This commit is contained in:
2025-12-07 11:32:45 +01:00
commit c3daed67ac
35 changed files with 1315 additions and 0 deletions
Executable
+39
View File
@@ -0,0 +1,39 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/teletype_softwares"
cmd = "go build -o ./tmp/teletype_softwares ./main.go"
delay = 1000
exclude_dir = [
"tmp",
]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
kill_delay = "0s"
log = "build-errors.log"
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
+4
View File
@@ -0,0 +1,4 @@
tmp
softwares
teletype_softwares
.env
+21
View File
@@ -0,0 +1,21 @@
# stage 1: building application binary file
FROM --platform=linux/amd64 golang:1.19-alpine as build
WORKDIR /app
COPY . .
RUN go mod tidy
RUN go build -o main ./main.go
# stage 2: copy only the application binary file and necessary files to the alpine container
FROM --platform=linux/amd64 alpine:latest
RUN apk --update add ca-certificates
WORKDIR /app
COPY --from=build /app/main .
COPY --from=build /app/http/openapi.yml .
# run the service on container startup.
CMD ["/app/main"]
+12
View File
@@ -0,0 +1,12 @@
FROM --platform=linux/amd64 golang:1.23-alpine
RUN apk add curl git
WORKDIR /app
COPY . .
RUN curl -fLo install.sh https://raw.githubusercontent.com/cosmtrek/air/master/install.sh \
&& chmod +x install.sh && sh install.sh && cp ./bin/air /bin/air
CMD "./entrypoint.sh"
+139
View File
@@ -0,0 +1,139 @@
services:
traefik:
image: traefik:v2.10
container_name: traefik
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
ports:
- "${TRAEFIK_WEB_PORT}:80"
- "${TRAEFIK_API_PORT}:8080"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
networks:
- proxy
gitea:
image: gitea/gitea:latest
container_name: gitea
environment:
DISABLE_REGISTRATION: "true"
ROOT_URL: "https://${GITEA_DOMAIN}"
ports:
- "${GITEA_SSH_PORT}:22"
volumes:
- gitea:/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.gitea.rule=Host(`${GITEA_DOMAIN}`)"
- "traefik.http.routers.gitea.entrypoints=web"
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
- "traefik.docker.network=proxy"
networks:
- proxy
woodpecker-server:
image: woodpeckerci/woodpecker-server:next-2a445a6663-alpine
container_name: woodpecker-server
environment:
WOODPECKER_HOST: "https://${WOODPECKER_DOMAIN}"
WOODPECKER_OPEN: "true"
WOODPECKER_GITEA: "true"
WOODPECKER_GITEA_URL: "https://${GITEA_DOMAIN}"
WOODPECKER_GITEA_CLIENT: "6de1c095-dd30-497b-811e-278759f71eba"
WOODPECKER_GITEA_SECRET: ${GITEA_CLIENT_SECRET}
WOODPECKER_SERVER_ADDR: ":8000"
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET}
volumes:
- woodpecker:/var/lib/woodpecker
labels:
- "traefik.enable=true"
- "traefik.http.routers.woodpecker.rule=Host(`${WOODPECKER_DOMAIN}`)"
- "traefik.http.routers.woodpecker.entrypoints=web"
- "traefik.http.services.woodpecker.loadbalancer.server.port=8000"
- "traefik.docker.network=proxy"
networks:
- proxy
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:next-2a445a6663-alpine
container_name: woodpecker-agent
environment:
WOODPECKER_SERVER: "woodpecker-server:9000"
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- softwares:/softwares
networks:
- proxy
mysql:
image: mysql:8
container_name: mysql
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
timeout: 20s
retries: 10
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: softwares
volumes:
- mysql-data:/var/lib/mysql
networks:
- proxy
webapp:
build:
context: .
dockerfile: Dockerfile.development
container_name: teletype-softwares
environment:
- DB_USER=root
- DB_PASSWORD=${DB_PASSWORD}
- DB_HOST=mysql
- DB_PORT=3306
- DB_NAME=softwares
- UPDATE_SECRET=${UPDATE_SECRET}
- GAMES_DIR=/softwares
depends_on:
mysql:
condition: service_healthy
volumes:
- softwares:/softwares
- ./:/app
labels:
- "traefik.enable=true"
- "traefik.http.routers.webapp.rule=Host(`${WEBAPP_DOMAIN}`)"
- "traefik.http.routers.webapp.entrypoints=web"
- "traefik.http.services.webapp.loadbalancer.server.port=80"
- "traefik.docker.network=proxy"
networks:
- proxy
droparea:
image: linuxserver/openssh-server
container_name: droparea
environment:
PUID: 1
PGID: 1
TZ: Europe/Budapest
SUDO_ACCESS: "false"
PASSWORD_ACCESS: "true"
USER_NAME: drop
USER_PASSWORD: ${DROP_PASSWORD}
volumes:
- softwares:/home/drop
ports:
- "${DROPAREA_SSH_PORT}:2222"
networks:
- proxy
volumes:
gitea:
woodpecker:
mysql-data:
softwares:
networks:
proxy:
+41
View File
@@ -0,0 +1,41 @@
package domain
import (
"teletype_softwares/lib/mysql_utils"
)
type Domain struct {
SoftwareRepository SoftwareRepository
ReleaseRepository ReleaseRepository
DownloadService DownloadService
SoftwareUpdaterService SoftwareUpdaterService
SoftwareService SoftwareService
}
func NewDomain() Domain {
DB := mysql_utils.Init()
MigrateGoDatabase(DB)
softwareRepository := &softwareRepository{db: DB}
releaseRepository := &releaseRepository{db: DB}
softwareService := &softwareService{softwareRepository: softwareRepository}
tic80Updater := &softwareUpdaterTIC80Service{
softwareRepository: softwareRepository,
releaseRepository: releaseRepository,
}
softwareUpdaterService := &softwareUpdaterService{tic80Updater: tic80Updater}
downloadServiceInstance := &downloadService{
softwareRepository: softwareRepository,
releaseRepository: releaseRepository,
}
return Domain{
SoftwareRepository: softwareRepository,
ReleaseRepository: releaseRepository,
DownloadService: downloadServiceInstance,
SoftwareUpdaterService: softwareUpdaterService,
SoftwareService: softwareService,
}
}
+12
View File
@@ -0,0 +1,12 @@
package domain
import (
"gorm.io/gorm"
)
func MigrateGoDatabase(db *gorm.DB) {
db.AutoMigrate(
Software{},
Release{},
)
}
+12
View File
@@ -0,0 +1,12 @@
package domain
import "gorm.io/gorm"
type Release struct {
gorm.Model
ID uint `gorm:"primaryKey" json:"id"`
SoftwareID uint `gorm:"index" json:"software_id"`
Version string `gorm:"size:64" json:"version"`
CartridgePath string `gorm:"size:255" json:"-"`
SourcePath string `gorm:"size:255" json:"-"`
}
+16
View File
@@ -0,0 +1,16 @@
package domain
import "gorm.io/gorm"
type Software struct {
gorm.Model
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex;size:128" json:"name"`
Title string `gorm:"size:255" json:"title"`
Author string `gorm:"size:255" json:"author"`
Desc string `gorm:"type:text" json:"desc"`
Site string `gorm:"size:255" json:"site"`
License string `gorm:"size:128" json:"license"`
Platform string `gorm:"size:128" json:"license"`
Releases []Release `json:"releases"`
}
+34
View File
@@ -0,0 +1,34 @@
package domain
import "gorm.io/gorm"
type ReleaseRepository interface {
Create(release *Release) error
FindLatestBySoftwareID(softwareID uint) (*Release, error)
FindBySoftwareIDAndVersion(softwareID uint, version string) (*Release, error)
}
type releaseRepository struct {
db *gorm.DB
}
// NewReleaseRepository creates a new instance of ReleaseRepository.
/* func NewReleaseRepository(db *gorm.DB) ReleaseRepository {
return &releaseRepository{db}
} */
func (r *releaseRepository) Create(release *Release) error {
return r.db.Create(release).Error
}
func (r *releaseRepository) FindLatestBySoftwareID(softwareID uint) (*Release, error) {
var release Release
err := r.db.Where("software_id = ?", softwareID).Order("created_at desc").First(&release).Error
return &release, err
}
func (r *releaseRepository) FindBySoftwareIDAndVersion(softwareID uint, version string) (*Release, error) {
var release Release
err := r.db.Where("software_id = ? AND version = ?", softwareID, version).First(&release).Error
return &release, err
}
+39
View File
@@ -0,0 +1,39 @@
package domain
import "gorm.io/gorm"
type SoftwareRepository interface {
List() ([]Software, error)
GetByName(name string) (*Software, error)
UpdateOrCreate(software *Software) error
}
type softwareRepository struct {
db *gorm.DB
}
func (r *softwareRepository) GetByName(name string) (*Software, error) {
var software Software
if err := r.db.Preload("Releases").Where("name = ?", name).First(&software).Error; err != nil {
return nil, err
}
return &software, nil
}
func (r *softwareRepository) List() ([]Software, error) {
var softwares []Software
if err := r.db.Preload("Releases").Find(&softwares).Error; err != nil {
return nil, err
}
return softwares, nil
}
func (r *softwareRepository) UpdateOrCreate(software *Software) error {
var existing Software
if err := r.db.Where("name = ?", software.Name).First(&existing).Error; err == nil {
software.ID = existing.ID
return r.db.Model(&existing).Updates(software).Error
} else {
return r.db.Create(software).Error
}
}
+49
View File
@@ -0,0 +1,49 @@
package domain
import (
"errors"
)
var (
ErrSoftwareNotFound = errors.New("software not found")
ErrNoReleasesFound = errors.New("no releases found for software")
ErrReleaseNotFound = errors.New("release not found for software and version")
)
type DownloadService interface {
GetLatestRelease(softwareName string) (*Release, error)
GetSpecificRelease(softwareName string, version string) (*Release, error)
}
type downloadService struct {
softwareRepository SoftwareRepository
releaseRepository ReleaseRepository
}
func (s *downloadService) GetLatestRelease(softwareName string) (*Release, error) {
software, err := s.softwareRepository.GetByName(softwareName)
if err != nil {
return nil, ErrSoftwareNotFound
}
release, err := s.releaseRepository.FindLatestBySoftwareID(software.ID)
if err != nil {
return nil, ErrNoReleasesFound
}
return release, nil
}
func (s *downloadService) GetSpecificRelease(softwareName string, version string) (*Release, error) {
software, err := s.softwareRepository.GetByName(softwareName)
if err != nil {
return nil, ErrSoftwareNotFound
}
release, err := s.releaseRepository.FindBySoftwareIDAndVersion(software.ID, version)
if err != nil {
return nil, ErrReleaseNotFound
}
return release, nil
}
+43
View File
@@ -0,0 +1,43 @@
package domain
import "sort"
type SoftwareListDTO struct {
Software
LatestRelease *Release
}
type SoftwareService interface {
List() ([]SoftwareListDTO, error)
GetByNameWithReleases(name string) (*Software, error)
}
type softwareService struct {
softwareRepository SoftwareRepository
}
func (s *softwareService) List() ([]SoftwareListDTO, error) {
softwares, err := s.softwareRepository.List()
if err != nil {
return nil, err
}
dtos := make([]SoftwareListDTO, 0, len(softwares))
for _, sw := range softwares {
dto := SoftwareListDTO{Software: sw}
if len(sw.Releases) > 0 {
// Sort releases by CreatedAt in descending order to find the latest
sort.Slice(sw.Releases, func(i, j int) bool {
return sw.Releases[i].CreatedAt.After(sw.Releases[j].CreatedAt)
})
dto.LatestRelease = &sw.Releases[0]
}
dtos = append(dtos, dto)
}
return dtos, nil
}
func (s *softwareService) GetByNameWithReleases(name string) (*Software, error) {
return s.softwareRepository.GetByName(name)
}
+22
View File
@@ -0,0 +1,22 @@
package domain
import (
"fmt"
)
// Keep os for getting GAME_PATH in TIC80 service if needed
type SoftwareUpdaterService interface {
UpdateSoftware(platform, name string) error
}
type softwareUpdaterService struct {
tic80Updater SoftwareUpdaterTIC80Service
}
func (s *softwareUpdaterService) UpdateSoftware(platform, name string) error {
if platform == "tic80" {
return s.tic80Updater.UpdateTIC80Software(name)
}
return fmt.Errorf("unsupported platform: %s", platform)
}
+193
View File
@@ -0,0 +1,193 @@
package domain
import (
"archive/zip"
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const TIC80_GAME_EXTENSION = ".tic" // Assuming .tic is the extension for TIC-80 games
type SoftwareUpdaterTIC80Service interface {
UpdateTIC80Software(name string) error
}
type softwareUpdaterTIC80Service struct {
softwareRepository SoftwareRepository
releaseRepository ReleaseRepository
}
func (s *softwareUpdaterTIC80Service) UpdateTIC80Software(name string) error {
fmt.Printf("TIC80 Updater: Starting update for name: %s\n", name)
gamePath, _ := os.LookupEnv("GAMES_DIR")
// Let's assume 'name' could be "mygame" which means "mygame.html.zip" or "mygame.lua" / "mygame.tic"
// Case 1: HTML game (name.html.zip)
zipFileName := fmt.Sprintf("%s.html.zip", name)
zipFilePath := filepath.Join(gamePath, zipFileName)
if _, err := os.Stat(zipFilePath); err == nil { // If the zip file exists
baseName := name
destDir := filepath.Join(gamePath, "html", baseName) // Destination is GAMES_DIR/html/name
fmt.Printf("TIC80 Updater: Unzipping %s to %s\n", zipFilePath, destDir)
if err := s.unzipSource(zipFilePath, destDir); err != nil {
return fmt.Errorf("error unzipping %s: %w", zipFileName, err)
}
if err := os.Remove(zipFilePath); err != nil {
fmt.Printf("TIC80 Updater: Deleting zip file: %s\n", zipFilePath)
return fmt.Errorf("error deleting zip file %s: %w", zipFileName, err)
}
fmt.Printf("TIC80 Updater: Successfully processed HTML game: %s\n", name)
}
// Case 2: TIC-80 Lua game (name.lua and name.tic)
luaFileName := fmt.Sprintf("%s.lua", name)
cartridgeFileName := fmt.Sprintf("%s.tic", name)
luaFilePath := filepath.Join(gamePath, luaFileName)
cartridgeFilePath := filepath.Join(gamePath, cartridgeFileName)
if _, err := os.Stat(luaFilePath); err == nil { // If the lua file exists
if _, err := os.Stat(cartridgeFilePath); os.IsNotExist(err) {
return fmt.Errorf("missing cartridge file '%s' for '%s'", cartridgeFileName, luaFileName)
}
software, version := s.parseMeta(luaFilePath, name)
if version == "" {
return fmt.Errorf("missing version info in '%s'", luaFileName)
}
if err := s.softwareRepository.UpdateOrCreate(&software); err != nil {
return err
}
// Create software directory
softwareDir := filepath.Join(gamePath, software.Name)
fmt.Printf("TIC80 Updater: Creating software directory: %s\n", softwareDir)
os.MkdirAll(softwareDir, os.ModePerm)
// New file paths
newCartridgePath := filepath.Join(softwareDir, fmt.Sprintf("%s-%s.tic", software.Name, version))
newSourcePath := filepath.Join(softwareDir, fmt.Sprintf("%s-%s.lua", software.Name, version))
// Move files
fmt.Printf("TIC80 Updater: Moving cartridge from %s to %s\n", cartridgeFilePath, newCartridgePath)
if err := os.Rename(cartridgeFilePath, newCartridgePath); err != nil {
return err
}
fmt.Printf("TIC80 Updater: Moving Lua source from %s to %s\n", luaFilePath, newSourcePath)
if err := os.Rename(luaFilePath, newSourcePath); err != nil {
return err
}
// Create Release
release := &Release{
SoftwareID: software.ID,
Version: version,
CartridgePath: newCartridgePath,
SourcePath: newSourcePath,
}
if err := s.releaseRepository.Create(release); err != nil {
return err
}
fmt.Printf("TIC80 Updater: Successfully processed Lua game: %s, version: %s\n", name, version)
return nil // Lua game processed
}
return fmt.Errorf("no recognizable game file found for '%s' in '%s'", name, gamePath)
}
// unzipSource extracts a zip archive to a destination directory.
func (s *softwareUpdaterTIC80Service) unzipSource(source, destination string) error {
reader, err := zip.OpenReader(source)
if err != nil {
return err
}
defer reader.Close()
destination, err = filepath.Abs(destination)
if err != nil {
return err
}
for _, f := range reader.File {
fpath := filepath.Join(destination, f.Name)
if !strings.HasPrefix(fpath, destination) {
return fmt.Errorf("%s: illegal file path", fpath)
}
if f.FileInfo().IsDir() {
os.MkdirAll(fpath, os.ModePerm)
continue
}
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
return err
}
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
_, err = io.Copy(outFile, rc)
outFile.Close()
rc.Close()
if err != nil {
return err
}
}
return nil
}
func (s *softwareUpdaterTIC80Service) parseMeta(path string, name string) (Software, string) {
file, _ := os.Open(path)
defer file.Close()
var g Software
var version string
g.Name = name
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "--") {
parts := strings.SplitN(strings.TrimPrefix(line, "--"), ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
switch strings.ToLower(key) {
case "title":
g.Title = val
case "author":
g.Author = val
case "desc":
g.Desc = val
case "site":
g.Site = val
case "license":
g.License = val
case "version":
version = val
}
} else {
break
}
}
return g, version
}
+1
View File
@@ -0,0 +1 @@
INSERT INTO softwares (id, name, title, author, description, site, license) VALUES (1, 'bombexpert', 'Bomb Expert', 'GameDev Inc.', 'A game about defusing bombs.', 'http://bombexpert.com', 'MIT'), (2, 'platformer', 'Jump King', 'IndieDev Studio', 'A challenging platformer.', 'http://jumpking.com', 'Proprietary'), (3, 'shooter', 'Space Invaders Clone', 'Retro Games', 'Classic arcade shooter.', 'http://spaceinvaders.com', 'GPL'), (4, 'puzzle', 'Mind Bender', 'Logic Labs', 'Brain-teasing puzzles.', 'http://mindbender.com', 'Apache'), (5, 'rpg', 'Fantasy Quest', 'Epic RPGs', 'An epic fantasy adventure.', 'http://fantasyquest.com', 'CC-BY-NC'), (6, 'arcade', 'Pixel Dash', 'Arcade Classics', 'Fast-paced arcade action.', 'http://pixeldash.com', 'MIT'); INSERT INTO releases (software_id, version, cartridge_path, source_path) VALUES (1, '1.0.0', '/softwares/bombexpert/bombexpert-1.0.0.tic', '/softwares/bombexpert/bombexpert-1.0.0.lua'), (1, '1.0.1', '/softwares/bombexpert/bombexpert-1.0.1.tic', '/softwares/bombexpert/bombexpert-1.0.1.lua'), (1, '1.0.2', '/softwares/bombexpert/bombexpert-1.0.2.tic', '/softwares/bombexpert/bombexpert-1.0.2.lua'), (2, '1.0.0', '/softwares/platformer/platformer-1.0.0.tic', '/softwares/platformer/platformer-1.0.0.lua'), (2, '1.0.1', '/softwares/platformer/platformer-1.0.1.tic', '/softwares/platformer/platformer-1.0.1.lua'), (2, '1.0.2', '/softwares/platformer/platformer-1.0.2.tic', '/softwares/platformer/platformer-1.0.2.lua'), (3, '1.0.0', '/softwares/shooter/shooter-1.0.0.tic', '/softwares/shooter/shooter-1.0.0.lua'), (3, '1.0.1', '/softwares/shooter/shooter-1.0.1.tic', '/softwares/shooter/shooter-1.0.1.lua'), (3, '1.0.2', '/softwares/shooter/shooter-1.0.2.tic', '/softwares/shooter/shooter-1.0.2.lua'), (4, '1.0.0', '/softwares/puzzle/puzzle-1.0.0.tic', '/softwares/puzzle/puzzle-1.0.0.lua'), (4, '1.0.1', '/softwares/puzzle/puzzle-1.0.1.tic', '/softwares/puzzle/puzzle-1.0.1.lua'), (4, '1.0.2', '/softwares/puzzle/puzzle-1.0.2.tic', '/softwares/puzzle/puzzle-1.0.2.lua'), (5, '1.0.0', '/softwares/rpg/rpg-1.0.0.tic', '/softwares/rpg/rpg-1.0.0.lua'), (5, '1.0.1', '/softwares/rpg/rpg-1.0.1.tic', '/softwares/rpg/rpg-1.0.1.lua'), (5, '1.0.2', '/softwares/rpg/rpg-1.0.2.tic', '/softwares/rpg/rpg-1.0.2.lua'), (6, '1.0.0', '/softwares/arcade/arcade-1.0.0.tic', '/softwares/arcade/arcade-1.0.0.lua'), (6, '1.0.1', '/softwares/arcade/arcade-1.0.1.tic', '/softwares/arcade/arcade-1.0.1.lua'), (6, '1.0.2', '/softwares/arcade/arcade-1.0.2.tic', '/softwares/arcade/arcade-1.0.2.lua');
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
go mod download && air -c .air.toml
+18
View File
@@ -0,0 +1,18 @@
# Domains
GITEA_DOMAIN=gitea.vps.teletype.hu
WOODPECKER_DOMAIN=woodpecker.vps.teletype.hu
WEBAPP_DOMAIN=softwares.vps.teletype.hu
# Ports
GITEA_SSH_PORT=2222
DROPAREA_SSH_PORT=2223
TRAEFIK_WEB_PORT=9100
TRAEFIK_API_PORT=9101
# Secrets
GITEA_CLIENT_SECRET=
WOODPECKER_AGENT_SECRET=
MYSQL_ROOT_PASSWORD=
DB_PASSWORD=
UPDATE_SECRET=
DROP_PASSWORD=
+17
View File
@@ -0,0 +1,17 @@
module teletype_softwares
go 1.21.5
require (
github.com/DATA-DOG/go-sqlmock v1.5.1
github.com/go-chi/chi/v5 v5.0.10
github.com/google/uuid v1.4.0
gorm.io/driver/mysql v1.5.2
gorm.io/gorm v1.25.5
)
require (
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
)
+18
View File
@@ -0,0 +1,18 @@
github.com/DATA-DOG/go-sqlmock v1.5.1 h1:FK6RCIUSfmbnI/imIICmboyQBkOckutaa6R5YYlLZyo=
github.com/DATA-DOG/go-sqlmock v1.5.1/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
+106
View File
@@ -0,0 +1,106 @@
package http
import (
"net/http"
"os"
"path/filepath"
"strings"
"teletype_softwares/domain"
"github.com/go-chi/chi/v5"
)
type DownloadController struct {
downloadService domain.DownloadService
}
func (c *DownloadController) serveReleaseFile(w http.ResponseWriter, r *http.Request, release *domain.Release, isSource bool) {
var filePath string
if isSource {
filePath = release.SourcePath
} else {
filePath = release.CartridgePath
}
// Security check: ensure the file is within the softwares directory
absFilePath, err := filepath.Abs(filePath)
if err != nil {
http.Error(w, "Invalid file path", http.StatusInternalServerError)
return
}
absSoftwaresDir, err := filepath.Abs(os.Getenv("GAMES_DIR"))
if err != nil {
http.Error(w, "Invalid softwares directory path", http.StatusInternalServerError)
return
}
if !strings.HasPrefix(absFilePath, absSoftwaresDir) {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
w.Header().Set("Content-Disposition", "attachment; filename="+filepath.Base(filePath))
http.ServeFile(w, r, filePath)
}
func (c *DownloadController) DownloadSource(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
release, err := c.downloadService.GetLatestRelease(name)
if err != nil {
if err == domain.ErrSoftwareNotFound || err == domain.ErrNoReleasesFound {
http.Error(w, "Software not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
c.serveReleaseFile(w, r, release, true)
}
func (c *DownloadController) DownloadCartridge(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
release, err := c.downloadService.GetLatestRelease(name)
if err != nil {
if err == domain.ErrSoftwareNotFound || err == domain.ErrNoReleasesFound {
http.Error(w, "Software not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
c.serveReleaseFile(w, r, release, false)
}
func (c *DownloadController) DownloadSourceByVersion(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
version := chi.URLParam(r, "version")
release, err := c.downloadService.GetSpecificRelease(name, version)
if err != nil {
if err == domain.ErrSoftwareNotFound || err == domain.ErrReleaseNotFound {
http.Error(w, "Software or release not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
c.serveReleaseFile(w, r, release, true)
}
func (c *DownloadController) DownloadCartridgeByVersion(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
version := chi.URLParam(r, "version")
release, err := c.downloadService.GetSpecificRelease(name, version)
if err != nil {
if err == domain.ErrSoftwareNotFound || err == domain.ErrReleaseNotFound {
http.Error(w, "Software or release not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
c.serveReleaseFile(w, r, release, false)
}
+61
View File
@@ -0,0 +1,61 @@
package http
import (
"fmt"
"html/template" // Only import if template parsing is done here
"net/http"
"os"
"path/filepath"
"github.com/go-chi/chi/v5"
)
type PlayController struct{}
// PlayGame renders the play.html template, which embeds the game content in an iframe.
// Templates are parsed on each request, mirroring the original SoftwareController's approach.
func (c *PlayController) PlayGame(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if name == "" {
http.Error(w, "Game name not provided", http.StatusBadRequest)
return
}
// Parse templates on each request
tmpl, err := template.ParseFiles("http/views/layouts/main.html", "http/views/play.html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := struct {
Name string
}{
Name: name,
}
tmpl.Execute(w, data)
}
// ServeGameContent serves the static files for a game from the html/$NAME/ directory.
func (c *PlayController) ServeGameContent(w http.ResponseWriter, r *http.Request) {
gamePath := os.Getenv("GAMES_DIR")
name := chi.URLParam(r, "name")
if name == "" {
http.Error(w, "Game name not provided", http.StatusBadRequest)
return
}
htmlBaseDir := filepath.Join(gamePath, "html", name)
// Check if the directory exists and is accessible
if _, err := os.Stat(htmlBaseDir); os.IsNotExist(err) {
http.Error(w, fmt.Sprintf("HTML content for game '%s' not found.", name), http.StatusNotFound)
return
} else if err != nil {
http.Error(w, fmt.Sprintf("Error accessing HTML content for game '%s': %v", name, err), http.StatusInternalServerError)
return
}
fs := http.StripPrefix(fmt.Sprintf("/play/%s/content", name), http.FileServer(http.Dir(htmlBaseDir)))
fs.ServeHTTP(w, r)
}
+53
View File
@@ -0,0 +1,53 @@
package http
import (
"html/template"
"net/http"
"teletype_softwares/domain"
"github.com/go-chi/chi/v5"
)
type SoftwareController struct {
softwareService domain.SoftwareService
}
func (c *SoftwareController) index(w http.ResponseWriter, r *http.Request) {
softwares, err := c.softwareService.List()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles("http/views/layouts/main.html", "http/views/index.html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl.Execute(w, softwares)
}
func (c *SoftwareController) releases(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
software, err := c.softwareService.GetByNameWithReleases(name)
if err != nil {
if err == domain.ErrSoftwareNotFound {
http.Error(w, "Software not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles("http/views/layouts/main.html", "http/views/releases.html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl.Execute(w, map[string]interface{}{
"Software": software,
})
}
+29
View File
@@ -0,0 +1,29 @@
package http
import (
"net/http"
"os"
"teletype_softwares/domain"
)
type SoftwareUpdaterController struct {
softwareUpdaterService domain.SoftwareUpdaterService
}
func (c *SoftwareUpdaterController) update(w http.ResponseWriter, r *http.Request) {
secret := r.URL.Query().Get("secret")
if secret != os.Getenv("UPDATE_SECRET") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
platform := r.URL.Query().Get("platform")
name := r.URL.Query().Get("name")
if err := c.softwareUpdaterService.UpdateSoftware(platform, name); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write([]byte("Updated"))
}
Executable
+21
View File
@@ -0,0 +1,21 @@
package http
import (
"teletype_softwares/domain"
"teletype_softwares/lib/http_utils"
)
func StartHttpServer(domain domain.Domain) {
router := Router{
SoftwareController: &SoftwareController{softwareService: domain.SoftwareService},
SoftwareUpdaterController: &SoftwareUpdaterController{
softwareUpdaterService: domain.SoftwareUpdaterService,
},
DownloadController: &DownloadController{downloadService: domain.DownloadService},
PlayController: &PlayController{},
}.Init()
http_utils.StartGenericHTTPServer(http_utils.StartGenericHTTPServerContext{
Router: router,
})
}
Executable
+28
View File
@@ -0,0 +1,28 @@
package http
import (
"github.com/go-chi/chi/v5"
)
type Router struct {
SoftwareController *SoftwareController
SoftwareUpdaterController *SoftwareUpdaterController
DownloadController *DownloadController
PlayController *PlayController
}
func (r Router) Init() *chi.Mux {
router := chi.NewRouter()
router.Get("/", r.SoftwareController.index)
router.Get("/update", r.SoftwareUpdaterController.update)
router.Get("/releases/{name}", r.SoftwareController.releases)
router.Get("/download/{name}/source", r.DownloadController.DownloadSource)
router.Get("/download/{name}/cartridge", r.DownloadController.DownloadCartridge)
router.Get("/download/{name}/{version}/source", r.DownloadController.DownloadSourceByVersion)
router.Get("/download/{name}/{version}/cartridge", r.DownloadController.DownloadCartridgeByVersion)
router.Get("/play/{name}", r.PlayController.PlayGame)
router.Get("/play/{name}/content*", r.PlayController.ServeGameContent)
return router
}
+28
View File
@@ -0,0 +1,28 @@
{{define "content"}}
<h1 class="my-4">Softwares</h1>
<div class="row">
{{range .}}
<div class="col-12 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{{.Title}}</h5>
<h6 class="card-subtitle mb-2 text-muted">{{.Author}}</h6>
<p class="card-text">{{.Desc}}</p>
<p class="card-text"><strong>Version:</strong> {{.LatestRelease.Version}}</p>
<p class="card-text"><strong>License:</strong> {{.License}}</p>
<p class="card-text"><a href="{{.Site}}" target="_blank">Website</a></p>
</div>
<div class="card-footer">
<div class="btn-group d-flex justify-content-end" role="group" aria-label="Operations">
<a href="/download/{{.Name}}/source" class="btn btn-primary btn-sm">Source</a>
<a href="/download/{{.Name}}/cartridge" class="btn btn-primary btn-sm">Cartridge</a>
<a href="/releases/{{.Name}}" class="btn btn-primary btn-sm">Releases</a>
<a href="/play/{{.Name}}" class="btn btn-primary btn-sm">Play</a>
</div>
</div>
</div>
</div>
{{end}}
</div>
{{end}}
+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Teletype Softwares</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<style>
body {
background-color: black;
color: limegreen; /* Light green */
font-family: "Lucida Console", "Courier New", monospace;
}
.card {
background-color: #333; /* Darker background for cards */
color: limegreen;
border: 1px solid limegreen;
}
a {
color: limegreen;
}
a:hover {
color: #00FF00; /* Brighter green on hover */
}
.btn-primary {
background-color: limegreen;
border-color: limegreen;
color: black;
}
.btn-primary:hover {
background-color: #00FF00;
border-color: #00FF00;
color: black;
}
/* Table styling for black background and green borders */
.table {
--bs-table-bg: black; /* Bootstrap 5 variable for table background */
--bs-table-color: limegreen; /* Text color for table */
--bs-table-border-color: limegreen; /* Border color for the table and cells */
}
.table-striped > tbody > tr:nth-of-type(odd) > * {
--bs-table-bg-type: #1a1a1a; /* Slightly lighter black for striped rows */
color: limegreen;
}
.table-hover > tbody > tr:hover > * {
--bs-table-hover-bg: #444; /* Darker grey on hover */
color: limegreen;
}
.table-bordered > :not(caption) > * > * {
border-color: limegreen;
}
.bg-dark { /* Ensure consistency for manually set dark backgrounds */
background-color: black !important;
}
.text-success { /* Ensure consistency for success text */
color: limegreen !important;
}
</style>
</head>
<body>
<div class="container mt-4">
<h1 class="text-center mb-4">Teletype Games</h1>
{{block "content" .}}{{end}}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
{{define "content"}}
<h1 class="my-4">Play {{.Name}}</h1>
<div class="ratio ratio-4x3" style="max-width: 640px; margin: 0 auto;">
<iframe id="gameFrame" src="/play/{{.Name}}/content/index.html" frameborder="0" allowfullscreen></iframe>
</div>
<p style="margin-top: 20px"><a href="/" class="btn btn-primary">Back to Softwares</a></p>
{{end}}
+31
View File
@@ -0,0 +1,31 @@
{{define "content"}}
<h1 class="my-4">Releases for {{.Software.Title}}</h1>
<div class="table-responsive">
<table class="table table-striped table-hover table-bordered">
<thead class="bg-dark text-success">
<tr>
<th>Version</th>
<th>Released At</th>
<th>Downloads</th>
</tr>
</thead>
<tbody>
{{range .Software.Releases}}
<tr>
<td>{{.Version}}</td>
<td>{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
<td>
<div class="btn-group" role="group" aria-label="Download options">
<a href="/download/{{$.Software.Name}}/{{.Version}}/source" class="btn btn-primary btn-sm">Source</a>
<a href="/download/{{$.Software.Name}}/{{.Version}}/cartridge" class="btn btn-primary btn-sm">Cartridge</a>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p><a href="/" class="btn btn-primary">Back to Softwares</a></p>
{{end}}
+38
View File
@@ -0,0 +1,38 @@
package http_utils
import (
"encoding/json"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func RespondWithJSON(w http.ResponseWriter, code int, payload interface{}) {
response, _ := json.Marshal(payload)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(response)
}
func GetParam(r *http.Request, name string) string {
return chi.URLParam(r, name)
}
func GetParamUUID(r *http.Request, name string) (uuid.UUID, error) {
return uuid.Parse(GetParam(r, name))
}
type StartGenericHTTPServerContext struct {
Router *chi.Mux
}
func StartGenericHTTPServer(ctx StartGenericHTTPServerContext) {
r := chi.NewRouter()
r.Use(LoggingMiddleware)
r.Mount("/", ctx.Router)
http.Handle("/", r)
log.Fatal(http.ListenAndServe(":80", r))
}
+13
View File
@@ -0,0 +1,13 @@
package http_utils
import (
"fmt"
"net/http"
)
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("%s %s\n", r.Method, r.RequestURI)
next.ServeHTTP(w, r)
})
}
+48
View File
@@ -0,0 +1,48 @@
package mysql_utils
import (
"database/sql"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
type DBMockSuite struct {
DB *gorm.DB
Mock sqlmock.Sqlmock
}
func NewDBMockSuite() *DBMockSuite {
var (
db *sql.DB
gormdb *gorm.DB
mock sqlmock.Sqlmock
err error
)
db, mock, err = sqlmock.New()
if err != nil {
panic("SQL Mock error")
}
mock.ExpectQuery("SELECT VERSION()").WithArgs().WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
gormdb, err = gorm.Open(mysql.New(mysql.Config{
DSN: "sqlmock_db_0",
DriverName: "mysql",
Conn: db,
}), &gorm.Config{})
if err != nil {
panic("GORM open error")
}
// gormdb.AutoMigrate()
return &DBMockSuite{
DB: gormdb,
Mock: mock,
}
}
+38
View File
@@ -0,0 +1,38 @@
package mysql_utils
import (
"fmt"
"os"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
type DbConnectData struct {
User string
Password string
Host string
Port string
Database string
}
func getDbConnectData() DbConnectData {
return DbConnectData{
User: os.Getenv("DB_USER"),
Password: os.Getenv("DB_PASSWORD"),
Host: os.Getenv("DB_HOST"),
Port: os.Getenv("DB_PORT"),
Database: os.Getenv("DB_NAME"),
}
}
func Init() *gorm.DB {
c := getDbConnectData()
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", c.User, c.Password, c.Host, c.Port, c.Database)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic(err)
}
return db
}
Executable
+11
View File
@@ -0,0 +1,11 @@
package main
import (
"teletype_softwares/domain"
"teletype_softwares/http"
)
func main() {
domain := domain.NewDomain()
http.StartHttpServer(domain)
}