Compare commits
13 Commits
8ea67b9a8c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
5d81d4a922
|
|||
|
cdc53641a5
|
|||
|
0366109e7d
|
|||
|
ec2a023db3
|
|||
|
35289a04f2
|
|||
|
500c080d02
|
|||
|
fb7decc608
|
|||
|
e8fdbaba84
|
|||
|
3934751615
|
|||
|
ed5e40b3f4
|
|||
| 6800d329d4 | |||
| d093e4709f | |||
| 70ffdbe8d5 |
43
.gitea/workflows/build-and-deploy.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Build and Deploy Website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'Backend/**'
|
||||
- 'Frontend/**'
|
||||
- 'Dockerfile'
|
||||
- '.gitea/workflows/build-and-deploy.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: mac
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.smpark.in
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: registry.smpark.in/smparkin/website:latest
|
||||
cache-from: type=registry,ref=registry.smpark.in/smparkin/website:buildcache
|
||||
cache-to: type=registry,ref=registry.smpark.in/smparkin/website:buildcache,mode=max
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "Built and pushed website:latest"
|
||||
10
.gitignore
vendored
@@ -1,2 +1,10 @@
|
||||
.vscode
|
||||
.build
|
||||
.DS_Store
|
||||
|
||||
# Frontend
|
||||
Frontend/node_modules
|
||||
Frontend/dist
|
||||
|
||||
# Backend
|
||||
Backend/server
|
||||
Backend/public/app/
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>smparkin.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>AppTests</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>Run</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
7
Backend/go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
module smpark.in
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require golang.org/x/time v0.14.0
|
||||
|
||||
require golang.org/x/crypto v0.48.0 // indirect
|
||||
4
Backend/go.sum
Normal file
@@ -0,0 +1,4 @@
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
46
Backend/main.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
"smpark.in/paste"
|
||||
)
|
||||
|
||||
func main() {
|
||||
publicDir := "./public"
|
||||
spaIndex := filepath.Join(publicDir, "app", "index.html")
|
||||
|
||||
store := paste.NewStore()
|
||||
|
||||
go paste.StartExpiryWorker(context.Background(), store, 30*time.Second)
|
||||
|
||||
rl := paste.NewRateLimiter(rate.Every(30*time.Second), 1)
|
||||
|
||||
handlers := paste.NewHandlers(store)
|
||||
http.HandleFunc("POST /api/paste", rl.Middleware(handlers.Create))
|
||||
http.HandleFunc("GET /api/paste/{id}/raw", handlers.GetRaw)
|
||||
http.HandleFunc("GET /api/paste/{id}", handlers.Get)
|
||||
|
||||
fs := http.FileServer(http.Dir(publicDir))
|
||||
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
path := filepath.Join(publicDir, filepath.Clean(r.URL.Path))
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
fs.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, spaIndex)
|
||||
})
|
||||
|
||||
addr := ":8080"
|
||||
slog.Info("listening", "addr", addr)
|
||||
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||
slog.Error("server error", "err", err)
|
||||
}
|
||||
}
|
||||
42
Backend/paste/expiry.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package paste
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
func StartExpiryWorker(ctx context.Context, s *Store, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
deleteExpired(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteExpired(s *Store) {
|
||||
var expired []string
|
||||
err := s.ForEach(func(p *Paste) error {
|
||||
if p.ExpiresAt != nil && time.Now().After(*p.ExpiresAt) {
|
||||
expired = append(expired, p.ID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("expiry scan error", "err", err)
|
||||
return
|
||||
}
|
||||
for _, id := range expired {
|
||||
if err := s.Delete(id); err != nil {
|
||||
slog.Error("expiry delete error", "id", id, "err", err)
|
||||
}
|
||||
}
|
||||
if len(expired) > 0 {
|
||||
slog.Info("deleted expired pastes", "count", len(expired))
|
||||
}
|
||||
}
|
||||
151
Backend/paste/handlers.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package paste
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewHandlers(store *Store) *Handlers {
|
||||
return &Handlers{store: store}
|
||||
}
|
||||
|
||||
type createRequest struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Language string `json:"language"`
|
||||
Expiry int `json:"expiry"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func (h *Handlers) Create(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1_100_000)
|
||||
|
||||
var req createRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
writeError(w, http.StatusBadRequest, "content is required")
|
||||
return
|
||||
}
|
||||
if len(req.Content) > 1_000_000 {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "content exceeds 1,000,000 characters")
|
||||
return
|
||||
}
|
||||
if len(req.Title) > 200 {
|
||||
writeError(w, http.StatusBadRequest, "title exceeds 200 characters")
|
||||
return
|
||||
}
|
||||
|
||||
var passwordHash string
|
||||
if req.Password != "" {
|
||||
if len(req.Password) > 72 {
|
||||
writeError(w, http.StatusBadRequest, "password exceeds 72 characters")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to process password")
|
||||
return
|
||||
}
|
||||
passwordHash = string(hash)
|
||||
}
|
||||
|
||||
p := &Paste{
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Language: req.Language,
|
||||
PasswordHash: passwordHash,
|
||||
Protected: passwordHash != "",
|
||||
}
|
||||
|
||||
if req.Expiry < 1 || req.Expiry > 10 {
|
||||
writeError(w, http.StatusBadRequest, "expiry must be between 1 and 10 minutes")
|
||||
return
|
||||
}
|
||||
t := time.Now().Add(time.Duration(req.Expiry) * time.Minute)
|
||||
p.ExpiresAt = &t
|
||||
|
||||
if err := h.store.Create(p); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create paste")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]string{
|
||||
"id": p.ID,
|
||||
"url": "/paste/" + p.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
p, err := h.store.Get(id)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "paste not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to retrieve paste")
|
||||
return
|
||||
}
|
||||
if p.ExpiresAt != nil && time.Now().After(*p.ExpiresAt) {
|
||||
h.store.Delete(id)
|
||||
writeError(w, http.StatusGone, "paste has expired")
|
||||
return
|
||||
}
|
||||
if p.Protected {
|
||||
pw := r.Header.Get("X-Paste-Password")
|
||||
if pw == "" || bcrypt.CompareHashAndPassword([]byte(p.PasswordHash), []byte(pw)) != nil {
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{"protected": true, "error": "password required"})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (h *Handlers) GetRaw(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
p, err := h.store.Get(id)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
http.Error(w, "paste not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "failed to retrieve paste", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if p.ExpiresAt != nil && time.Now().After(*p.ExpiresAt) {
|
||||
h.store.Delete(id)
|
||||
http.Error(w, "paste has expired", http.StatusGone)
|
||||
return
|
||||
}
|
||||
if p.Protected {
|
||||
pw := r.Header.Get("X-Paste-Password")
|
||||
if pw == "" || bcrypt.CompareHashAndPassword([]byte(p.PasswordHash), []byte(pw)) != nil {
|
||||
http.Error(w, "password required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Write([]byte(p.Content))
|
||||
}
|
||||
82
Backend/paste/ratelimit.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package paste
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type ipLimiter struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
limiters map[string]*ipLimiter
|
||||
rate rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
func NewRateLimiter(r rate.Limit, burst int) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
limiters: make(map[string]*ipLimiter),
|
||||
rate: r,
|
||||
burst: burst,
|
||||
}
|
||||
go rl.cleanup()
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) get(ip string) *rate.Limiter {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
entry, ok := rl.limiters[ip]
|
||||
if !ok {
|
||||
entry = &ipLimiter{limiter: rate.NewLimiter(rl.rate, rl.burst)}
|
||||
rl.limiters[ip] = entry
|
||||
}
|
||||
entry.lastSeen = time.Now()
|
||||
return entry.limiter
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
for range time.Tick(5 * time.Minute) {
|
||||
rl.mu.Lock()
|
||||
for ip, entry := range rl.limiters {
|
||||
if time.Since(entry.lastSeen) > 10*time.Minute {
|
||||
delete(rl.limiters, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) Middleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := extractIP(r)
|
||||
if !rl.get(ip).Allow() {
|
||||
slog.Warn("rate limit exceeded", "ip", ip)
|
||||
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func extractIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
parts := strings.SplitN(fwd, ",", 2)
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
97
Backend/paste/store.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package paste
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("paste not found")
|
||||
|
||||
type Paste struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Language string `json:"language"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
PasswordHash string `json:"-"`
|
||||
Protected bool `json:"protected"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
pastes map[string]*Paste
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{pastes: make(map[string]*Paste)}
|
||||
}
|
||||
|
||||
func (s *Store) Create(p *Paste) error {
|
||||
id, err := generateID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.ID = id
|
||||
p.CreatedAt = time.Now().UTC()
|
||||
|
||||
s.mu.Lock()
|
||||
s.pastes[id] = p
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (*Paste, error) {
|
||||
s.mu.RLock()
|
||||
p, ok := s.pastes[id]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
delete(s.pastes, id)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ForEach(fn func(p *Paste) error) error {
|
||||
s.mu.RLock()
|
||||
ids := make([]string, 0, len(s.pastes))
|
||||
for id := range s.pastes {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
for _, id := range ids {
|
||||
s.mu.RLock()
|
||||
p, ok := s.pastes[id]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := fn(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const base62Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
func generateID() (string, error) {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := make([]byte, 8)
|
||||
for i, v := range b {
|
||||
result[i] = base62Chars[int(v)%62]
|
||||
}
|
||||
return string(result), nil
|
||||
}
|
||||
4
Backend/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="16" fill="#9370db"/>
|
||||
<text x="50" y="68" font-family="system-ui, sans-serif" font-size="48" font-weight="bold" fill="white" text-anchor="middle">SP</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 264 B |
|
Before Width: | Height: | Size: 544 KiB After Width: | Height: | Size: 544 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
89
Dockerfile
@@ -1,77 +1,40 @@
|
||||
# ================================
|
||||
# Build image
|
||||
# Frontend build
|
||||
# ================================
|
||||
FROM swift:6.1.0-noble AS build
|
||||
FROM node:22-slim AS frontend
|
||||
|
||||
# Install OS updates and, if needed, sqlite3
|
||||
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||
&& apt-get -q update \
|
||||
&& apt-get -q dist-upgrade -y\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /frontend
|
||||
COPY Frontend/package.json Frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY Frontend/ ./
|
||||
RUN npm run build -- --outDir dist --emptyOutDir
|
||||
|
||||
# ================================
|
||||
# Backend build
|
||||
# ================================
|
||||
FROM golang:1.26-alpine AS backend
|
||||
|
||||
# Set up a build area
|
||||
WORKDIR /build
|
||||
|
||||
# First just resolve dependencies.
|
||||
# This creates a cached layer that can be reused
|
||||
# as long as your Package.swift/Package.resolved
|
||||
# files do not change.
|
||||
COPY ./Package.* ./
|
||||
RUN swift package resolve
|
||||
|
||||
# Copy entire repo into container
|
||||
COPY . .
|
||||
|
||||
# Build everything, with optimizations
|
||||
RUN swift build -c release --static-swift-stdlib
|
||||
|
||||
# Switch to the staging area
|
||||
WORKDIR /staging
|
||||
|
||||
# Copy main executable to staging area
|
||||
RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Run" ./
|
||||
|
||||
# Copy resources bundled by SPM to staging area
|
||||
RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
|
||||
|
||||
# Copy any resources from the public directory and views directory if the directories exist
|
||||
# Ensure that by default, neither the directory nor any of its contents are writable.
|
||||
RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true
|
||||
RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true
|
||||
COPY Backend/ ./
|
||||
RUN CGO_ENABLED=0 go build -o server .
|
||||
|
||||
# ================================
|
||||
# Run image
|
||||
# Runtime
|
||||
# ================================
|
||||
FROM ubuntu:noble
|
||||
FROM alpine:3.21
|
||||
|
||||
# Make sure all system packages are up to date, and install only essential packages.
|
||||
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||
&& apt-get -q update \
|
||||
&& apt-get -q dist-upgrade -y \
|
||||
&& apt-get -q install -y \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
|
||||
# libcurl4 \
|
||||
# If your app or its dependencies import FoundationXML, also install `libxml2`.
|
||||
# libxml2 \
|
||||
&& rm -r /var/lib/apt/lists/*
|
||||
|
||||
# Create a vapor user and group with /app as its home directory
|
||||
RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor
|
||||
|
||||
# Switch to the new home directory
|
||||
RUN adduser -D -h /app app
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built executable and any staged resources from builder
|
||||
COPY --from=build --chown=vapor:vapor /staging /app
|
||||
# Copy Go binary
|
||||
COPY --from=backend --chown=app:app /build/server ./
|
||||
|
||||
# Ensure all further commands run as the vapor user
|
||||
USER vapor:vapor
|
||||
# Copy static assets
|
||||
COPY --chown=app:app Backend/public/ ./public/
|
||||
|
||||
# Let Docker bind to port 8080
|
||||
# Copy built frontend
|
||||
COPY --from=frontend --chown=app:app /frontend/dist ./public/app/
|
||||
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
|
||||
# Start the Vapor service when the image is run, default to listening on 8080 in production environment
|
||||
ENTRYPOINT ["./Run"]
|
||||
CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]
|
||||
ENTRYPOINT ["./server"]
|
||||
|
||||
24
Frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
7
Frontend/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100
|
||||
}
|
||||
23
Frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
16
Frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Stephen Parkinson</title>
|
||||
</head>
|
||||
<body style="margin: 0">
|
||||
<script>
|
||||
document.body.style.backgroundColor = window.matchMedia("(prefers-color-scheme: dark)").matches ? "#161d26" : "#ffffff";
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3782
Frontend/package-lock.json
generated
Normal file
37
Frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "prettier --write src/ && tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write src/",
|
||||
"format:check": "prettier --check src/",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudscape-design/code-view": "^3.0.102",
|
||||
"@cloudscape-design/components": "^3.0.1200",
|
||||
"@cloudscape-design/global-styles": "^1.0.50",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
19
Frontend/src/App.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
52
Frontend/src/App.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { BrowserRouter, Routes, Route, useNavigate, useLocation } from "react-router-dom";
|
||||
import AppLayout from "@cloudscape-design/components/app-layout";
|
||||
import SideNavigation from "@cloudscape-design/components/side-navigation";
|
||||
import "@cloudscape-design/global-styles/index.css";
|
||||
import "./App.css";
|
||||
import Home from "./pages/Home";
|
||||
import Resume from "./pages/Resume";
|
||||
import Paste from "./pages/Paste";
|
||||
import PasteView from "./pages/PasteView";
|
||||
import NotFound from "./pages/NotFound";
|
||||
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
toolsHide
|
||||
navigation={
|
||||
<SideNavigation
|
||||
header={{ text: "Stephen Parkinson", href: "/" }}
|
||||
activeHref={location.pathname}
|
||||
onFollow={(event) => {
|
||||
event.preventDefault();
|
||||
navigate(event.detail.href);
|
||||
}}
|
||||
items={[
|
||||
{ type: "link", text: "Home", href: "/" },
|
||||
{ type: "link", text: "Resume", href: "/resume" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/resume" element={<Resume />} />
|
||||
<Route path="/paste" element={<Paste />} />
|
||||
<Route path="/paste/:id" element={<PasteView />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppContent />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
17
Frontend/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { applyMode, Mode } from "@cloudscape-design/global-styles";
|
||||
|
||||
import App from "./App.tsx";
|
||||
|
||||
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
applyMode(darkQuery.matches ? Mode.Dark : Mode.Light);
|
||||
darkQuery.addEventListener("change", (e) => {
|
||||
applyMode(e.matches ? Mode.Dark : Mode.Light);
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
42
Frontend/src/pages/Home.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import ContentLayout from "@cloudscape-design/components/content-layout";
|
||||
import Header from "@cloudscape-design/components/header";
|
||||
import Container from "@cloudscape-design/components/container";
|
||||
import SpaceBetween from "@cloudscape-design/components/space-between";
|
||||
import Link from "@cloudscape-design/components/link";
|
||||
import Box from "@cloudscape-design/components/box";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<ContentLayout
|
||||
header={
|
||||
<div className="page-header">
|
||||
<img src="/images/me.jpg" alt="Stephen Parkinson" className="profile-image" />
|
||||
<Header variant="h1" description="Systems Development Engineer">
|
||||
Stephen Parkinson
|
||||
</Header>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SpaceBetween size="l">
|
||||
<Container header={<Header variant="h2">About Me</Header>}>
|
||||
<Box variant="p">I'm doing a lot of stuff with computers right now.</Box>
|
||||
</Container>
|
||||
|
||||
<Container>
|
||||
<img src="/images/trees.jpg" alt="Trees" className="hero-image" />
|
||||
</Container>
|
||||
|
||||
<Container header={<Header variant="h2">Contact</Header>}>
|
||||
<SpaceBetween size="s" direction="horizontal">
|
||||
<Link href="https://www.linkedin.com/in/stephen-parkinson" external>
|
||||
LinkedIn
|
||||
</Link>
|
||||
<Link href="https://github.com/smparkin" external>
|
||||
GitHub
|
||||
</Link>
|
||||
</SpaceBetween>
|
||||
</Container>
|
||||
</SpaceBetween>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
14
Frontend/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import ContentLayout from "@cloudscape-design/components/content-layout";
|
||||
import Header from "@cloudscape-design/components/header";
|
||||
import Box from "@cloudscape-design/components/box";
|
||||
import Link from "@cloudscape-design/components/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<ContentLayout header={<Header variant="h1">404</Header>}>
|
||||
<Box variant="p">
|
||||
The page you're looking for doesn't exist. <Link href="/">Go home</Link>
|
||||
</Box>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
139
Frontend/src/pages/Paste.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import ContentLayout from "@cloudscape-design/components/content-layout";
|
||||
import Header from "@cloudscape-design/components/header";
|
||||
import Container from "@cloudscape-design/components/container";
|
||||
import FormField from "@cloudscape-design/components/form-field";
|
||||
import Input from "@cloudscape-design/components/input";
|
||||
import Textarea from "@cloudscape-design/components/textarea";
|
||||
import Select from "@cloudscape-design/components/select";
|
||||
import Slider from "@cloudscape-design/components/slider";
|
||||
import Button from "@cloudscape-design/components/button";
|
||||
import Alert from "@cloudscape-design/components/alert";
|
||||
import SpaceBetween from "@cloudscape-design/components/space-between";
|
||||
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ label: "Plain Text", value: "plaintext" },
|
||||
{ label: "Bash", value: "bash" },
|
||||
{ label: "C", value: "c" },
|
||||
{ label: "C++", value: "cpp" },
|
||||
{ label: "CSS", value: "css" },
|
||||
{ label: "Dockerfile", value: "dockerfile" },
|
||||
{ label: "Go", value: "go" },
|
||||
{ label: "HTML", value: "html" },
|
||||
{ label: "Java", value: "java" },
|
||||
{ label: "JavaScript", value: "javascript" },
|
||||
{ label: "JSON", value: "json" },
|
||||
{ label: "Kotlin", value: "kotlin" },
|
||||
{ label: "Markdown", value: "markdown" },
|
||||
{ label: "Python", value: "python" },
|
||||
{ label: "Ruby", value: "ruby" },
|
||||
{ label: "Rust", value: "rust" },
|
||||
{ label: "SQL", value: "sql" },
|
||||
{ label: "Swift", value: "swift" },
|
||||
{ label: "TypeScript", value: "typescript" },
|
||||
{ label: "YAML", value: "yaml" },
|
||||
];
|
||||
|
||||
export default function Paste() {
|
||||
const navigate = useNavigate();
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [language, setLanguage] = useState(LANGUAGE_OPTIONS[0]);
|
||||
const [expiry, setExpiry] = useState(5);
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!content.trim()) {
|
||||
setError("Content is required.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/paste", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
content,
|
||||
language: language.value,
|
||||
expiry,
|
||||
password: password || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Failed to create paste.");
|
||||
return;
|
||||
}
|
||||
navigate(`/paste/${data.id}`, { state: { password: password || undefined } });
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentLayout header={<Header variant="h1">New Paste</Header>}>
|
||||
<Container>
|
||||
<SpaceBetween size="m">
|
||||
{error && (
|
||||
<Alert type="error" onDismiss={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<FormField label="Title" description="Optional title for your paste.">
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.detail.value)}
|
||||
placeholder="Untitled"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Content" constraintText="Required. Maximum 1 MB.">
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.detail.value)}
|
||||
placeholder="Paste your content here..."
|
||||
rows={20}
|
||||
spellcheck={false}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Language">
|
||||
<Select
|
||||
selectedOption={language}
|
||||
onChange={(e) =>
|
||||
setLanguage(e.detail.selectedOption as { label: string; value: string })
|
||||
}
|
||||
options={LANGUAGE_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Password" description="Optional. Leave blank for a public paste.">
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.detail.value)}
|
||||
placeholder="Leave blank for no password"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Expires" description={`${expiry} minute${expiry === 1 ? "" : "s"}`}>
|
||||
<Slider
|
||||
value={expiry}
|
||||
onChange={(e) => setExpiry(e.detail.value)}
|
||||
min={1}
|
||||
max={10}
|
||||
tickMarks
|
||||
valueFormatter={(v) => `${v}m`}
|
||||
/>
|
||||
</FormField>
|
||||
<Button variant="primary" onClick={handleSubmit} loading={loading}>
|
||||
Create Paste
|
||||
</Button>
|
||||
</SpaceBetween>
|
||||
</Container>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
251
Frontend/src/pages/PasteView.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, Link, useLocation } from "react-router-dom";
|
||||
import ContentLayout from "@cloudscape-design/components/content-layout";
|
||||
import Header from "@cloudscape-design/components/header";
|
||||
import Container from "@cloudscape-design/components/container";
|
||||
import Box from "@cloudscape-design/components/box";
|
||||
import Button from "@cloudscape-design/components/button";
|
||||
import SpaceBetween from "@cloudscape-design/components/space-between";
|
||||
import Spinner from "@cloudscape-design/components/spinner";
|
||||
import Alert from "@cloudscape-design/components/alert";
|
||||
import ColumnLayout from "@cloudscape-design/components/column-layout";
|
||||
import FormField from "@cloudscape-design/components/form-field";
|
||||
import Input from "@cloudscape-design/components/input";
|
||||
import { CodeView } from "@cloudscape-design/code-view";
|
||||
|
||||
interface Paste {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
language: string;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
export default function PasteView() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation();
|
||||
const [paste, setPaste] = useState<Paste | null>(null);
|
||||
const [status, setStatus] = useState<
|
||||
"loading" | "ok" | "notfound" | "expired" | "error" | "locked"
|
||||
>("loading");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const [passwordInput, setPasswordInput] = useState("");
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
|
||||
const initialPassword = (location.state as { password?: string })?.password;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const headers: HeadersInit = {};
|
||||
if (initialPassword) headers["X-Paste-Password"] = initialPassword;
|
||||
fetch(`/api/paste/${id}`, { headers })
|
||||
.then((res) => {
|
||||
if (res.status === 404) {
|
||||
setStatus("notfound");
|
||||
return null;
|
||||
}
|
||||
if (res.status === 410) {
|
||||
setStatus("expired");
|
||||
return null;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
setStatus("locked");
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setStatus("error");
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
setPaste(data);
|
||||
setStatus("ok");
|
||||
}
|
||||
})
|
||||
.catch(() => setStatus("error"));
|
||||
}, [id]);
|
||||
|
||||
async function handleUnlock() {
|
||||
if (!id || !passwordInput) return;
|
||||
setUnlocking(true);
|
||||
setPasswordError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/paste/${id}`, {
|
||||
headers: { "X-Paste-Password": passwordInput },
|
||||
});
|
||||
if (res.status === 403) {
|
||||
setPasswordError("Incorrect password.");
|
||||
return;
|
||||
}
|
||||
if (res.status === 404) {
|
||||
setStatus("notfound");
|
||||
return;
|
||||
}
|
||||
if (res.status === 410) {
|
||||
setStatus("expired");
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setPaste(data);
|
||||
setStatus("ok");
|
||||
} catch {
|
||||
setPasswordError("Network error. Please try again.");
|
||||
} finally {
|
||||
setUnlocking(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyLink() {
|
||||
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setLinkCopied(true);
|
||||
setTimeout(() => setLinkCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
if (!paste) return;
|
||||
navigator.clipboard.writeText(paste.content).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<ContentLayout header={<Header variant="h1">Loading…</Header>}>
|
||||
<Container>
|
||||
<Spinner size="large" />
|
||||
</Container>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "notfound") {
|
||||
return (
|
||||
<ContentLayout header={<Header variant="h1">Not Found</Header>}>
|
||||
<Container>
|
||||
<SpaceBetween size="m">
|
||||
<Alert type="error">This paste does not exist.</Alert>
|
||||
<Link to="/paste">Create a new paste</Link>
|
||||
</SpaceBetween>
|
||||
</Container>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "expired") {
|
||||
return (
|
||||
<ContentLayout header={<Header variant="h1">Expired</Header>}>
|
||||
<Container>
|
||||
<SpaceBetween size="m">
|
||||
<Alert type="warning">This paste has expired and is no longer available.</Alert>
|
||||
<Link to="/paste">Create a new paste</Link>
|
||||
</SpaceBetween>
|
||||
</Container>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "locked") {
|
||||
return (
|
||||
<ContentLayout header={<Header variant="h1">Password Required</Header>}>
|
||||
<Container>
|
||||
<SpaceBetween size="m">
|
||||
{passwordError && (
|
||||
<Alert type="error" onDismiss={() => setPasswordError(null)} dismissible>
|
||||
{passwordError}
|
||||
</Alert>
|
||||
)}
|
||||
<FormField label="Password" description="This paste is password-protected.">
|
||||
<Input
|
||||
type="password"
|
||||
value={passwordInput}
|
||||
onChange={(e) => setPasswordInput(e.detail.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.detail.key === "Enter") handleUnlock();
|
||||
}}
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</FormField>
|
||||
<Button variant="primary" onClick={handleUnlock} loading={unlocking}>
|
||||
Unlock
|
||||
</Button>
|
||||
</SpaceBetween>
|
||||
</Container>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error" || !paste) {
|
||||
return (
|
||||
<ContentLayout header={<Header variant="h1">Error</Header>}>
|
||||
<Container>
|
||||
<Alert type="error">Failed to load paste. Please try again.</Alert>
|
||||
</Container>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentLayout
|
||||
header={
|
||||
<Header
|
||||
variant="h1"
|
||||
actions={
|
||||
<SpaceBetween direction="horizontal" size="xs">
|
||||
<Button onClick={handleCopyLink} iconName={linkCopied ? "status-positive" : "share"}>
|
||||
{linkCopied ? "Copied!" : "Copy Link"}
|
||||
</Button>
|
||||
<Button onClick={handleCopy} iconName={copied ? "status-positive" : "copy"}>
|
||||
{copied ? "Copied!" : "Copy Text"}
|
||||
</Button>
|
||||
{!paste.protected && (
|
||||
<Button href={`/api/paste/${id}/raw`} target="_blank" iconName="external">
|
||||
Raw
|
||||
</Button>
|
||||
)}
|
||||
</SpaceBetween>
|
||||
}
|
||||
>
|
||||
{paste.title || "Untitled"}
|
||||
</Header>
|
||||
}
|
||||
>
|
||||
<SpaceBetween size="m">
|
||||
<Container>
|
||||
<ColumnLayout columns={3} variant="text-grid">
|
||||
<div>
|
||||
<Box variant="awsui-key-label">Language</Box>
|
||||
<Box>{paste.language || "Plain Text"}</Box>
|
||||
</div>
|
||||
<div>
|
||||
<Box variant="awsui-key-label">Created</Box>
|
||||
<Box>{formatDate(paste.created_at)}</Box>
|
||||
</div>
|
||||
<div>
|
||||
<Box variant="awsui-key-label">Expires</Box>
|
||||
<Box>{paste.expires_at ? formatDate(paste.expires_at) : "Never"}</Box>
|
||||
</div>
|
||||
</ColumnLayout>
|
||||
</Container>
|
||||
<Container>
|
||||
<CodeView content={paste.content} lineNumbers wrapLines />
|
||||
</Container>
|
||||
</SpaceBetween>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
137
Frontend/src/pages/Resume.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import ContentLayout from "@cloudscape-design/components/content-layout";
|
||||
import Header from "@cloudscape-design/components/header";
|
||||
import Container from "@cloudscape-design/components/container";
|
||||
import SpaceBetween from "@cloudscape-design/components/space-between";
|
||||
import Box from "@cloudscape-design/components/box";
|
||||
import ColumnLayout from "@cloudscape-design/components/column-layout";
|
||||
|
||||
export default function Resume() {
|
||||
return (
|
||||
<ContentLayout header={<Header variant="h1">Resume</Header>}>
|
||||
<SpaceBetween size="l">
|
||||
<Container header={<Header variant="h2">Employment</Header>}>
|
||||
<SpaceBetween size="l">
|
||||
<SpaceBetween size="xxs">
|
||||
<Header variant="h3">Systems Development Engineer, Amazon Security</Header>
|
||||
<Box variant="small" color="text-body-secondary">
|
||||
San Luis Obispo, CA · April 2020 – Present (Promoted to full-time July
|
||||
2022)
|
||||
</Box>
|
||||
<Box variant="p">
|
||||
<ul>
|
||||
<li>
|
||||
Create and deploy serverless applications to collect and enrich data for
|
||||
analysis.
|
||||
</li>
|
||||
<li>
|
||||
Add monitoring and alarming to existing services to allow for easier diagnosing
|
||||
of potential issues.
|
||||
</li>
|
||||
<li>
|
||||
Work with customers to understand requirements and deliver results quickly.
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
</SpaceBetween>
|
||||
|
||||
<SpaceBetween size="xxs">
|
||||
<Header variant="h3">Student Assistant, On-Site Support Cal Poly ITS</Header>
|
||||
<Box variant="small" color="text-body-secondary">
|
||||
San Luis Obispo, CA · May 2019 – February 2020
|
||||
</Box>
|
||||
<Box variant="p">
|
||||
<ul>
|
||||
<li>
|
||||
Responsible for support of computer services and equipment across multiple
|
||||
departments.
|
||||
</li>
|
||||
<li>Troubleshooting, detecting, and solving of technical problems.</li>
|
||||
<li>
|
||||
Managing macOS and Windows based computers using Active Directory, SCCM, and
|
||||
Jamf.
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
</SpaceBetween>
|
||||
</SpaceBetween>
|
||||
</Container>
|
||||
|
||||
<Container header={<Header variant="h2">Education</Header>}>
|
||||
<SpaceBetween size="l">
|
||||
<SpaceBetween size="xxs">
|
||||
<Header variant="h3">California Polytechnic State University</Header>
|
||||
<Box variant="small" color="text-body-secondary">
|
||||
2017 – 2022
|
||||
</Box>
|
||||
<Box variant="p">
|
||||
<strong>Software Engineering</strong>
|
||||
<br />
|
||||
Relevant Coursework: Introduction to Computing, Fundamentals of Computer Science,
|
||||
Data Structures, Project-Based Object-Oriented Programming & Design,
|
||||
Introduction to Computer Organization, Systems Programming, and Introduction to
|
||||
Operating Systems.
|
||||
</Box>
|
||||
</SpaceBetween>
|
||||
|
||||
<SpaceBetween size="xxs">
|
||||
<Header variant="h3">Cal Poly Security Education Club — President</Header>
|
||||
<Box variant="small" color="text-body-secondary">
|
||||
Spring 2020 – Spring 2021
|
||||
</Box>
|
||||
<Box variant="p">
|
||||
<ul>
|
||||
<li>
|
||||
Responsible for planning and logistics of events including iFixit Triathlon and
|
||||
Security Career Fair.
|
||||
</li>
|
||||
<li>
|
||||
Manage a team of student officers to introduce students to security topics at
|
||||
all skill levels.
|
||||
</li>
|
||||
<li>Coordinate with companies for presentations and special events.</li>
|
||||
<li>
|
||||
Presented technical talks such as "iOS Security and Jailbreaking", "machswap, a
|
||||
vulnerability in XNU IPC", "Intro to SSH", "Nintendo Switch Security", "macOS
|
||||
Security", and "Mac File Systems and APFS Security".
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
</SpaceBetween>
|
||||
</SpaceBetween>
|
||||
</Container>
|
||||
|
||||
<Container header={<Header variant="h2">Skills</Header>}>
|
||||
<ColumnLayout columns={3} variant="text-grid">
|
||||
<SpaceBetween size="xxs">
|
||||
<Box variant="h3">Languages & Tools</Box>
|
||||
<Box variant="p">
|
||||
Python, TypeScript, React, JavaScript, C, Swift, git, zsh, Burp Suite
|
||||
</Box>
|
||||
</SpaceBetween>
|
||||
<SpaceBetween size="xxs">
|
||||
<Box variant="h3">AWS</Box>
|
||||
<Box variant="p">Lambda, S3, DynamoDB, CloudWatch, KMS, SSM, SES, SQS, SNS, CDK</Box>
|
||||
</SpaceBetween>
|
||||
<SpaceBetween size="xxs">
|
||||
<Box variant="h3">Operating Systems</Box>
|
||||
<Box variant="p">macOS, Windows, Linux</Box>
|
||||
</SpaceBetween>
|
||||
</ColumnLayout>
|
||||
</Container>
|
||||
|
||||
<Container header={<Header variant="h2">Projects</Header>}>
|
||||
<ul>
|
||||
<li>
|
||||
Developed a watchOS app to show the status of the White Hat lab on an Apple Watch.
|
||||
</li>
|
||||
<li>Created a Python script to control Spotify's API from the command line.</li>
|
||||
<li>
|
||||
Upgraded White Hat's network infrastructure to take advantage of the 1Gbps connection
|
||||
to the internet.
|
||||
</li>
|
||||
</ul>
|
||||
</Container>
|
||||
</SpaceBetween>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
28
Frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
Frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
Frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
25
Frontend/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/app/',
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../Backend/public/app',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom'],
|
||||
cloudscape: ['@cloudscape-design/components', '@cloudscape-design/global-styles'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
22
Makefile
Normal file
@@ -0,0 +1,22 @@
|
||||
.PHONY: all frontend backend run dev clean upgrade
|
||||
|
||||
all: frontend backend
|
||||
|
||||
frontend:
|
||||
cd Frontend && npm run build
|
||||
|
||||
backend:
|
||||
cd Backend && go build -o server .
|
||||
|
||||
run: all
|
||||
cd Backend && ./server
|
||||
|
||||
dev:
|
||||
cd Frontend && npm run dev
|
||||
|
||||
clean:
|
||||
rm -rf Backend/server Backend/public/app
|
||||
|
||||
upgrade:
|
||||
cd Frontend && npm upgrade
|
||||
cd Backend && go get -u ./... && go mod tidy
|
||||
249
Package.resolved
@@ -1,249 +0,0 @@
|
||||
{
|
||||
"originHash" : "6a244abdea5ff1ff36734246a0ac5fbc6830084dfc1322e83f9683689f7bb3fc",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "async-http-client",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swift-server/async-http-client.git",
|
||||
"state" : {
|
||||
"revision" : "3b265e6a00fc5c3fdb8f91f773e506990c704337",
|
||||
"version" : "1.26.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "async-kit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/async-kit.git",
|
||||
"state" : {
|
||||
"revision" : "e048c8ee94967e8d8a1c2ec0e1156d6f7fa34d31",
|
||||
"version" : "1.20.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "console-kit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/console-kit.git",
|
||||
"state" : {
|
||||
"revision" : "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b",
|
||||
"version" : "4.15.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "leaf",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/leaf.git",
|
||||
"state" : {
|
||||
"revision" : "d469584b9186851c5a4012d11325fb9db3207ebb",
|
||||
"version" : "4.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "leaf-kit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/leaf-kit.git",
|
||||
"state" : {
|
||||
"revision" : "cf186d8f2ef33e16fd1dd78df36466c22c2e632f",
|
||||
"version" : "1.13.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "multipart-kit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/multipart-kit.git",
|
||||
"state" : {
|
||||
"revision" : "3498e60218e6003894ff95192d756e238c01f44e",
|
||||
"version" : "4.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "routing-kit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/routing-kit.git",
|
||||
"state" : {
|
||||
"revision" : "93f7222c8e195cbad39fafb5a0e4cc85a8def7ea",
|
||||
"version" : "4.9.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-algorithms.git",
|
||||
"state" : {
|
||||
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "a54383ada6cecde007d374f58f864e29370ba5c3",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
|
||||
"version" : "1.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
|
||||
"version" : "1.1.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto.git",
|
||||
"state" : {
|
||||
"revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed",
|
||||
"version" : "3.12.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-distributed-tracing",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-distributed-tracing.git",
|
||||
"state" : {
|
||||
"revision" : "a64a0abc2530f767af15dd88dda7f64d5f1ff9de",
|
||||
"version" : "1.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-http-structured-headers",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-http-structured-headers.git",
|
||||
"state" : {
|
||||
"revision" : "f280fc7676b9940ff2c6598642751ea333c6544f",
|
||||
"version" : "1.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-http-types",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-http-types.git",
|
||||
"state" : {
|
||||
"revision" : "a0a57e949a8903563aba4615869310c0ebf14c03",
|
||||
"version" : "1.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "3d8596ed08bd13520157f0355e35caed215ffbfa",
|
||||
"version" : "1.6.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-metrics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-metrics.git",
|
||||
"state" : {
|
||||
"revision" : "4c83e1cdf4ba538ef6e43a9bbd0bcc33a0ca46e3",
|
||||
"version" : "2.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "34d486b01cd891297ac615e40d5999536a1e138d",
|
||||
"version" : "2.83.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-extras.git",
|
||||
"state" : {
|
||||
"revision" : "f1f6f772198bee35d99dd145f1513d8581a54f2c",
|
||||
"version" : "1.26.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-http2",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-http2.git",
|
||||
"state" : {
|
||||
"revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5",
|
||||
"version" : "1.38.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-ssl",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-ssl.git",
|
||||
"state" : {
|
||||
"revision" : "4b38f35946d00d8f6176fe58f96d83aba64b36c7",
|
||||
"version" : "2.31.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-transport-services",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-transport-services.git",
|
||||
"state" : {
|
||||
"revision" : "cd1e89816d345d2523b11c55654570acd5cd4c56",
|
||||
"version" : "1.24.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-numerics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-numerics.git",
|
||||
"state" : {
|
||||
"revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8",
|
||||
"version" : "1.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-service-context",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-service-context.git",
|
||||
"state" : {
|
||||
"revision" : "8946c930cae601452149e45d31d8ddfac973c3c7",
|
||||
"version" : "1.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1",
|
||||
"version" : "1.4.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "vapor",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/vapor.git",
|
||||
"state" : {
|
||||
"revision" : "87b0edd2633c35de543cb7573efe5fbf456181bc",
|
||||
"version" : "4.114.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "websocket-kit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/websocket-kit.git",
|
||||
"state" : {
|
||||
"revision" : "8666c92dbbb3c8eefc8008c9c8dcf50bfd302167",
|
||||
"version" : "2.16.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// swift-tools-version:6.1.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "smparkin",
|
||||
platforms: [
|
||||
.macOS(.v13)
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/vapor/vapor.git", .upToNextMajor(from: "4.114.0")),
|
||||
.package(url: "https://github.com/vapor/leaf.git", .upToNextMajor(from: "4.5.0")),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "App",
|
||||
dependencies: [
|
||||
.product(name: "Vapor", package: "vapor"),
|
||||
.product(name: "Leaf", package: "leaf")
|
||||
],
|
||||
swiftSettings: [
|
||||
.unsafeFlags(["-cross-module-optimization"], .when(configuration: .release))
|
||||
]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "Run",
|
||||
dependencies: [
|
||||
.target(name: "App")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "AppTests",
|
||||
dependencies: [
|
||||
.target(name: "App"),
|
||||
.product(name: "XCTVapor", package: "vapor")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -1,96 +0,0 @@
|
||||
* {
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#notfound {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#notfound .notfound {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.notfound {
|
||||
max-width: 410px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notfound .notfound-404 {
|
||||
height: 280px;
|
||||
position: relative;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.notfound .notfound-404 h1 {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
font-size: 230px;
|
||||
margin: 0px;
|
||||
font-weight: 900;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
-webkit-transform: translateX(-50%);
|
||||
-ms-transform: translateX(-50%);
|
||||
transform: translateX(-50%);
|
||||
background: url('../images/bg.jpg') no-repeat;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
|
||||
.notfound h2 {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
color: #000;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.notfound p {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
color: #000;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 20px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.notfound a {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
background: #0046d5;
|
||||
display: inline-block;
|
||||
padding: 15px 30px;
|
||||
border-radius: 40px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
-webkit-box-shadow: 0px 4px 15px -5px #0046d5;
|
||||
box-shadow: 0px 4px 15px -5px #0046d5;
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width: 767px) {
|
||||
.notfound .notfound-404 {
|
||||
height: 142px;
|
||||
}
|
||||
.notfound .notfound-404 h1 {
|
||||
font-size: 112px;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 497 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 466 KiB |
@@ -1,42 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags -->
|
||||
|
||||
<title>404</title>
|
||||
|
||||
<!-- Google font -->
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:400,700,900" rel="stylesheet">
|
||||
|
||||
<!-- Custom stlylesheet -->
|
||||
<link type="text/css" rel="stylesheet" href="/css/404style.css" />
|
||||
|
||||
<!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
|
||||
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="notfound">
|
||||
<div class="notfound">
|
||||
<div class="notfound-404">
|
||||
<h1>Oops!</h1>
|
||||
</div>
|
||||
<h2>404 - Page not found</h2>
|
||||
<p>The page you are looking for might have been removed had its name changed or is temporarily unavailable.</p>
|
||||
<a href="https://smpark.in">Home</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body><!-- This templates was made by Colorlib (https://colorlib.com) -->
|
||||
|
||||
</html>
|
||||
@@ -1,109 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Stephen Parkinson</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
}
|
||||
header {
|
||||
background-color: #1e1e1e;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-bottom: 2px solid #9370db !important;
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
color: #9370db !important;
|
||||
}
|
||||
header img {
|
||||
max-width: 150px;
|
||||
height: auto;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #9370db !important;
|
||||
margin: 10px 0;
|
||||
}
|
||||
a:link,
|
||||
a:visited {
|
||||
color: #9370db !important;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
a:hover {
|
||||
background-color: #9370db !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
a:active {
|
||||
background-color: #9370db !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
section {
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
section img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0 auto 20px auto;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #9370db !important;
|
||||
}
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background-color: #1e1e1e;
|
||||
border-top: 2px solid #9370db !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img
|
||||
src="/images/me.jpg"
|
||||
alt="Stephen Parkinson"
|
||||
/>
|
||||
<h1>Stephen Parkinson</h1>
|
||||
<p>Systems Development Engineer</p>
|
||||
</header>
|
||||
<section id="about">
|
||||
<h2>About Me</h2>
|
||||
<p>I'm doing a lot of stuff with computers right now.</p>
|
||||
</section>
|
||||
<section id="image">
|
||||
<img
|
||||
src="images/trees.jpg"
|
||||
alt="Project Image"
|
||||
/>
|
||||
</section>
|
||||
<section id="contact">
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Connect with me on
|
||||
<a
|
||||
href="https://www.linkedin.com/in/stephen-parkinson"
|
||||
target="_blank"
|
||||
>LinkedIn</a
|
||||
>.
|
||||
</p>
|
||||
<p>
|
||||
Check out my work on
|
||||
<a href="https://github.com/smparkin" target="_blank"
|
||||
>GitHub</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
<footer>
|
||||
<p>© 2024 Stephen Parkinson. All rights reserved.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,107 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width'>
|
||||
<title>Privacy Policy</title>
|
||||
<style> body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; padding:1em; } </style>
|
||||
</head>
|
||||
<body>
|
||||
<strong>Privacy Policy</strong>
|
||||
<p>
|
||||
Stephen Parkinson built the Monitorr app as
|
||||
a Free app. This SERVICE is provided by
|
||||
Stephen Parkinson at no cost and is intended for use as
|
||||
is.
|
||||
</p> <p>
|
||||
This page is used to inform visitors regarding my
|
||||
policies with the collection, use, and disclosure of Personal
|
||||
Information if anyone decided to use my Service.
|
||||
</p> <p>
|
||||
If you choose to use my Service, then you agree to
|
||||
the collection and use of information in relation to this
|
||||
policy. The Personal Information that I collect is
|
||||
used for providing and improving the Service. I will not use or share your information with
|
||||
anyone except as described in this Privacy Policy.
|
||||
</p> <p>
|
||||
The terms used in this Privacy Policy have the same meanings
|
||||
as in our Terms and Conditions, which are accessible at
|
||||
Monitorr unless otherwise defined in this Privacy Policy.
|
||||
</p> <p><strong>Information Collection and Use</strong></p> <p>
|
||||
For a better experience, while using our Service, I
|
||||
may require you to provide us with certain personally
|
||||
identifiable information. The information that
|
||||
I request will be retained on your device and is not collected by me in any way.
|
||||
</p> <!----> <p><strong>Log Data</strong></p> <p>
|
||||
I want to inform you that whenever you
|
||||
use my Service, in a case of an error in the app
|
||||
I collect data and information (through third-party
|
||||
products) on your phone called Log Data. This Log Data may
|
||||
include information such as your device Internet Protocol
|
||||
(“IP”) address, device name, operating system version, the
|
||||
configuration of the app when utilizing my Service,
|
||||
the time and date of your use of the Service, and other
|
||||
statistics.
|
||||
</p> <p><strong>Cookies</strong></p> <p>
|
||||
Cookies are files with a small amount of data that are
|
||||
commonly used as anonymous unique identifiers. These are sent
|
||||
to your browser from the websites that you visit and are
|
||||
stored on your device's internal memory.
|
||||
</p> <p>
|
||||
This Service does not use these “cookies” explicitly. However,
|
||||
the app may use third-party code and libraries that use
|
||||
“cookies” to collect information and improve their services.
|
||||
You have the option to either accept or refuse these cookies
|
||||
and know when a cookie is being sent to your device. If you
|
||||
choose to refuse our cookies, you may not be able to use some
|
||||
portions of this Service.
|
||||
</p> <p><strong>Service Providers</strong></p> <p>
|
||||
I may employ third-party companies and
|
||||
individuals due to the following reasons:
|
||||
</p> <ul><li>To facilitate our Service;</li> <li>To provide the Service on our behalf;</li> <li>To perform Service-related services; or</li> <li>To assist us in analyzing how our Service is used.</li></ul> <p>
|
||||
I want to inform users of this Service
|
||||
that these third parties have access to their Personal
|
||||
Information. The reason is to perform the tasks assigned to
|
||||
them on our behalf. However, they are obligated not to
|
||||
disclose or use the information for any other purpose.
|
||||
</p> <p><strong>Security</strong></p> <p>
|
||||
I value your trust in providing us your
|
||||
Personal Information, thus we are striving to use commercially
|
||||
acceptable means of protecting it. But remember that no method
|
||||
of transmission over the internet, or method of electronic
|
||||
storage is 100% secure and reliable, and I cannot
|
||||
guarantee its absolute security.
|
||||
</p> <p><strong>Links to Other Sites</strong></p> <p>
|
||||
This Service may contain links to other sites. If you click on
|
||||
a third-party link, you will be directed to that site. Note
|
||||
that these external sites are not operated by me.
|
||||
Therefore, I strongly advise you to review the
|
||||
Privacy Policy of these websites. I have
|
||||
no control over and assume no responsibility for the content,
|
||||
privacy policies, or practices of any third-party sites or
|
||||
services.
|
||||
</p> <p><strong>Children’s Privacy</strong></p> <!----> <div><p>
|
||||
I do not knowingly collect personally
|
||||
identifiable information from children. I
|
||||
encourage all children to never submit any personally
|
||||
identifiable information through
|
||||
the Application and/or Services.
|
||||
I encourage parents and legal guardians to monitor
|
||||
their children's Internet usage and to help enforce this Policy by instructing
|
||||
their children never to provide personally identifiable information through the Application and/or Services without their permission. If you have reason to believe that a child
|
||||
has provided personally identifiable information to us through the Application and/or Services,
|
||||
please contact us. You must also be at least 16 years of age to consent to the processing
|
||||
of your personally identifiable information in your country (in some countries we may allow your parent
|
||||
or guardian to do so on your behalf).
|
||||
</p></div> <p><strong>Changes to This Privacy Policy</strong></p> <p>
|
||||
I may update our Privacy Policy from
|
||||
time to time. Thus, you are advised to review this page
|
||||
periodically for any changes. I will
|
||||
notify you of any changes by posting the new Privacy Policy on
|
||||
this page.
|
||||
</p> <p>This policy is effective as of 2023-03-12</p> <p><strong>Contact Us</strong></p> <p>
|
||||
If you have any questions or suggestions about my
|
||||
Privacy Policy, do not hesitate to contact me at stephen@smpark.in.
|
||||
</p> <p>This privacy policy page was created at <a href="https://privacypolicytemplate.net" target="_blank" rel="noopener noreferrer">privacypolicytemplate.net </a>and modified/generated by <a href="https://app-privacy-policy-generator.nisrulz.com/" target="_blank" rel="noopener noreferrer">App Privacy Policy Generator</a></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
import Leaf
|
||||
import Vapor
|
||||
|
||||
// Called before your application initializes.
|
||||
public func configure(_ app: Application) throws {
|
||||
//leaf
|
||||
app.views.use(.leaf)
|
||||
|
||||
//Register middleware
|
||||
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
|
||||
app.middleware.use(ErrorMiddleware.default(environment: app.environment))
|
||||
|
||||
//routes
|
||||
try routes(app)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import Vapor
|
||||
|
||||
// Register your application's routes here.
|
||||
public func routes(_ app: Application) throws {
|
||||
app.get("") { req async throws -> View in
|
||||
return try await req.view.render("home")
|
||||
}
|
||||
app.get("index.html") { req async throws -> View in
|
||||
return try await req.view.render("home")
|
||||
}
|
||||
app.get("privacy") { req async throws -> View in
|
||||
return try await req.view.render("privacy")
|
||||
}
|
||||
app.get("welcome") { req async throws -> View in
|
||||
return try await req.view.render("welcome")
|
||||
}
|
||||
app.get("**") { req async throws -> View in
|
||||
return try await req.view.render("404")
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import App
|
||||
import Vapor
|
||||
import Logging
|
||||
|
||||
var env = try Environment.detect()
|
||||
try LoggingSystem.bootstrap(from: &env)
|
||||
let app = try await Application.make(env)
|
||||
|
||||
do {
|
||||
try configure(app)
|
||||
try await app.execute()
|
||||
} catch {
|
||||
app.logger.report(error: error)
|
||||
try? await app.asyncShutdown()
|
||||
throw error
|
||||
}
|
||||
|
||||
try await app.asyncShutdown()
|
||||
@@ -1,12 +0,0 @@
|
||||
import XCTest
|
||||
@testable import App
|
||||
|
||||
class AppTests: XCTestCase {
|
||||
func testStub() throws {
|
||||
XCTAssert(true)
|
||||
}
|
||||
|
||||
static let allTests = [
|
||||
("testStub", testStub),
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
#if os(Linux)
|
||||
|
||||
import XCTest
|
||||
@testable import AppTests
|
||||
|
||||
XCTMain([
|
||||
// AppTests
|
||||
testCase(AppTests.allTests),
|
||||
])
|
||||
|
||||
#endif
|
||||
@@ -1,29 +0,0 @@
|
||||
# Docker Compose file for Vapor
|
||||
#
|
||||
# Install Docker on your system to run and test
|
||||
# your Vapor app in a production-like environment.
|
||||
#
|
||||
# Note: This file is intended for testing and does not
|
||||
# implement best practices for a production deployment.
|
||||
#
|
||||
# Learn more: https://docs.docker.com/compose/reference/
|
||||
#
|
||||
# Build images: docker-compose build
|
||||
# Start app: docker-compose up app
|
||||
# Stop all: docker-compose down
|
||||
#
|
||||
version: '3.7'
|
||||
|
||||
x-shared_environment: &shared_environment
|
||||
LOG_LEVEL: ${LOG_LEVEL:-debug}
|
||||
|
||||
services:
|
||||
app:
|
||||
image: sparkii/smparkin:latest
|
||||
build:
|
||||
context: .
|
||||
environment:
|
||||
<<: *shared_environment
|
||||
ports:
|
||||
- '8888:8888'
|
||||
command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8888"]
|
||||