@ -0,0 +1 @@ | |||
node_modules |
@ -0,0 +1,21 @@ | |||
MIT License | |||
Copyright (c) 2019 Levi Olson | |||
Permission is hereby granted, free of charge, to any person obtaining a copy | |||
of this software and associated documentation files (the "Software"), to deal | |||
in the Software without restriction, including without limitation the rights | |||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||
copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all | |||
copies or substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
SOFTWARE. |
@ -0,0 +1,34 @@ | |||
# TicTacToe API Interface | |||
A Golang Server that opens a unique socket for two users to play TicTacToe online. | |||
## Firewall | |||
sudo ufw status | |||
sudo ufw allow OpenSSH | |||
sudo ufw allow https | |||
sudo ufw enable | |||
## Install Go | |||
curl -C - https://dl.google.com/go/go1.11.4.linux-amd64.tar.gz -o go1.11.4.linux-amd64.tar.gz | |||
tar -C /usr/local -xzf go1.11.4.linux-amd64.tar.gz | |||
echo "export PATH=$PATH:/usr/local/go/bin" | |||
go version | |||
## Letsencrypt | |||
sudo add-apt-repository ppa:certbot/certbot | |||
sudo apt-get update | |||
sudo apt-get install python-certbot-nginx | |||
certbot certonly --standalone -d weather.l3vi.co | |||
## Systemd | |||
sudo cp tictactoe-api.service /lib/systemd/system/. | |||
ls -al /lib/systemd/system | |||
sudo chmod 755 /lib/systemd/system/tictactoe-api.service | |||
sudo systemctl enable tictactoe-api.service | |||
sudo systemctl start tictactoe-api.service | |||
sudo journalctl -f -u tictactoe-api | |||
# or | |||
sudo systemctl status tictactoe-api.service |
@ -0,0 +1,261 @@ | |||
package cache | |||
import ( | |||
"fmt" | |||
"runtime" | |||
"time" | |||
uuid "github.com/satori/go.uuid" | |||
) | |||
// Expired returns true if the item has expired | |||
func (item Item) Expired() bool { | |||
if item.Expiration == 0 { | |||
return false | |||
} | |||
return time.Now().UnixNano() > item.Expiration | |||
} | |||
// NewCache returns a simple in-memory cache | |||
func NewCache(defaultExp, cleanupInterval time.Duration) *Cache { | |||
items := make(map[uuid.UUID]Item) | |||
return newCacheWithJanitor(defaultExp, cleanupInterval, items) | |||
} | |||
func newCacheWithJanitor(defaultExp, cleanupInterval time.Duration, items map[uuid.UUID]Item) *Cache { | |||
cache := newCache(defaultExp, items) | |||
Cache := &Cache{cache} | |||
if cleanupInterval > 0 { | |||
runJanitor(cache, cleanupInterval) | |||
runtime.SetFinalizer(Cache, stopJanitor) | |||
} | |||
return Cache | |||
} | |||
func newCache(de time.Duration, m map[uuid.UUID]Item) *cache { | |||
if de == 0 { | |||
de = -1 | |||
} | |||
c := &cache{ | |||
defaultExpiration: de, | |||
items: m, | |||
} | |||
return c | |||
} | |||
// Add an item to the cache, replacing any existing item. If the duration is 0 | |||
// (DefaultExpiration), the cache's default expiration time is used. If it is -1 | |||
// (NoExpiration), the item never expires. | |||
func (c *cache) Set(k uuid.UUID, x interface{}, d time.Duration) { | |||
// "Inlining" of set | |||
var e int64 | |||
if d == DefaultExpiration { | |||
d = c.defaultExpiration | |||
} | |||
if d > 0 { | |||
e = time.Now().Add(d).UnixNano() | |||
} | |||
c.mu.Lock() | |||
c.items[k] = Item{ | |||
Object: x, | |||
Expiration: e, | |||
} | |||
// TODO: Calls to mu.Unlock are currently not deferred because defer | |||
// adds ~200 ns (as of go1.) | |||
c.mu.Unlock() | |||
} | |||
func (c *cache) set(k uuid.UUID, x interface{}, d time.Duration) { | |||
var e int64 | |||
if d == DefaultExpiration { | |||
d = c.defaultExpiration | |||
} | |||
if d > 0 { | |||
e = time.Now().Add(d).UnixNano() | |||
} | |||
c.items[k] = Item{ | |||
Object: x, | |||
Expiration: e, | |||
} | |||
} | |||
// Add an item to the cache, replacing any existing item, using the default | |||
// expiration. | |||
func (c *cache) SetDefault(k uuid.UUID, x interface{}) { | |||
c.Set(k, x, DefaultExpiration) | |||
} | |||
// Add an item to the cache only if an item doesn't already exist for the given | |||
// key, or if the existing item has expired. Returns an error otherwise. | |||
func (c *cache) Add(k uuid.UUID, x interface{}, d time.Duration) error { | |||
c.mu.Lock() | |||
_, found := c.get(k) | |||
if found { | |||
c.mu.Unlock() | |||
return fmt.Errorf("Item %s already exists", k) | |||
} | |||
c.set(k, x, d) | |||
c.mu.Unlock() | |||
return nil | |||
} | |||
// Set a new value for the cache key only if it already exists, and the existing | |||
// item hasn't expired. Returns an error otherwise. | |||
func (c *cache) Replace(k uuid.UUID, x interface{}, d time.Duration) error { | |||
c.mu.Lock() | |||
_, found := c.get(k) | |||
if !found { | |||
c.mu.Unlock() | |||
return fmt.Errorf("Item %s doesn't exist", k) | |||
} | |||
c.set(k, x, d) | |||
c.mu.Unlock() | |||
return nil | |||
} | |||
// Get an item from the cache. Returns the item or nil, and a bool indicating | |||
// whether the key was found. | |||
func (c *cache) Get(k uuid.UUID) (interface{}, bool) { | |||
c.mu.RLock() | |||
// "Inlining" of get and Expired | |||
item, found := c.items[k] | |||
if !found { | |||
c.mu.RUnlock() | |||
return nil, false | |||
} | |||
if item.Expiration > 0 { | |||
if time.Now().UnixNano() > item.Expiration { | |||
c.mu.RUnlock() | |||
return nil, false | |||
} | |||
} | |||
c.mu.RUnlock() | |||
return item.Object, true | |||
} | |||
// GetWithExpiration returns an item and its expiration time from the cache. | |||
// It returns the item or nil, the expiration time if one is set (if the item | |||
// never expires a zero value for time.Time is returned), and a bool indicating | |||
// whether the key was found. | |||
func (c *cache) GetWithExpiration(k uuid.UUID) (interface{}, time.Time, bool) { | |||
c.mu.RLock() | |||
// "Inlining" of get and Expired | |||
item, found := c.items[k] | |||
if !found { | |||
c.mu.RUnlock() | |||
return nil, time.Time{}, false | |||
} | |||
if item.Expiration > 0 { | |||
if time.Now().UnixNano() > item.Expiration { | |||
c.mu.RUnlock() | |||
return nil, time.Time{}, false | |||
} | |||
// Return the item and the expiration time | |||
c.mu.RUnlock() | |||
return item.Object, time.Unix(0, item.Expiration), true | |||
} | |||
// If expiration <= 0 (i.e. no expiration time set) then return the item | |||
// and a zeroed time.Time | |||
c.mu.RUnlock() | |||
return item.Object, time.Time{}, true | |||
} | |||
func (c *cache) get(k uuid.UUID) (interface{}, bool) { | |||
item, found := c.items[k] | |||
if !found { | |||
return nil, false | |||
} | |||
// "Inlining" of Expired | |||
if item.Expiration > 0 { | |||
if time.Now().UnixNano() > item.Expiration { | |||
return nil, false | |||
} | |||
} | |||
return item.Object, true | |||
} | |||
func (c *cache) delete(k uuid.UUID) (interface{}, bool) { | |||
if c.onEvicted != nil { | |||
if v, found := c.items[k]; found { | |||
delete(c.items, k) | |||
return v.Object, true | |||
} | |||
} | |||
delete(c.items, k) | |||
return nil, false | |||
} | |||
type keyAndValue struct { | |||
key uuid.UUID | |||
value interface{} | |||
} | |||
// Delete all expired items from the cache. | |||
func (c *cache) DeleteExpired() { | |||
var evictedItems []keyAndValue | |||
now := time.Now().UnixNano() | |||
c.mu.Lock() | |||
for k, v := range c.items { | |||
// "Inlining" of expired | |||
if v.Expiration > 0 && now > v.Expiration { | |||
ov, evicted := c.delete(k) | |||
if evicted { | |||
evictedItems = append(evictedItems, keyAndValue{k, ov}) | |||
} | |||
} | |||
} | |||
c.mu.Unlock() | |||
for _, v := range evictedItems { | |||
c.onEvicted(v.key, v.value) | |||
} | |||
} | |||
// Sets an (optional) function that is called with the key and value when an | |||
// item is evicted from the cache. (Including when it is deleted manually, but | |||
// not when it is overwritten.) Set to nil to disable. | |||
func (c *cache) OnEvicted(f func(uuid.UUID, interface{})) { | |||
c.mu.Lock() | |||
c.onEvicted = f | |||
c.mu.Unlock() | |||
} | |||
// Delete all items from the cache. | |||
func (c *cache) Flush() { | |||
c.mu.Lock() | |||
c.items = map[uuid.UUID]Item{} | |||
c.mu.Unlock() | |||
} | |||
type janitor struct { | |||
Interval time.Duration | |||
stop chan bool | |||
} | |||
func (j *janitor) Run(c *cache) { | |||
ticker := time.NewTicker(j.Interval) | |||
for { | |||
select { | |||
case <-ticker.C: | |||
c.DeleteExpired() | |||
case <-j.stop: | |||
ticker.Stop() | |||
return | |||
} | |||
} | |||
} | |||
func stopJanitor(c *Cache) { | |||
c.janitor.stop <- true | |||
} | |||
func runJanitor(c *cache, ci time.Duration) { | |||
j := &janitor{ | |||
Interval: ci, | |||
stop: make(chan bool), | |||
} | |||
c.janitor = j | |||
go j.Run(c) | |||
} |
@ -0,0 +1,36 @@ | |||
package cache | |||
import ( | |||
"sync" | |||
"time" | |||
uuid "github.com/satori/go.uuid" | |||
) | |||
// Item for caching | |||
type Item struct { | |||
Object interface{} | |||
Expiration int64 | |||
} | |||
// Cache is a simple in-memory cache for storing things | |||
type Cache struct { | |||
*cache | |||
} | |||
type cache struct { | |||
defaultExpiration time.Duration | |||
items map[uuid.UUID]Item | |||
mu sync.RWMutex | |||
onEvicted func(uuid.UUID, interface{}) | |||
janitor *janitor | |||
} | |||
const ( | |||
// NoExpiration For use with functions that take an expiration time. | |||
NoExpiration time.Duration = -1 | |||
// DefaultExpiration For use with functions that take an expiration time. | |||
// Equivalent to passing in the same expiration duration as was given to | |||
// New() or NewFrom() when the cache was created (e.g. 5 minutes.) | |||
DefaultExpiration time.Duration = time.Hour * 1 | |||
) |
@ -0,0 +1,137 @@ | |||
// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. | |||
// Use of this source code is governed by a BSD-style | |||
// license that can be found in the LICENSE file. | |||
package main | |||
import ( | |||
"bytes" | |||
"log" | |||
"net/http" | |||
"time" | |||
"github.com/gorilla/websocket" | |||
) | |||
const ( | |||
// Time allowed to write a message to the peer. | |||
writeWait = 10 * time.Second | |||
// Time allowed to read the next pong message from the peer. | |||
pongWait = 60 * time.Second | |||
// Send pings to peer with this period. Must be less than pongWait. | |||
pingPeriod = (pongWait * 9) / 10 | |||
// Maximum message size allowed from peer. | |||
maxMessageSize = 512 | |||
) | |||
var ( | |||
newline = []byte{'\n'} | |||
space = []byte{' '} | |||
) | |||
var upgrader = websocket.Upgrader{ | |||
ReadBufferSize: 1024, | |||
WriteBufferSize: 1024, | |||
} | |||
// Client is a middleman between the websocket connection and the hub. | |||
type Client struct { | |||
hub *Hub | |||
// The websocket connection. | |||
conn *websocket.Conn | |||
// Buffered channel of outbound messages. | |||
send chan []byte | |||
} | |||
// readPump pumps messages from the websocket connection to the hub. | |||
// | |||
// The application runs readPump in a per-connection goroutine. The application | |||
// ensures that there is at most one reader on a connection by executing all | |||
// reads from this goroutine. | |||
func (c *Client) readPump() { | |||
defer func() { | |||
c.hub.unregister <- c | |||
c.conn.Close() | |||
}() | |||
c.conn.SetReadLimit(maxMessageSize) | |||
c.conn.SetReadDeadline(time.Now().Add(pongWait)) | |||
c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil }) | |||
for { | |||
_, message, err := c.conn.ReadMessage() | |||
if err != nil { | |||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { | |||
log.Printf("error: %v", err) | |||
} | |||
break | |||
} | |||
message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1)) | |||
c.hub.broadcast <- message | |||
} | |||
} | |||
// writePump pumps messages from the hub to the websocket connection. | |||
// | |||
// A goroutine running writePump is started for each connection. The | |||
// application ensures that there is at most one writer to a connection by | |||
// executing all writes from this goroutine. | |||
func (c *Client) writePump() { | |||
ticker := time.NewTicker(pingPeriod) | |||
defer func() { | |||
ticker.Stop() | |||
c.conn.Close() | |||
}() | |||
for { | |||
select { | |||
case message, ok := <-c.send: | |||
c.conn.SetWriteDeadline(time.Now().Add(writeWait)) | |||
if !ok { | |||
// The hub closed the channel. | |||
c.conn.WriteMessage(websocket.CloseMessage, []byte{}) | |||
return | |||
} | |||
w, err := c.conn.NextWriter(websocket.TextMessage) | |||
if err != nil { | |||
return | |||
} | |||
w.Write(message) | |||
// Add queued chat messages to the current websocket message. | |||
n := len(c.send) | |||
for i := 0; i < n; i++ { | |||
w.Write(newline) | |||
w.Write(<-c.send) | |||
} | |||
if err := w.Close(); err != nil { | |||
return | |||
} | |||
case <-ticker.C: | |||
c.conn.SetWriteDeadline(time.Now().Add(writeWait)) | |||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { | |||
return | |||
} | |||
} | |||
} | |||
} | |||
// serveWs handles websocket requests from the peer. | |||
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) { | |||
conn, err := upgrader.Upgrade(w, r, nil) | |||
if err != nil { | |||
log.Println(err) | |||
return | |||
} | |||
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)} | |||
client.hub.register <- client | |||
// Allow collection of memory referenced by the caller by doing all work in | |||
// new goroutines. | |||
go client.writePump() | |||
go client.readPump() | |||
} |
@ -0,0 +1,56 @@ | |||
<!DOCTYPE html> | |||
<html> | |||
<head> | |||
<meta charset="utf-8" /> | |||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> | |||
<title>TicTacToe :: {{ .title }}</title> | |||
<meta name="viewport" content="width=device-width, initial-scale=1"> | |||
<link rel="stylesheet" type="text/css" media="screen" href="/static/main.css" /> | |||
</head> | |||
<body> | |||
<div class=wrapper> | |||
<h1>TicTacToe</h1> | |||
<h2 id=player class=hide></h2> | |||
<form id=startGameForm> | |||
<label for=name id=nameLabel>To start a new game, enter your name:</label> | |||
<input type=text name=name id=name placeholder="Enter your name/nickname" size=64> | |||
<button type=submit id=start>Start a new game</button> | |||
</form> | |||
<div id=instructions class=hide> | |||
<p>Share this link with a friend to begin:</p> | |||
<div class=link> | |||
<div class=tooltip> | |||
<span class=tooltiptext id=tooltip>Copy</span> | |||
<input type=text id=shareLink onclick="ui.Copy(this)" onmouseout="ui.TooltipBlur()" readonly=readonly> | |||
</div> | |||
</div> | |||
</div> | |||
<div id=status class=hide></div> | |||
<div id=waiting class=hide>Waiting for opponent</div> | |||
<div id=turn class=hide>Your Turn</div> | |||
<div id=winner class=hide>You WIN!!!</div> | |||
</div> | |||
<div id=gameTable class=hide> | |||
<table> | |||
<tr> | |||
<td id=pos_0 class=tile>T</td> | |||
<td id=pos_1 class=tile>I</td> | |||
<td id=pos_2 class=tile>C</td> | |||
</tr> | |||
<tr> | |||
<td id=pos_3 class=tile>T</td> | |||
<td id=pos_4 class=tile>A</td> | |||
<td id=pos_5 class=tile>C</td> | |||
</tr> | |||
<tr> | |||
<td id=pos_6 class=tile>T</td> | |||
<td id=pos_7 class=tile>O</td> | |||
<td id=pos_8 class=tile>E</td> | |||
</tr> | |||
</table> | |||
</div> | |||
<script src="/static/bundle.js"></script> | |||
</body> | |||
</html> |
@ -0,0 +1,44 @@ | |||
<!DOCTYPE html> | |||
<html> | |||
<head> | |||
<meta charset="utf-8" /> | |||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> | |||
<title>TicTacToe :: {{ .title }}</title> | |||
<meta name="viewport" content="width=device-width, initial-scale=1"> | |||
<link rel="stylesheet" type="text/css" media="screen" href="/static/main.css" /> | |||
</head> | |||
<body> | |||
<div class=wrapper> | |||
<h1>TicTacToe</h1> | |||
<h2 id=player class=hide></h2> | |||
<form id=joinGameForm> | |||
<label for=name id=nameLabel>To join, enter your name:</label> | |||
<input type=text name=name id=name placeholder="Enter your name/nickname" size=64> | |||
<button type=submit id=join>Join Game</button> | |||
</form> | |||
<div id=status class=hide></div> | |||
<div id=turn class=hide></div> | |||
<div id=winner class=hide>You WIN!!!</div> | |||
</div> | |||
<div id=gameTable class=hide> | |||
<table> | |||
<tr> | |||
<td id=pos_0 class=tile>T</td> | |||
<td id=pos_1 class=tile>I</td> | |||
<td id=pos_2 class=tile>C</td> | |||
</tr> | |||
<tr> | |||
<td id=pos_3 class=tile>T</td> | |||
<td id=pos_4 class=tile>A</td> | |||
<td id=pos_5 class=tile>C</td> | |||
</tr> | |||
<tr> | |||
<td id=pos_6 class=tile>T</td> | |||
<td id=pos_7 class=tile>O</td> | |||
<td id=pos_8 class=tile>E</td> | |||
</tr> | |||
</table> | |||
</div> | |||
<script src="/static/bundle.js"></script> | |||
</body> | |||
</html> |
@ -0,0 +1,78 @@ | |||
@import url(https://fonts.googleapis.com/css?family=Lato); | |||
input#shareLink { | |||
font-family: monospace; | |||
cursor: pointer; | |||
width: 400px; } | |||
span#copySuccess { | |||
display: none; | |||
color: green; | |||
font-weight: bold; } | |||
span#copyError { | |||
display: none; | |||
color: red; | |||
font-weight: bold; } | |||
.link { | |||
color: blue; } | |||
#status { | |||
font: normal normal normal 16px/20px 'Lato', sans-serif; | |||
color: red; } | |||
#turn, #waiting { | |||
font: normal normal normal 16px/20px 'Lato', sans-serif; } | |||
#gameTable tr td { | |||
border-bottom: 1px solid #333; | |||
border-right: 1px solid #333; | |||
height: 160px; | |||
width: 160px; | |||
text-align: center; } | |||
#gameTable tr td:last-child { | |||
border-right: none; } | |||
#gameTable tr td.dim { | |||
opacity: 0.3; } | |||
#gameTable tr td.dim.highlight { | |||
opacity: 1; } | |||
#gameTable tr td.highlight { | |||
opacity: 1; } | |||
#gameTable tr:last-child td { | |||
border-bottom: none; } | |||
.hide { | |||
display: none; } | |||
.tooltip { | |||
position: relative; | |||
display: inline-block; } | |||
.tooltip .tooltiptext { | |||
visibility: hidden; | |||
width: 140px; | |||
background-color: #555; | |||
color: #fff; | |||
text-align: center; | |||
border-radius: 6px; | |||
padding: 5px; | |||
position: absolute; | |||
z-index: 1; | |||
bottom: 150%; | |||
left: 50%; | |||
margin-left: -75px; | |||
opacity: 0; | |||
transition: opacity 0.3s; } | |||
.tooltip .tooltiptext::after { | |||
content: ""; | |||
position: absolute; | |||
top: 100%; | |||
left: 50%; | |||
margin-left: -5px; | |||
border-width: 5px; | |||
border-style: solid; | |||
border-color: #555 transparent transparent transparent; } | |||
.tooltip:hover .tooltiptext { | |||
visibility: visible; | |||
opacity: 1; } | |||
@ -0,0 +1,53 @@ | |||
// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. | |||
// Use of this source code is governed by a BSD-style | |||
// license that can be found in the LICENSE file. | |||
package main | |||
// Hub maintains the set of active clients and broadcasts messages to the | |||
// clients. | |||
type Hub struct { | |||
// Registered clients. | |||
clients map[*Client]bool | |||
// Inbound messages from the clients. | |||
broadcast chan []byte | |||
// Register requests from the clients. | |||
register chan *Client | |||
// Unregister requests from clients. | |||
unregister chan *Client | |||
} | |||
func newHub() *Hub { | |||
return &Hub{ | |||
broadcast: make(chan []byte), | |||
register: make(chan *Client), | |||
unregister: make(chan *Client), | |||
clients: make(map[*Client]bool), | |||
} | |||
} | |||
func (h *Hub) run() { | |||
for { | |||
select { | |||
case client := <-h.register: | |||
h.clients[client] = true | |||
case client := <-h.unregister: | |||
if _, ok := h.clients[client]; ok { | |||
delete(h.clients, client) | |||
close(client.send) | |||
} | |||
case message := <-h.broadcast: | |||
for client := range h.clients { | |||
select { | |||
case client.send <- message: | |||
default: | |||
close(client.send) | |||
delete(h.clients, client) | |||
} | |||
} | |||
} | |||
} | |||
} |
@ -0,0 +1,128 @@ | |||
package main | |||
import ( | |||
"log" | |||
"net/http" | |||
"os" | |||
"time" | |||
cache "tictactoe-api/cache" | |||
"github.com/gin-contrib/multitemplate" | |||
"github.com/gin-gonic/autotls" | |||
"github.com/gin-gonic/gin" | |||
uuid "github.com/satori/go.uuid" | |||
) | |||
func setupRender() multitemplate.Renderer { | |||
r := multitemplate.NewRenderer() | |||
r.AddFromFiles("index", "dist/index.html") | |||
r.AddFromFiles("join", "dist/join.html") | |||
return r | |||
} | |||
func main() { | |||
hub := newHub() | |||
go hub.run() | |||
cache := cache.NewCache(time.Minute*10, time.Minute) | |||
router := gin.Default() | |||
router.HTMLRender = setupRender() | |||
router.Static("/static", "dist") | |||
router.GET("/favicon.ico", func(c *gin.Context) { | |||
c.File("dist/favicon.ico") | |||
}) | |||
router.GET("/ping", func(c *gin.Context) { | |||
c.String(http.StatusOK, "pong") | |||
}) | |||
router.Any("/ws", func(c *gin.Context) { | |||
serveWs(hub, c.Writer, c.Request) | |||
}) | |||
// | |||
// | |||
// | |||
router.GET("/join/:gameUUID", func(c *gin.Context) { | |||
c.HTML(http.StatusOK, "join", gin.H{ | |||
"title": "Play Game", | |||
}) | |||
}) | |||
router.GET("/game", func(c *gin.Context) { | |||
c.HTML(http.StatusOK, "index", gin.H{ | |||
"title": "Start New Game", | |||
}) | |||
}) | |||
router.POST("/game", func(c *gin.Context) { | |||
var game Game | |||
err := c.BindJSON(&game) | |||
if err != nil { | |||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | |||
return | |||
} | |||
game.Turn = game.Players[0].UUID | |||
cache.Set(game.UUID, game, 0) | |||
c.JSON(http.StatusOK, game) | |||
}) | |||
router.GET("/game/:gameUUID", func(c *gin.Context) { | |||
gameUUID, err := uuid.FromString(c.Params.ByName("gameUUID")) | |||
if err != nil { | |||
log.Printf("UUID Parse Failed: %s\n", err) | |||
} | |||
game, found := cache.Get(gameUUID) | |||
if !found { | |||
c.Status(http.StatusNotFound) | |||
return | |||
} | |||
c.JSON(http.StatusOK, game) | |||
}) | |||
router.POST("/game/:gameUUID/player/:playerUUID", func(c *gin.Context) { | |||
gameUUID, err := uuid.FromString(c.Params.ByName("gameUUID")) | |||
if err != nil { | |||
log.Printf("UUID Game Parse Failed: %s\n", err) | |||
} | |||
playerUUID, err := uuid.FromString(c.Params.ByName("playerUUID")) | |||
if err != nil { | |||
log.Printf("UUID Player Parse Failed: %s\n", err) | |||
} | |||
game, found := cache.Get(gameUUID) | |||
if !found { | |||
c.Status(http.StatusNotFound) | |||
return | |||
} | |||
_game := game.(Game) | |||
var player Player | |||
err = c.BindJSON(&player) | |||
if err != nil { | |||
log.Printf("Error: %s\n", err) | |||
} | |||
if len(_game.Players) > 1 { | |||
c.JSON(http.StatusBadRequest, gin.H{"error": "TicTacToe can only be played with two players."}) | |||
return | |||
} | |||
_game.Players = append(_game.Players, &Player{ | |||
UUID: &playerUUID, | |||
Name: player.Name, | |||
}) | |||
cache.Set(gameUUID, _game, 0) | |||
c.JSON(http.StatusOK, _game) | |||
}) | |||
// | |||
// | |||
// | |||
if os.Getenv("GIN_MODE") == "release" { | |||
log.Fatal(autotls.Run(router, "tictactoe.l3vi.co")) | |||
} else { | |||
log.Fatal(router.Run(":80")) | |||
} | |||
} |
@ -0,0 +1,35 @@ | |||
{ | |||
"name": "tictactoe-api", | |||
"version": "1.0.0", | |||
"description": "A Golang Server that opens a unique socket for two users to play TicTacToe online.", | |||
"private": true, | |||
"scripts": { | |||
"build": "webpack --watch" | |||
}, | |||
"babel": { | |||
"presets": [ | |||
[ | |||
"@babel/preset-env", | |||
{ | |||
"targets": { | |||
"esmodules": true | |||
} | |||
} | |||
] | |||
] | |||
}, | |||
"author": "Levi Olson", | |||
"license": "MIT", | |||
"devDependencies": { | |||
"@babel/cli": "^7.2.3", | |||
"@babel/core": "^7.2.2", | |||
"@babel/preset-env": "^7.2.3", | |||
"css-loader": "^2.1.0", | |||
"mini-css-extract-plugin": "^0.5.0", | |||
"node-sass": "^4.11.0", | |||
"sass-loader": "^7.1.0", | |||
"style-loader": "^0.23.1", | |||
"webpack": "^4.28.1", | |||
"webpack-cli": "^3.2.1" | |||
} | |||
} |
@ -0,0 +1,116 @@ | |||
'use strict' | |||
import * as HTTP from './services.js' | |||
import { timingSafeEqual } from 'crypto'; | |||
class BaseUtils { | |||
constructor() { | |||
this.id = this.getUUID() | |||
} | |||
getUUID () { | |||
function s4 () { | |||
return Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) | |||
} | |||
return (s4() + s4() + '-' + s4() + '-4' + s4().slice(1) + '-8' + s4().slice(1) + '-' + s4() + s4() + s4()).toUpperCase() | |||
} | |||
setId (id) { | |||
this.id = id | |||
return this | |||
} | |||
setCookie (cname, cvalue) { | |||
document.cookie = cname + "=" + cvalue + ";path=/" | |||
return this | |||
} | |||
getCookie (cname) { | |||
let name = cname + "=" | |||
let decodedCookie = decodeURIComponent(document.cookie) | |||
let ca = decodedCookie.split(';') | |||
for (let i = 0; i < ca.length; i++) { | |||
let c = ca[i] | |||
while (c.charAt(0) == ' ') { | |||
c = c.substring(1) | |||
} | |||
if (c.indexOf(name) == 0) { | |||
return c.substring(name.length, c.length) | |||
} | |||
} | |||
return "" | |||
} | |||
} | |||
export class Player extends BaseUtils { | |||
constructor(id, name) { | |||
super() | |||
if (id) | |||
this.id = id | |||
if (name) | |||
this.name = name | |||
return this | |||
} | |||
setId (id) { | |||
this.id = id | |||
return this | |||
} | |||
getId () { | |||
return this.id | |||
} | |||
setName (name) { | |||
this.name = name | |||
return this | |||
} | |||
getName () { | |||
return this.name | |||
} | |||
loadFromCookies() { | |||
this.setId(this.getCookie("player_id") || this.getId()) | |||
this.setName(this.getCookie("player_name")) | |||
return this | |||
} | |||
setCookies() { | |||
this.setCookie("player_id", this.id) | |||
this.setCookie("player_name", this.name) | |||
return this | |||
} | |||
} | |||
export class Game extends BaseUtils { | |||
constructor(player) { | |||
super() | |||
this.setId(this.getGameIdFromUrl() || this.id) | |||
this.players = [player] | |||
this.turn = player.uuid | |||
this.winner = undefined | |||
this.draw = undefined | |||
this.matrix = [ | |||
null, null, null, | |||
null, null, null, | |||
null, null, null | |||
] | |||
this.blocked = false | |||
return this | |||
} | |||
getId () { | |||
return this.id | |||
} | |||
getGameIdFromUrl() { | |||
console.debug("getGameIdFromUrl") | |||
let path = document.location.pathname.split("/") | |||
console.debug(path) | |||
if (path && path.length > 1) { | |||
let uuid = path[path.length - 1] || "" | |||
console.debug(uuid) | |||
let matches = uuid.match("^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$") | |||
return matches && matches.length ? path[path.length - 1] : undefined | |||
} | |||
return undefined | |||
} | |||
getOpponentsName(myId) { | |||
let opponent = this.players.filter(x => x.id !== myId)[0] | |||
console.debug("getOpponentsName()", opponent.name) | |||
return opponent.name | |||
} | |||
setOpponentsTurn(myId) { | |||
let opponent = this.players.filter(x => x.id !== myId)[0] | |||
console.debug("setOpponentsTurn()", opponent.id) | |||
this.turn = opponent.id | |||
return this | |||
} | |||
} |
@ -0,0 +1,375 @@ | |||
'use strict' | |||
import {Player, Game} from "./classes.js" | |||
import * as HTTP from "./services.js" | |||
import style from './style.scss' | |||
let conn | |||
let gameOver = false | |||
let player = new Player() | |||
player.loadFromCookies() | |||
player.setCookies() | |||
let game = new Game(player) | |||
console.debug(player) | |||
console.debug(game) | |||
/** | |||
* Initialize DOM variables | |||
*/ | |||
let name = document.getElementById('name') | |||
let nameLabel = document.getElementById('nameLabel') | |||
let playerH1 = document.getElementById('player') | |||
let status = document.getElementById('status') | |||
let waiting = document.getElementById('waiting') | |||
let turn = document.getElementById('turn') | |||
let winner = document.getElementById('winner') | |||
let draw = document.getElementById('draw') | |||
let gameTable = document.getElementById('gameTable') | |||
let instructions = document.getElementById('instructions') | |||
let input = document.getElementById('shareLink') | |||
let tooltip = document.getElementById("tooltip") | |||
let startGameForm = document.getElementById('startGameForm') | |||
let joinGameForm = document.getElementById('joinGameForm') | |||
let opponentTileColor = "#992222" | |||
let tileColor = "#229922" | |||
/** | |||
* Setup | |||
*/ | |||
function _setup() { | |||
if (player.getName()) { | |||
name.value = player.getName() | |||
name.classList.add("hide") | |||
nameLabel.classList.add("hide") | |||
playerH1.innerText = "Hi " + player.getName() | |||
playerH1.classList.remove("hide") | |||
} | |||
} | |||
_setup() | |||
export function Copy (context) { | |||
console.debug(context.innerText) | |||
context.select() | |||
document.execCommand("Copy") | |||
tooltip.innerHTML = "Copied!" | |||
} | |||
export function TooltipBlur () { | |||
tooltip.innerHTML = "Copy" | |||
} | |||
if (window.WebSocket) { | |||
conn = new WebSocket("ws://" + document.location.host + "/ws") | |||
conn.onclose = function (evt) { | |||
console.debug("Websocket Closed") | |||
} | |||
conn.onmessage = function (evt) { | |||
let messages = evt.data.split('\n') | |||
for (var i = 0; i < messages.length; i++) { | |||
let data = messages[i] | |||
try { | |||
data = JSON.parse(data) | |||
} catch (e) { | |||
console.error("Error parsing", data) | |||
} | |||
console.debug("Message", data) | |||
if (data.sender === player.getId()) { | |||
console.debug("My own message received and ignored") | |||
return | |||
} | |||
switch(data.event) { | |||
case "joining": | |||
console.debug("Event: joining") | |||
instructions.classList.add("hide") | |||
game.players.push(data.player) | |||
console.debug(game) | |||
_yourTurn() | |||
_showGame() | |||
break | |||
case "move": | |||
console.debug("Event: move") | |||
game.matrix[data.index] = data.player | |||
_updateTiles(data.index) | |||
_yourTurn() | |||
console.debug(game.matrix) | |||
break | |||
case "winner": | |||
console.debug("Event: winner") | |||
game.matrix[data.index] = data.player | |||
_updateTiles(data.index) | |||
gameOver = true | |||
status.classList.add("hide") | |||
turn.classList.add("hide") | |||
winner.innerText = "You lose... " + game.getOpponentsName(player.getId()) + " Wins!" | |||
winner.classList.remove("hide") | |||
_highlightBoard(data.positions) | |||
_dimBoard() | |||
if (startGameForm) | |||
startGameForm.classList.remove("hide") | |||
break | |||
case "draw": | |||
console.debug("Event: draw") | |||
game.matrix[data.index] = data.player | |||
_updateTiles(data.index) | |||
gameOver = true | |||
status.classList.add("hide") | |||
turn.classList.add("hide") | |||
winner.innerText = "Its a draw!" | |||
winner.classList.remove("hide") | |||
_dimBoard() | |||
if (startGameForm) | |||
startGameForm.classList.remove("hide") | |||
break | |||
} | |||
} | |||
} | |||
} else { | |||
console.error("TicTacToe can only be played in a browser that supports a WebSocket connection.") | |||
} | |||
/** | |||
* BEGIN Document Listeners | |||
*/ | |||
if (startGameForm) { | |||
startGameForm.addEventListener('submit', event => { | |||
event.preventDefault() | |||
if (!_validateForm()) { | |||
return false | |||
} | |||
_createGame().then(resp => { | |||
startGameForm.classList.add("hide") | |||
console.debug("_createGame() success", resp) | |||
game.turn = resp.turn | |||
game.matrix = resp.matrix | |||
console.debug("Game:", game) | |||
input.value = "http://localhost/join/" + game.getId() | |||
instructions.classList.remove("hide") | |||
_waiting() | |||
// _yourTurn() | |||
// _showGame() | |||
}).catch(err => { | |||
console.debug("_createGame() error", err) | |||
status.innerText = err.message.error | |||
status.classList.remove("hide") | |||
}) | |||
}) | |||
} | |||
if (joinGameForm) { | |||
joinGameForm.addEventListener('submit', event => { | |||
event.preventDefault() | |||
if (!_validateForm()) { | |||
return false | |||
} | |||
_addPlayer().then(resp => { | |||
joinGameForm.classList.add("hide") | |||
console.debug("_addPlayer() success", resp) | |||
conn.send(JSON.stringify({ | |||
sender: player.getId(), | |||
game_id: game.getId(), | |||
event: "joining", | |||
player: { | |||
id: player.getId(), | |||
name: player.getName() | |||
} | |||
})) | |||
game.players.push(new Player(resp.players[0].id, resp.players[0].name)) | |||
_theirTurn() | |||
_showGame() | |||
}).catch(err => { | |||
console.debug("_addPlayer() error", err) | |||
status.innerText = err.message.error | |||
status.classList.remove("hide") | |||
}) | |||
}) | |||
} | |||
if (gameTable) { | |||
document.querySelectorAll('.tile').forEach(element => { | |||
element.addEventListener('click', event => { | |||
if (gameOver) { | |||
console.debug("Game over") | |||
event.preventDefault() | |||
return | |||
} | |||
let pos = event.target.id.split('_')[1] | |||
// validate its my turn | |||
if (game.turn !== player.getId()) { | |||
console.debug("Not my turn") | |||
event.preventDefault() | |||
return | |||
} | |||
// validate position available | |||
if (game.matrix[pos]) { | |||
console.debug("Tile not free") | |||
event.preventDefault() | |||
return | |||
} | |||
// set move | |||
game.matrix[pos] = player.getId() | |||
// show tile selected | |||
event.target.style.backgroundColor = tileColor | |||
// calculate if 3 tiles in a row | |||
let three = _threeInARow() | |||
if (three) { | |||
console.debug("You win!") | |||
_highlightBoard(three) | |||
_dimBoard() | |||
gameOver = true | |||
winner.innerText = "You Win!!!" | |||
winner.classList.remove("hide") | |||
status.classList.add("hide") | |||
turn.classList.add("hide") | |||
conn.send(JSON.stringify({ | |||
sender: player.getId(), | |||
game_id: game.getId(), | |||
event: "winner", | |||
index: pos, | |||
player: player.getId(), | |||
winner: player.getId(), | |||
positions: three | |||
})) | |||
if (startGameForm) | |||
startGameForm.classList.remove("hide") | |||
event.preventDefault() | |||
return | |||
} | |||
if (_draw()) { | |||
console.debug("Draw!") | |||
_dimBoard() | |||
gameOver = true | |||
winner.innerText = "Its a draw!" | |||
winner.classList.remove("hide") | |||
status.classList.add("hide") | |||
turn.classList.add("hide") | |||
conn.send(JSON.stringify({ | |||
sender: player.getId(), | |||
game_id: game.getId(), | |||
event: "draw", | |||
index: pos, | |||
player: player.getId(), | |||
winner: player.getId() | |||
})) | |||
if (startGameForm) | |||
startGameForm.classList.remove("hide") | |||
event.preventDefault() | |||
return | |||
} | |||
// send move via socket | |||
conn.send(JSON.stringify({ | |||
sender: player.getId(), | |||
game_id: game.getId(), | |||
event: "move", | |||
index: pos, | |||
player: player.getId() | |||
})) | |||
_theirTurn() | |||
}) | |||
}) | |||
} | |||
/** | |||
* END Document Listeners | |||
*/ | |||
function _validateForm() { | |||
if (!name.value) { | |||
status.innerText = "Name is required." | |||
status.classList.remove("hide") | |||
return false | |||
} | |||
player.setName(name.value) | |||
player.setCookies() | |||
status.classList.add("hide") | |||
_greeting() | |||
return true | |||
} | |||
function _getUrl() { | |||
return document.location.protocol + '//' + document.location.host | |||
} | |||
function _greeting() { | |||
if (player.getName()) { | |||
playerH1.innerText = "Hi " + player.getName() | |||
playerH1.classList.remove("hide") | |||
} | |||
} | |||
function _createGame() { | |||
let url = _getUrl() | |||
return HTTP.POST(`${url}/game`, JSON.stringify(game)) | |||
} | |||
function _addPlayer() { | |||
let url = _getUrl() | |||
let gid = game.getId() | |||
let pid = player.getId() | |||
return HTTP.POST(`${url}/game/${gid}/player/${pid}`, JSON.stringify({ name: document.getElementById('name').value })) | |||
} | |||
function _showGame() { | |||
gameTable.classList.remove("hide") | |||
} | |||
function _waiting() { | |||
waiting.innerText = "Waiting for opponent" | |||
waiting.classList.remove("hide") | |||
} | |||
function _yourTurn () { | |||
if (waiting) | |||
waiting.classList.add("hide") | |||
turn.innerText = "Your Turn" | |||
turn.classList.remove("hide") | |||
game.turn = player.getId() | |||
} | |||
function _theirTurn () { | |||
game.setOpponentsTurn(player.getId()) | |||
turn.innerText = _possessivizer(game.getOpponentsName(player.getId())) + " Turn" | |||
turn.classList.remove("hide") | |||
} | |||
function _possessivizer(name) { | |||
if (name.charAt(name.length - 1) === "s") { | |||
return name + "'" | |||
} else { | |||
return name + "'s" | |||
} | |||
} | |||
function _updateTiles(index) { | |||
document.getElementById('pos_' + index).style.backgroundColor = opponentTileColor | |||
} | |||
function _threeInARow() { | |||
for (let i = 0; i <= 8; i) { | |||
if (game.matrix[i] && (game.matrix[i] === game.matrix[i + 1] && game.matrix[i] === game.matrix[i + 2]) ) { | |||
return [i, i + 1, i + 2] | |||
} | |||
i = i + 3 | |||
} | |||
for (let i = 0; i <= 2; i++) { | |||
if (game.matrix[i] && (game.matrix[i] === game.matrix[i + 3] && game.matrix[i] === game.matrix[i + 6]) ) { | |||
return [i, i + 3, i + 6] | |||
} | |||
} | |||
if (game.matrix[0] && (game.matrix[0] === game.matrix[4] && game.matrix[0] === game.matrix[8])) { | |||
return [0, 4, 8] | |||
} | |||
if (game.matrix[2] && (game.matrix[2] === game.matrix[4] && game.matrix[2] === game.matrix[6])) { | |||
return [2, 4, 6] | |||
} | |||
return false | |||
} | |||
function _draw() { | |||
for (let i = 0; i < game.matrix.length; i++) { | |||
if (!game.matrix[i]) { | |||
return false | |||
} | |||
} | |||
return true | |||
} | |||
function _dimBoard() { | |||
document.querySelectorAll('td').forEach(el => el.classList.add("dim")) | |||
} | |||
function _highlightBoard (positions) { | |||
for (let pos of positions) { | |||
document.querySelector('#pos_' + pos).classList.add("highlight") | |||
} | |||
} |
@ -0,0 +1,41 @@ | |||
'use strict' | |||
// GET HTTP call | |||
export function GET (theUrl, callback) { | |||
var req = new XMLHttpRequest() | |||
req.onreadystatechange = function () { | |||
if (req.readyState == 4 && req.status == 200) | |||
callback(req.responseText) | |||
} | |||
req.open("GET", theUrl, true) | |||
req.send(null) | |||
} | |||
// POST HTTP call | |||
export function POST (theUrl, data) { | |||
return new Promise((resolve, reject) => { | |||
let req = new XMLHttpRequest() | |||
req.onreadystatechange = function () { | |||
if (req.readyState == 4 && req.status == 200) { | |||
resolve(_parseOrNot(req.responseText)) | |||
} else if (req.readyState == 4) { | |||
reject({ status: req.status, message: _parseOrNot(req.responseText) }) | |||
} | |||
} | |||
req.open("POST", theUrl, true) | |||
if (data) { | |||
req.setRequestHeader("Content-Type", "application/json") | |||
} | |||
req.send(data) | |||
}) | |||
} | |||
function _parseOrNot(str) { | |||
let data = str | |||
try { | |||
data = JSON.parse(str) | |||
} catch (e) { | |||
console.debug("Error Parsing Response JSON:", str) | |||
} | |||
return data | |||
} |
@ -0,0 +1,96 @@ | |||
@import url('https://fonts.googleapis.com/css?family=Lato'); | |||
input#shareLink { | |||
font-family: monospace; | |||
cursor: pointer; | |||
width: 400px; | |||
} | |||
span { | |||
&#copySuccess { | |||
display: none; | |||
color: green; | |||
font-weight: bold; | |||
} | |||
&#copyError { | |||
display: none; | |||
color: red; | |||
font-weight: bold; | |||
} | |||
} | |||
.link { | |||
color: blue; | |||
} | |||
#status { | |||
font: normal normal normal 16px/20px 'Lato', sans-serif; | |||
color: red; | |||
} | |||
#turn, #waiting { | |||
font: normal normal normal 16px/20px 'Lato', sans-serif; | |||
} | |||
#gameTable { | |||
tr { | |||
td { | |||
border-bottom: 1px solid #333; | |||
border-right: 1px solid #333; | |||
height: 160px; | |||
width: 160px; | |||
text-align: center; | |||
&:last-child { | |||
border-right: none; | |||
} | |||
&.dim { | |||
opacity: 0.3; | |||
&.highlight { | |||
opacity: 1; | |||
} | |||
} | |||
&.highlight { | |||
opacity: 1; | |||
} | |||
} | |||
&:last-child { | |||
td { | |||
border-bottom: none; | |||
} | |||
} | |||
} | |||
} | |||
.hide { | |||
display: none; | |||
} | |||
.tooltip { | |||
position: relative; | |||
display: inline-block; | |||
.tooltiptext { | |||
visibility: hidden; | |||
width: 140px; | |||
background-color: #555; | |||
color: #fff; | |||
text-align: center; | |||
border-radius: 6px; | |||
padding: 5px; | |||
position: absolute; | |||
z-index: 1; | |||
bottom: 150%; | |||
left: 50%; | |||
margin-left: -75px; | |||
opacity: 0; | |||
transition: opacity 0.3s; | |||
&::after { | |||
content: ""; | |||
position: absolute; | |||
top: 100%; | |||
left: 50%; | |||
margin-left: -5px; | |||
border-width: 5px; | |||
border-style: solid; | |||
border-color: #555 transparent transparent transparent; | |||
} | |||
} | |||
&:hover .tooltiptext { | |||
visibility: visible; | |||
opacity: 1; | |||
} | |||
} |
@ -0,0 +1,21 @@ | |||
package main | |||
import ( | |||
uuid "github.com/satori/go.uuid" | |||
) | |||
// Game object for binding with JSON POST body | |||
type Game struct { | |||
UUID uuid.UUID `json:"id" binding:"required"` | |||
Players []*Player `json:"players"` | |||
Turn *uuid.UUID `json:"turn"` | |||
Draw *bool `json:"draw,omitempty"` | |||
Winner *uuid.UUID `json:"winner,omitempty"` | |||
Matrix [9]*uuid.UUID `json:"matrix" binding:"min=9,max=9"` | |||
} | |||
// Player object for binding with JSON POST body | |||
type Player struct { | |||
UUID *uuid.UUID `json:"id"` | |||
Name string `json:"name,omitempty"` | |||
} |
@ -0,0 +1,29 @@ | |||
[Unit] | |||
Description=TicTacToe API service | |||
ConditionPathExists=/root/go/src/tictactoe-api | |||
After=network.target | |||
[Service] | |||
Type=simple | |||
User=root | |||
Group=root | |||
LimitNOFILE=1024 | |||
Environment="GIN_MODE=release" | |||
Restart=on-failure | |||
RestartSec=10 | |||
WorkingDirectory=/root/go/src/tictactoe-api | |||
ExecStart=/bin/bash -c "/root/go/src/tictactoe-api/tictactoe-api" | |||
# make sure log directory exists and owned by syslog | |||
PermissionsStartOnly=true | |||
ExecStartPre=/bin/mkdir -p /var/log/tictactoeapi | |||
ExecStartPre=/bin/chown syslog:adm /var/log/tictactoeapi | |||
ExecStartPre=/bin/chmod 755 /var/log/tictactoeapi | |||
StandardOutput=syslog | |||
StandardError=syslog | |||
SyslogIdentifier=tictactoeapi | |||
[Install] | |||
WantedBy=multi-user.target |
@ -0,0 +1,33 @@ | |||
const path = require('path'); | |||
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); | |||
module.exports = { | |||
mode: 'development', | |||
entry: ['./src/index.js'], | |||
output: { | |||
filename: 'bundle.js', | |||
path: path.resolve(__dirname, 'dist'), | |||
library: 'ui' | |||
}, | |||
module: { | |||
rules: [ | |||
{ | |||
test: /\.(sa|sc|c)ss$/, | |||
use: [ | |||
MiniCssExtractPlugin.loader, | |||
'css-loader', | |||
'sass-loader', | |||
], | |||
} | |||
] | |||
}, | |||
target: "web", | |||
plugins: [ | |||
new MiniCssExtractPlugin({ | |||
// Options similar to the same options in webpackOptions.output | |||
// both options are optional | |||
filename: '[name].css', | |||
chunkFilename: '[id].css', | |||
}) | |||
] | |||
}; |