commit c3daed67ac86af8f5d87f682fbb91ddfae982c79 Author: Zsolt Tasnadi Date: Fri Dec 5 19:13:22 2025 +0100 initial commit diff --git a/.air.toml b/.air.toml new file mode 100755 index 0000000..7893174 --- /dev/null +++ b/.air.toml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ceb03b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +tmp +softwares +teletype_softwares +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8d8c0a0 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/Dockerfile.development b/Dockerfile.development new file mode 100755 index 0000000..f01ad4a --- /dev/null +++ b/Dockerfile.development @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2759051 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/domain/domain.go b/domain/domain.go new file mode 100755 index 0000000..0cb6dce --- /dev/null +++ b/domain/domain.go @@ -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, + } +} diff --git a/domain/model.migrate.go b/domain/model.migrate.go new file mode 100644 index 0000000..4dbf09c --- /dev/null +++ b/domain/model.migrate.go @@ -0,0 +1,12 @@ +package domain + +import ( + "gorm.io/gorm" +) + +func MigrateGoDatabase(db *gorm.DB) { + db.AutoMigrate( + Software{}, + Release{}, + ) +} diff --git a/domain/model.release.go b/domain/model.release.go new file mode 100644 index 0000000..8d56935 --- /dev/null +++ b/domain/model.release.go @@ -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:"-"` +} diff --git a/domain/model.software.go b/domain/model.software.go new file mode 100644 index 0000000..102dfe1 --- /dev/null +++ b/domain/model.software.go @@ -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"` +} diff --git a/domain/repository.release.go b/domain/repository.release.go new file mode 100644 index 0000000..0e708ae --- /dev/null +++ b/domain/repository.release.go @@ -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 +} diff --git a/domain/repository.software.go b/domain/repository.software.go new file mode 100644 index 0000000..221d320 --- /dev/null +++ b/domain/repository.software.go @@ -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 + } +} diff --git a/domain/service.download.go b/domain/service.download.go new file mode 100644 index 0000000..800123f --- /dev/null +++ b/domain/service.download.go @@ -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 +} diff --git a/domain/service.software.go b/domain/service.software.go new file mode 100644 index 0000000..b1ec812 --- /dev/null +++ b/domain/service.software.go @@ -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) +} diff --git a/domain/service.software_updater.go b/domain/service.software_updater.go new file mode 100644 index 0000000..73944e2 --- /dev/null +++ b/domain/service.software_updater.go @@ -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) +} diff --git a/domain/service.software_updater_tic80.go b/domain/service.software_updater_tic80.go new file mode 100644 index 0000000..1d67c80 --- /dev/null +++ b/domain/service.software_updater_tic80.go @@ -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 +} diff --git a/dummy.sql b/dummy.sql new file mode 100644 index 0000000..2928d88 --- /dev/null +++ b/dummy.sql @@ -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'); \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..3447204 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,2 @@ +#!/bin/sh +go mod download && air -c .air.toml diff --git a/env-example b/env-example new file mode 100644 index 0000000..5b49185 --- /dev/null +++ b/env-example @@ -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= diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..28e2c4e --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..479e353 --- /dev/null +++ b/go.sum @@ -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= diff --git a/http/controller.download.go b/http/controller.download.go new file mode 100644 index 0000000..37b733c --- /dev/null +++ b/http/controller.download.go @@ -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) +} diff --git a/http/controller.play.go b/http/controller.play.go new file mode 100644 index 0000000..d21fd25 --- /dev/null +++ b/http/controller.play.go @@ -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) +} diff --git a/http/controller.software.go b/http/controller.software.go new file mode 100644 index 0000000..3726f2c --- /dev/null +++ b/http/controller.software.go @@ -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, + }) +} diff --git a/http/controller.software_updater.go b/http/controller.software_updater.go new file mode 100644 index 0000000..90ec2cd --- /dev/null +++ b/http/controller.software_updater.go @@ -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")) +} diff --git a/http/http.go b/http/http.go new file mode 100755 index 0000000..ec7153c --- /dev/null +++ b/http/http.go @@ -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, + }) +} diff --git a/http/router.go b/http/router.go new file mode 100755 index 0000000..d85e1a6 --- /dev/null +++ b/http/router.go @@ -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 +} diff --git a/http/views/index.html b/http/views/index.html new file mode 100644 index 0000000..6515002 --- /dev/null +++ b/http/views/index.html @@ -0,0 +1,28 @@ +{{define "content"}} +

Softwares

+ +
+ {{range .}} +
+
+
+
{{.Title}}
+
{{.Author}}
+

{{.Desc}}

+

Version: {{.LatestRelease.Version}}

+

License: {{.License}}

+

Website

+
+ +
+
+ {{end}} +
+{{end}} \ No newline at end of file diff --git a/http/views/layouts/main.html b/http/views/layouts/main.html new file mode 100644 index 0000000..774c41c --- /dev/null +++ b/http/views/layouts/main.html @@ -0,0 +1,68 @@ + + + + + + Teletype Softwares + + + + +
+

Teletype Games

+ {{block "content" .}}{{end}} +
+ + + diff --git a/http/views/play.html b/http/views/play.html new file mode 100644 index 0000000..af65ca2 --- /dev/null +++ b/http/views/play.html @@ -0,0 +1,10 @@ +{{define "content"}} +

Play {{.Name}}

+ +
+ +
+ +

Back to Softwares

+ +{{end}} \ No newline at end of file diff --git a/http/views/releases.html b/http/views/releases.html new file mode 100644 index 0000000..96d330b --- /dev/null +++ b/http/views/releases.html @@ -0,0 +1,31 @@ +{{define "content"}} +

Releases for {{.Software.Title}}

+ +
+ + + + + + + + + + {{range .Software.Releases}} + + + + + + {{end}} + +
VersionReleased AtDownloads
{{.Version}}{{.CreatedAt.Format "2006-01-02 15:04:05"}} + +
+
+ +

Back to Softwares

+{{end}} diff --git a/lib/http_utils/http_utils.go b/lib/http_utils/http_utils.go new file mode 100755 index 0000000..cdac680 --- /dev/null +++ b/lib/http_utils/http_utils.go @@ -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)) +} diff --git a/lib/http_utils/middleware.logger.go b/lib/http_utils/middleware.logger.go new file mode 100644 index 0000000..5aa7b61 --- /dev/null +++ b/lib/http_utils/middleware.logger.go @@ -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) + }) +} diff --git a/lib/mysql_utils/db_mock.go b/lib/mysql_utils/db_mock.go new file mode 100755 index 0000000..16b5ee8 --- /dev/null +++ b/lib/mysql_utils/db_mock.go @@ -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, + } +} diff --git a/lib/mysql_utils/mysql.go b/lib/mysql_utils/mysql.go new file mode 100755 index 0000000..bbacab9 --- /dev/null +++ b/lib/mysql_utils/mysql.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100755 index 0000000..58874ef --- /dev/null +++ b/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "teletype_softwares/domain" + "teletype_softwares/http" +) + +func main() { + domain := domain.NewDomain() + http.StartHttpServer(domain) +}