initial commit
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
tmp
|
||||
softwares
|
||||
teletype_softwares
|
||||
.env
|
||||
+21
@@ -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"]
|
||||
Executable
+12
@@ -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"
|
||||
@@ -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:
|
||||
Executable
+41
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func MigrateGoDatabase(db *gorm.DB) {
|
||||
db.AutoMigrate(
|
||||
Software{},
|
||||
Release{},
|
||||
)
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
go mod download && air -c .air.toml
|
||||
+18
@@ -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=
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
Executable
+38
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Executable
+48
@@ -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,
|
||||
}
|
||||
}
|
||||
Executable
+38
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user