Compare commits

...

13 Commits

Author SHA1 Message Date
5d81d4a922 update node deps
All checks were successful
Build and Deploy Website / build (push) Successful in 1m15s
2026-04-07 17:30:39 -07:00
cdc53641a5 fix auto-build
All checks were successful
Build and Deploy Website / build (push) Successful in 1m7s
2026-03-24 17:58:38 -07:00
0366109e7d update node deps
Some checks failed
Build and Deploy Website / build (push) Has been cancelled
2026-03-24 17:56:24 -07:00
ec2a023db3 changed text for copy
All checks were successful
Build and Deploy Website / build (push) Successful in 3m30s
2026-03-01 15:05:33 -08:00
35289a04f2 fix password auto-fill
All checks were successful
Build and Deploy Website / build (push) Successful in 2m27s
2026-02-28 23:14:54 -08:00
500c080d02 add copy link button
All checks were successful
Build and Deploy Website / build (push) Successful in 2m32s
2026-02-28 23:06:18 -08:00
fb7decc608 add password protected pastes
All checks were successful
Build and Deploy Website / build (push) Successful in 4m27s
2026-02-28 22:50:25 -08:00
e8fdbaba84 fix dockerfile go version
All checks were successful
Build and Deploy Website / build (push) Successful in 5m1s
2026-02-27 23:58:34 -08:00
3934751615 add paste functionality
Some checks failed
Build and Deploy Website / build (push) Failing after 2m46s
2026-02-27 23:38:14 -08:00
ed5e40b3f4 update message
All checks were successful
Build and Deploy Website / build (push) Successful in 43s
2026-02-22 00:49:16 -08:00
6800d329d4 build for x86 and arm
Some checks failed
Build and Deploy Website / build (push) Failing after 1m36s
2026-02-21 19:09:16 -08:00
d093e4709f switch to go backend
All checks were successful
Build and Deploy Website / build (push) Successful in 2m26s
2026-02-12 20:58:20 -08:00
70ffdbe8d5 add gitea autobuild
Some checks failed
Build and Deploy Website / build (push) Failing after 7s
2026-02-05 19:45:47 -08:00
54 changed files with 5180 additions and 890 deletions

View 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
View File

@@ -1,2 +1,10 @@
.vscode
.build
.DS_Store
# Frontend
Frontend/node_modules
Frontend/dist
# Backend
Backend/server
Backend/public/app/

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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
View 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
View 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
View 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
View 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
View 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))
}

View 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
View 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
}

View 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

View File

Before

Width:  |  Height:  |  Size: 544 KiB

After

Width:  |  Height:  |  Size: 544 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -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
View 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
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 100
}

23
Frontend/eslint.config.js Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

37
Frontend/package.json Normal file
View 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
View 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
View 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
View 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>,
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &middot; April 2020 &ndash; 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 &middot; May 2019 &ndash; 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 &ndash; 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 &amp; 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 &mdash; President</Header>
<Box variant="small" color="text-body-secondary">
Spring 2020 &ndash; 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 &amp; 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>
);
}

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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
View 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

View File

@@ -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
}

View File

@@ -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")
]
)
]
)

View File

@@ -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;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

View File

@@ -1 +1 @@
smpark.in
# smpark.in

View File

@@ -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>

View File

@@ -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>&copy; 2024 Stephen Parkinson. All rights reserved.</p>
</footer>
</body>
</html>

View File

@@ -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>Childrens 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>

File diff suppressed because one or more lines are too long

View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -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()

View File

@@ -1,12 +0,0 @@
import XCTest
@testable import App
class AppTests: XCTestCase {
func testStub() throws {
XCTAssert(true)
}
static let allTests = [
("testStub", testStub),
]
}

View File

@@ -1,11 +0,0 @@
#if os(Linux)
import XCTest
@testable import AppTests
XCTMain([
// AppTests
testCase(AppTests.allTests),
])
#endif

View File

@@ -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"]