init with Bun + SvelteKit

This commit is contained in:
Shahrad Elahi
2023-11-02 14:34:25 +03:30
parent 857d3d26b5
commit 686172b27b
103 changed files with 216 additions and 7533 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.idea
.ignore
node_modules

View File

@@ -1,12 +1,10 @@
FROM node:alpine as base
WORKDIR /app
FROM oven/bun:alpine as base
LABEL Maintainer="Shahrad Elahi <https://github.com/shahradelahi>"
WORKDIR /usr/src/app
ENV TZ=UTC
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
COPY --from=golang:1.20-alpine /usr/local/go/ /usr/local/go/
COPY --from=gogost/gost:3.0.0-rc8 /bin/gost /usr/local/bin/gost
COPY --from=chriswayg/tor-alpine:latest /usr/local/bin/obfs4proxy /usr/local/bin/obfs4proxy
COPY --from=chriswayg/tor-alpine:latest /usr/local/bin/meek-server /usr/local/bin/meek-server
@@ -20,19 +18,35 @@ RUN apk add -U --no-cache \
redis
FROM base
WORKDIR /app
FROM base AS deps
COPY /src/ /app/
COPY /config/torrc /etc/tor/torrc
RUN mkdir -p /temp/dev
COPY web/package.json web/bun.lockb /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
RUN mkdir -p /temp/prod
COPY web/package.json web/bun.lockb /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
FROM install AS build
COPY --from=deps /temp/dev/node_modules node_modules
COPY web .
# build
ENV NODE_ENV=production
RUN bun run build
FROM base AS release
COPY --from=deps /temp/prod/node_modules node_modules
COPY --from=build /usr/src/app/build .
COPY --from=build /usr/src/app/package.json .
ENV NODE_ENV=production
# run the app
USER bun
EXPOSE 3000/tcp
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://127.0.0.1:3000/api/healthcheck || exit 1
COPY docker-entrypoint.sh /usr/bin/entrypoint
RUN chmod +x /usr/bin/entrypoint
ENTRYPOINT ["/usr/bin/entrypoint"]
CMD ["npm", "run", "dev"]
CMD [ "bun", "start" ]

View File

@@ -3,14 +3,10 @@
"version": "1.0.0",
"description": "",
"scripts": {
"dev:image": "cross-env DOCKER_BUILDKIT=1 docker build --tag wireadmin -f Dockerfile-Dev .",
"dev:image": "DOCKER_BUILDKIT=1 docker build --tag wireadmin -f Dockerfile-Dev .",
"dev": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up; docker rm -f wireadmin"
},
"keywords": [],
"author": "Shahrad Elahi <https://github.com/shahradelahi>",
"license": "MIT",
"packageManager": "pnpm@8.7.0",
"dependencies": {
"cross-env": "^7.0.3"
}
"license": "MIT"
}

58
pnpm-lock.yaml generated
View File

@@ -1,58 +0,0 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
cross-env:
specifier: ^7.0.3
version: 7.0.3
packages:
/cross-env@7.0.3:
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
hasBin: true
dependencies:
cross-spawn: 7.0.3
dev: false
/cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
dev: false
/isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
dev: false
/path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
dev: false
/shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
dependencies:
shebang-regex: 3.0.0
dev: false
/shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
dev: false
/which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
dependencies:
isexe: 2.0.0
dev: false

34
src/.gitignore vendored
View File

@@ -1,34 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel

View File

@@ -1 +0,0 @@
auto-install-peers = true

View File

@@ -1,28 +0,0 @@
## Getting Started
First, run the development server:
```bash
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
To create [API routes](https://nextjs.org/docs/app/building-your-application/routing/router-handlers) add an `api/` directory to the `app/` directory with a `route.ts` file. For individual endpoints, create a subfolder in the `api` directory, like `api/hello/route.ts` would map to [http://localhost:3000/api/hello](http://localhost:3000/api/hello).
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn/foundations/about-nextjs) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@@ -1,298 +0,0 @@
import type {
Adapter,
AdapterUser,
AdapterAccount,
AdapterSession,
VerificationToken,
} from "next-auth/adapters"
import type Redis from "ioredis"
/** This is the interface of the Upstash Redis adapter options. */
export interface RedisAdapterOptions {
/**
* The base prefix for your keys
*/
baseKeyPrefix?: string
/**
* The prefix for the `account` key
*/
accountKeyPrefix?: string
/**
* The prefix for the `accountByUserId` key
*/
accountByUserIdPrefix?: string
/**
* The prefix for the `emailKey` key
*/
emailKeyPrefix?: string
/**
* The prefix for the `sessionKey` key
*/
sessionKeyPrefix?: string
/**
* The prefix for the `sessionByUserId` key
*/
sessionByUserIdKeyPrefix?: string
/**
* The prefix for the `user` key
*/
userKeyPrefix?: string
/**
* The prefix for the `verificationToken` key
*/
verificationTokenKeyPrefix?: string
}
export const defaultOptions = {
baseKeyPrefix: "",
accountKeyPrefix: "user:account:",
accountByUserIdPrefix: "user:account:by-user-id:",
emailKeyPrefix: "user:email:",
sessionKeyPrefix: "user:session:",
sessionByUserIdKeyPrefix: "user:session:by-user-id:",
userKeyPrefix: "user:",
verificationTokenKeyPrefix: "user:token:",
}
const isoDateRE =
/(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/
function isDate(value: any) {
return value && isoDateRE.test(value) && !isNaN(Date.parse(value))
}
export function hydrateDates(json: object) {
return Object.entries(json).reduce((acc, [key, val]) => {
acc[key] = isDate(val) ? new Date(val as string) : val
return acc
}, {} as any)
}
/**
* ## Setup
*
* Configure Auth.js to use the Upstash Redis Adapter:
*
* ```javascript title="pages/api/auth/[...nextauth].js"
* import NextAuth from "next-auth"
* import GoogleProvider from "next-auth/providers/google"
* import { RedisAdapter } from "@shahrad/redis-adapter"
* import redisClient from "ioredis";
*
* const redis = redisClient(
* process.env.UPSTASH_REDIS_URL,
* process.env.UPSTASH_REDIS_TOKEN
* )
*
* export default NextAuth({
* adapter: RedisAdapter(redis),
* providers: [
* GoogleProvider({
* clientId: process.env.GOOGLE_CLIENT_ID,
* clientSecret: process.env.GOOGLE_CLIENT_SECRET,
* }),
* ],
* })
* ```
*
* ## Advanced usage
*
* ### Using multiple apps with a single Upstash Redis instance
*
* The Upstash free-tier allows for only one Redis instance. If you have multiple Auth.js connected apps using this instance, you need different key prefixes for every app.
*
* You can change the prefixes by passing an `options` object as the second argument to the adapter factory function.
*
* The default values for this object are:
*
* ```js
* const defaultOptions = {
* baseKeyPrefix: "",
* accountKeyPrefix: "user:account:",
* accountByUserIdPrefix: "user:account:by-user-id:",
* emailKeyPrefix: "user:email:",
* sessionKeyPrefix: "user:session:",
* sessionByUserIdKeyPrefix: "user:session:by-user-id:",
* userKeyPrefix: "user:",
* verificationTokenKeyPrefix: "user:token:",
* }
* ```
*
* Usually changing the `baseKeyPrefix` should be enough for this scenario, but for more custom setups, you can also change the prefixes of every single key.
*
* Example:
*
* ```js
* export default NextAuth({
* ...
* adapter: RedisAdapter(redis, {baseKeyPrefix: "app2:"})
* ...
* })
* ```
*/
export function RedisAdapter(
client: Redis,
options: RedisAdapterOptions = {}
): Adapter {
const mergedOptions = {
...defaultOptions,
...options,
}
const { baseKeyPrefix } = mergedOptions
const accountKeyPrefix = baseKeyPrefix + mergedOptions.accountKeyPrefix
const accountByUserIdPrefix =
baseKeyPrefix + mergedOptions.accountByUserIdPrefix
const emailKeyPrefix = baseKeyPrefix + mergedOptions.emailKeyPrefix
const sessionKeyPrefix = baseKeyPrefix + mergedOptions.sessionKeyPrefix
const sessionByUserIdKeyPrefix =
baseKeyPrefix + mergedOptions.sessionByUserIdKeyPrefix
const userKeyPrefix = baseKeyPrefix + mergedOptions.userKeyPrefix
const verificationTokenKeyPrefix =
baseKeyPrefix + mergedOptions.verificationTokenKeyPrefix
const setObjectAsJson = async (key: string, obj: any) =>
await client.set(key, JSON.stringify(obj))
const setAccount = async (id: string, account: AdapterAccount) => {
const accountKey = accountKeyPrefix + id
await setObjectAsJson(accountKey, account)
await client.set(accountByUserIdPrefix + account.userId, accountKey)
return account
}
const getAccount = async (id: string) => {
const account = await client.get(accountKeyPrefix + id) as AdapterSession | null
if (!account) return null
return hydrateDates(account)
}
const setSession = async (
id: string,
session: AdapterSession
): Promise<AdapterSession> => {
const sessionKey = sessionKeyPrefix + id
await setObjectAsJson(sessionKey, session)
await client.set(sessionByUserIdKeyPrefix + session.userId, sessionKey)
return session
}
const getSession = async (id: string) => {
const session = await client.get(sessionKeyPrefix + id) as AdapterSession | null
if (!session) return null
return hydrateDates(session)
}
const setUser = async (
id: string,
user: AdapterUser
): Promise<AdapterUser> => {
await setObjectAsJson(userKeyPrefix + id, user)
await client.set(`${emailKeyPrefix}${user.email as string}`, id)
return user
}
const getUser = async (id: string) => {
const user = await client.get(userKeyPrefix + id) as AdapterUser | null
if (!user) return null
return hydrateDates(user)
}
return {
async createUser(user) {
const id = crypto.randomUUID()
// TypeScript thinks the emailVerified field is missing
// but all fields are copied directly from user, so it's there
return await setUser(id, { ...user, id })
},
getUser,
async getUserByEmail(email) {
const userId = await client.get(emailKeyPrefix + email)
if (!userId) {
return null
}
return await getUser(userId)
},
async getUserByAccount(account) {
const dbAccount = await getAccount(
`${account.provider}:${account.providerAccountId}`
)
if (!dbAccount) return null
return await getUser(dbAccount.userId)
},
async updateUser(updates) {
const userId = updates.id as string
const user = await getUser(userId)
return await setUser(userId, { ...(user as AdapterUser), ...updates })
},
async linkAccount(account) {
const id = `${account.provider}:${account.providerAccountId}`
return await setAccount(id, { ...account, id })
},
createSession: (session) => setSession(session.sessionToken, session),
async getSessionAndUser(sessionToken) {
const session = await getSession(sessionToken)
if (!session) return null
const user = await getUser(session.userId)
if (!user) return null
return { session, user }
},
async updateSession(updates) {
const session = await getSession(updates.sessionToken)
if (!session) return null
return await setSession(updates.sessionToken, { ...session, ...updates })
},
async deleteSession(sessionToken) {
await client.del(sessionKeyPrefix + sessionToken)
},
async createVerificationToken(verificationToken) {
await setObjectAsJson(
verificationTokenKeyPrefix +
verificationToken.identifier +
":" +
verificationToken.token,
verificationToken
)
return verificationToken
},
async useVerificationToken(verificationToken) {
const tokenKey =
verificationTokenKeyPrefix +
verificationToken.identifier +
":" +
verificationToken.token
const token = await client.get(tokenKey) as VerificationToken | null
if (!token) return null
await client.del(tokenKey)
return hydrateDates(token)
// return reviveFromJson(token)
},
async unlinkAccount(account) {
const id = `${account.provider}:${account.providerAccountId}`
const dbAccount = await getAccount(id)
if (!dbAccount) return
const accountKey = `${accountKeyPrefix}${id}`
await client.del(
accountKey,
`${accountByUserIdPrefix} + ${dbAccount.userId as string}`
)
},
async deleteUser(userId) {
const user = await getUser(userId)
if (!user) return
const accountByUserKey = accountByUserIdPrefix + userId
const accountKey = await client.get(accountByUserKey)
const sessionByUserIdKey = sessionByUserIdKeyPrefix + userId
const sessionKey = await client.get(sessionByUserIdKey)
await client.del(
userKeyPrefix + userId,
`${emailKeyPrefix}${user.email as string}`,
accountKey as string,
accountByUserKey,
sessionKey as string,
sessionByUserIdKey
)
},
}
}

View File

@@ -1,11 +0,0 @@
export const WG_PATH = '/etc/wireguard'
export const WG_PRE_UP = process.env.WG_PRE_UP || ''
export const WG_POST_UP = process.env.WG_POST_UP || ''
export const WG_PRE_DOWN = process.env.WG_PRE_DOWN || ''
export const WG_POST_DOWN = process.env.WG_POST_DOWN || ''
export const IPV4_REGEX = new RegExp(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)

View File

@@ -1,34 +0,0 @@
import fs from "fs";
import path from "path";
export default class FileManager {
static readDirectoryFiles(dir: string): string[] {
const files_: string[] = [];
const files = fs.readdirSync(dir);
for (const i in files) {
const name = dir + '/' + files[i];
if (!fs.statSync(name).isDirectory()) {
files_.push(path.resolve(process.cwd(), name))
}
}
return files_;
}
static readFile(filePath: string): string {
if (!fs.existsSync(filePath)) {
throw new Error('file not found')
}
return fs.readFileSync(filePath, { encoding: 'utf8' })
}
static writeFile(filePath: string, content: string, forced: boolean = false): void {
const dir_ = filePath.split('/')
const dir = dir_.slice(0, dir_.length - 1).join('/')
if (!fs.existsSync(dir) && forced) {
fs.mkdirSync(dir, { mode: 0o744 })
}
fs.writeFileSync(filePath, content,{ encoding: 'utf8' })
}
}

View File

@@ -1,18 +0,0 @@
import { NameSchema } from "@lib/schemas/WireGuard";
import { zodErrorMessage } from "@lib/zod";
import type { Rule } from "rc-field-form/lib/interface";
export const RLS_NAME_INPUT: Rule[] = [
{
required: true,
message: 'Name is required'
},
{
validator: (_, value) => {
if (!value) return Promise.resolve()
const res = NameSchema.safeParse(value)
if (res.success) return Promise.resolve()
return Promise.reject(zodErrorMessage(res.error)[0])
}
}
]

View File

@@ -1,38 +0,0 @@
import Shell from "@lib/shell";
export default class Network {
public static async createInterface(inet: string, address: string): Promise<boolean> {
// First, check if the interface already exists.
const interfaces = await Shell.exec(`ip link show | grep ${inet}`, true)
if (interfaces.includes(`${inet}`)) {
console.error(`failed to create interface, ${inet} already exists!`)
return false
}
const o2 = await Shell.exec(`ip address add dev ${inet} ${address}`)
// check if it has any error
if (o2 !== '') {
console.error(`failed to assign ip to interface, ${o2}`)
console.log(`removing interface ${inet} due to errors`)
await Shell.exec(`ip link delete dev ${inet}`, true)
return false
}
return true
}
public static async dropInterface(inet: string) {
await Shell.exec(`ip link delete dev ${inet}`, true)
}
public static async defaultInterface(): Promise<string> {
return await Shell.exec(`ip route list default | awk '{print $5}'`)
}
public static async checkInterfaceExists(inet: string): Promise<boolean> {
return await Shell.exec(`ip link show | grep ${inet}`, true)
.then((o) => o.trim() !== '')
}
};

View File

@@ -1,11 +0,0 @@
import IORedis from "ioredis";
export const client = new IORedis({
port: 6479
});
export type RedisClient = typeof client;
export const WG_SEVER_PATH = `WG::SERVERS`

View File

@@ -1,14 +0,0 @@
import { NextApiResponse } from "next";
export default async function safeServe(res: NextApiResponse, fn: () => void): Promise<void> {
return new Promise(() => {
try {
fn()
} catch (e) {
console.error('[SafeServe]: ', e)
return res
.status(500)
.json({ ok: false, details: 'Server Internal Error' })
}
})
}

View File

@@ -1,67 +0,0 @@
import { z } from "zod";
import { IPV4_REGEX } from "@lib/constants";
import { isBetween, isPrivateIP } from "@lib/utils";
export const NameSchema = z
.string()
.nonempty()
.refine((v) => v.length < 32, {
message: 'Name must be less than 32 characters'
})
.refine((v) => v.match(/^[a-zA-Z0-9-_]+$/), {
message: 'Name must only contain alphanumeric characters, dashes, and underscores'
})
export const AddressSchema = z
.string()
.nonempty()
.refine((v) => isPrivateIP(v), {
message: 'Address must be a private IP address'
})
export const PortSchema = z
.string()
.nonempty()
.refine((v) => {
const port = parseInt(v)
return port > 0 && port < 65535
}, {
message: 'Port must be a valid port number'
})
export const TypeSchema = z.enum([ 'direct', 'tor' ])
export const DnsSchema = z
.string()
.regex(IPV4_REGEX, {
message: 'DNS must be a valid IPv4 address'
})
.optional()
export const MtuSchema = z
.string()
.refine((d) => isBetween(d, 1, 1500), {
message: 'MTU must be between 1 and 1500'
})
.optional()
export const ServerId = z
.string()
.uuid({ message: 'Server ID must be a valid UUID' })
export const ClientId = z
.string()
.uuid({ message: 'Client ID must be a valid UUID' })
export const ServerStatusSchema = z
.enum([ 'up', 'down' ], {
errorMap: issue => {
switch (issue.code) {
case 'invalid_type':
case 'invalid_enum_value':
return { message: 'Status must be either "up" or "down"' }
default:
return { message: 'Invalid status' }
}
}
})

View File

@@ -1,10 +0,0 @@
export default class ServerError extends Error {
statusCode
constructor(message: string, statusCode: number = 500) {
super(message);
this.statusCode = statusCode;
}
};

View File

@@ -1,28 +0,0 @@
import childProcess from "child_process";
export default class Shell {
public static async exec(command: string, safe: boolean = false, ...args: string[]): Promise<string> {
if (process.platform !== 'linux') {
throw new Error('This program is not meant to run non UNIX systems');
}
return new Promise(async (resolve, reject) => {
const cmd = `${command}${args.length > 0 ? ` ${args.join(' ')}` : ''}`;
childProcess.exec(
cmd,
{ shell: 'bash' },
(err, stdout, stderr) => {
if (err) {
console.error(
`${safe ? 'Ignored::' : 'CRITICAL::'} Shell Command Failed:`,
JSON.stringify({ cmd, code: err.code, killed: err.killed, stderr })
);
return safe ? resolve(stderr) : reject(err);
}
return resolve(String(stdout).trim());
}
);
});
}
};

View File

@@ -1,42 +0,0 @@
import { APIResponse, Peer, PeerSchema, WgServer, WgServerSchema } from "@lib/typings";
import { zodErrorMessage } from "@lib/zod";
export const UPDATE_SERVER = async (url: string, { arg }: { arg: Partial<WgServer> }) => {
const parsed = WgServerSchema.partial().safeParse(arg)
if (!parsed.success) {
console.error('invalid server schema', zodErrorMessage(parsed.error))
return false
}
const resp = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(arg)
})
const data = await resp.json() as APIResponse<any>
if (!data.ok) throw new Error('Server responded with error status')
return true
}
export const UPDATE_CLIENT = async (url: string, { arg }: { arg: Partial<Peer> }) => {
const parsed = PeerSchema
.partial()
.safeParse(arg)
if (!parsed.success) {
console.error('invalid peer schema', zodErrorMessage(parsed.error))
return false
}
const resp = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(arg)
})
const data = await resp.json() as APIResponse<any>
if (!data.ok) throw new Error('Server responded with error status')
return true
}

View File

@@ -1,87 +0,0 @@
import { z } from "zod";
import type React from "react";
import { IPV4_REGEX } from "@lib/constants";
import { NextApiRequest as TNextApiRequest } from "next/dist/shared/lib/utils";
import { NameSchema } from "@lib/schemas/WireGuard";
export const WgKeySchema = z.object({
privateKey: z.string(),
publicKey: z.string(),
preSharedKey: z.string(),
})
export type WgKey = z.infer<typeof WgKeySchema>
const WgPeerSchema = z.object({
id: z.string().uuid(),
name: z.string().regex(/^[A-Za-z\d\s]{3,32}$/),
preSharedKey: z.string(),
endpoint: z.string(),
address: z.string().regex(IPV4_REGEX),
latestHandshakeAt: z.string().nullable(),
transferRx: z.number().nullable(),
transferTx: z.number().nullable(),
persistentKeepalive: z.number().nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
enabled: z.boolean(),
})
.merge(WgKeySchema)
export type WgPeer = z.infer<typeof WgPeerSchema>
export const PeerSchema = z.object({
id: z.string().uuid(),
name: NameSchema,
preSharedKey: z.string().nullable(),
allowedIps: z.string().regex(IPV4_REGEX),
persistentKeepalive: z.number().nullable(),
})
.merge(WgKeySchema)
export type Peer = z.infer<typeof PeerSchema>
export const WgServerSchema = z.object({
id: z.string().uuid(),
confId: z.number(),
confHash: z.string().nullable(),
type: z.enum([ 'direct', 'bridge', 'tor' ]),
name: NameSchema,
address: z.string().regex(IPV4_REGEX),
listen: z.number(),
preUp: z.string().nullable(),
postUp: z.string().nullable(),
preDown: z.string().nullable(),
postDown: z.string().nullable(),
dns: z.string().regex(IPV4_REGEX).nullable(),
peers: z.array(PeerSchema),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
status: z.enum([ 'up', 'down' ]),
})
.merge(WgKeySchema.omit({ preSharedKey: true }))
export type WgServer = z.infer<typeof WgServerSchema>
export type ReactHTMLProps<T extends HTMLElement> = React.DetailedHTMLProps<React.HTMLAttributes<T>, T>
export type APIErrorResponse = {
ok: false
details: string
}
export type APISuccessResponse<D> = {
ok: true
result: D
}
export type APIResponse<D, N extends boolean = false> = N extends true ? D : APIErrorResponse | APISuccessResponse<D>
export type NextApiRequest<
P = Record<string, string | string[] | undefined>,
Q = Record<string, string | string[] | undefined>
> = Omit<TNextApiRequest, 'query'> & {
query: Partial<Q> & P
}
export type LeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];

View File

@@ -1,45 +0,0 @@
import { IPV4_REGEX } from "@/lib/constants";
export function isValidIPv4(str: string): boolean {
return IPV4_REGEX.test(str);
}
export function isBetween(v: any, n1: number, n2: number): boolean {
if (Number.isNaN(v)) {
return false
}
const n = Number(v)
return n1 <= n && n >= n2
}
export function isJson(str: string | object): boolean {
if (typeof str === 'object' && isObject(str)) {
return true;
}
try {
return typeof str === 'string' && JSON.parse(str);
} catch (ex) {
return false;
}
}
export function isObject(obj: object) {
return Object.prototype.toString.call(obj) === '[object Object]';
}
/**
* Private IP Address Identifier in Regular Expression
*
* 127. 0.0.0 127.255.255.255 127.0.0.0 /8
* 10. 0.0.0 10.255.255.255 10.0.0.0 /8
* 172. 16.0.0 172. 31.255.255 172.16.0.0 /12
* 192.168.0.0 192.168.255.255 192.168.0.0 /16
*/
export function isPrivateIP(ip: string) {
const ipRegex = /^(127\.)|(10\.)|(172\.1[6-9]\.)|(172\.2[0-9]\.)|(172\.3[0-1]\.)|(192\.168\.)/
return ipRegex.test(ip)
}
export function dynaJoin(lines: (string | 0 | null | undefined)[]): string[] {
return lines.filter((d) => typeof d === 'string') as string[]
}

View File

@@ -1,36 +0,0 @@
import { WgServer } from "@lib/typings";
type Peer = WgServer['peers'][0]
interface GenPeerConParams extends Peer, Pick<WgServer, 'dns'> {
serverAddress?: string
serverPublicKey: string
port: number
}
export async function getServerIP(): Promise<string> {
const resp = await fetch('/api/host')
return resp.text()
}
export async function getPeerConf(params: GenPeerConParams): Promise<string> {
const serverAddress = params.serverAddress || await getServerIP()
const lines = [
'# Autogenerated by WireGuard UI (WireAdmin)',
'[Interface]',
`PrivateKey = ${params.privateKey}`,
`Address = ${params.allowedIps}/24`,
`${params.dns ? `DNS = ${params.dns}` : 'OMIT'}`,
'',
'[Peer]',
`PublicKey = ${params.serverPublicKey}`,
`${params.preSharedKey ? `PresharedKey = ${params.preSharedKey}` : 'OMIT'}`,
`AllowedIPs = 0.0.0.0/0, ::/0`,
`PersistentKeepalive = ${params.persistentKeepalive}`,
`Endpoint = ${serverAddress}:${params.port}`,
]
return lines
.filter((l) => l !== 'OMIT')
.join('\n')
}

View File

@@ -1,620 +0,0 @@
import { promises as fs } from "fs";
import path from "path";
import { WG_PATH } from "@lib/constants";
import Shell from "@lib/shell";
import { Peer, WgKey, WgServer } from "@lib/typings";
import { client, WG_SEVER_PATH } from "@lib/redis";
import { dynaJoin, isJson } from "@lib/utils";
import deepmerge from "deepmerge";
import { getPeerConf } from "@lib/wireguard-utils";
import Network from "@lib/network";
import { enc, SHA256 } from "crypto-js";
export class WGServer {
static async stop(id: string): Promise<boolean> {
const server = await findServer(id)
if (!server) {
console.error('server could not be updated (reason: not exists)')
return false
}
if (await Network.checkInterfaceExists(`wg${server.confId}`)) {
await Shell.exec(`wg-quick down wg${server.confId}`, true)
}
await this.update(id, { status: 'down' })
return true
}
static async start(id: string): Promise<boolean> {
const server = await findServer(id)
if (!server) {
console.error('server could not be updated (reason: not exists)')
return false
}
const HASH = await getConfigHash(server.confId);
if (!HASH || server.confHash !== HASH) {
await writeConfigFile(server);
await WGServer.update(id, { confHash: await getConfigHash(server.confId) });
}
if (await Network.checkInterfaceExists(`wg${server.confId}`)) {
await Shell.exec(`wg-quick down wg${server.confId}`, true)
}
await Shell.exec(`wg-quick up wg${server.confId}`)
await this.update(id, { status: 'up' })
return true
}
static async remove(id: string): Promise<boolean> {
const server = await findServer(id)
if (!server) {
console.error('server could not be updated (reason: not exists)')
return false
}
await this.stop(id)
await fs.unlink(path.join(WG_PATH, `wg${server.confId}.conf`)).catch(() => null)
const index = await findServerIndex(id)
if (typeof index !== 'number') {
console.warn('findServerIndex: index not found')
return true
}
const element = await client.lindex(WG_SEVER_PATH, index)
if (!element) {
console.warn('remove: element not found')
return true
}
await client.lrem(WG_SEVER_PATH, 1, element)
return true
}
static async update(id: string, update: Partial<WgServer>): Promise<boolean> {
const server = await findServer(id)
if (!server) {
console.error('server could not be updated (reason: not exists)')
return false
}
const index = await findServerIndex(id)
if (typeof index !== 'number') {
console.warn('findServerIndex: index not found')
return true
}
const res = await client.lset(WG_SEVER_PATH, index, JSON.stringify({
...deepmerge(server, update),
peers: update?.peers || server?.peers || [],
updatedAt: new Date().toISOString()
}))
return res === 'OK'
}
static async findAttachedUuid(confId: number): Promise<string | undefined> {
const server = await getServers()
return server.find((s) => s.confId === confId)?.id
}
static async addPeer(id: string, peer: WgServer['peers'][0]): Promise<boolean> {
const server = await findServer(id)
if (!server) {
console.error('server could not be updated (reason: not exists)')
return false
}
const confPath = path.join(WG_PATH, `wg${server.confId}.conf`)
const conf = await fs.readFile(confPath, 'utf-8')
const lines = conf.split('\n')
lines.push(...dynaJoin([
`[Peer]`,
`PublicKey = ${peer.publicKey}`,
peer.preSharedKey && `PresharedKey = ${peer.preSharedKey}`,
`AllowedIPs = ${peer.allowedIps}/32`,
peer.persistentKeepalive && `PersistentKeepalive = ${peer.persistentKeepalive}`
]))
await fs.writeFile(confPath, lines.join('\n'))
await WGServer.update(id, { confHash: await getConfigHash(server.confId) });
const index = await findServerIndex(id)
if (typeof index !== 'number') {
console.warn('findServerIndex: index not found')
return true
}
await client.lset(WG_SEVER_PATH, index, JSON.stringify({
...server,
peers: [ ...server.peers, peer ]
}))
await this.stop(server.id)
await this.start(server.id)
return true
}
static async removePeer(serverId: string, publicKey: string): Promise<boolean> {
const server = await findServer(serverId)
if (!server) {
console.error('server could not be updated (reason: not exists)')
return false
}
const peers = await wgPeersStr(server.confId)
const index = await findServerIndex(serverId)
if (typeof index !== 'number') {
console.warn('findServerIndex: index not found')
return true
}
await client.lset(WG_SEVER_PATH, index, JSON.stringify({
...server,
peers: server.peers.filter((p) => p.publicKey !== publicKey)
}))
const peerIndex = peers.findIndex((p) => p.includes(`PublicKey = ${publicKey}`))
if (peerIndex === -1) {
console.warn('removePeer: no peer found')
return false
}
const confPath = path.join(WG_PATH, `wg${server.confId}.conf`)
const conf = await fs.readFile(confPath, 'utf-8')
const serverConfStr = conf.includes('[Peer]') ?
conf.split('[Peer]')[0] :
conf
const peersStr = peers.filter((_, i) => i !== peerIndex).join('\n')
await fs.writeFile(confPath, `${serverConfStr}\n${peersStr}`)
await WGServer.update(server.id, { confHash: await getConfigHash(server.confId) });
await WGServer.stop(server.id)
await WGServer.start(server.id)
return true
}
static async updatePeer(serverId: string, publicKey: string, update: Partial<Peer>): Promise<boolean> {
const server = await findServer(serverId)
if (!server) {
console.error('WGServer:UpdatePeer: server could not be updated (Reason: not exists)')
return false
}
const index = await findServerIndex(serverId)
if (typeof index !== 'number') {
console.warn('findServerIndex: index not found')
return true
}
const updatedPeers = server.peers.map((p) => {
if (p.publicKey !== publicKey) return p
return deepmerge(p, update)
})
await client.lset(WG_SEVER_PATH, index, JSON.stringify({ ...server, peers: updatedPeers }))
await this.storePeers({ id: server.id, confId: server.confId }, publicKey, updatedPeers)
await WGServer.stop(serverId)
await WGServer.start(serverId)
return true
}
private static async getPeerIndex(id: string, publicKey: string): Promise<number | undefined> {
const server = await findServer(id)
if (!server) {
console.error('server could not be updated (reason: not exists)')
return undefined
}
return server.peers.findIndex((p) => p.publicKey === publicKey)
}
private static async storePeers(sd: Pick<WgServer, 'id' | 'confId'>, publicKey: string, peers: Peer[]): Promise<void> {
const peerIndex = await this.getPeerIndex(sd.id, publicKey)
if (peerIndex === -1) {
console.warn('WGServer:StorePeers: no peer found')
return
}
const confPath = path.join(WG_PATH, `wg${sd.confId}.conf`)
const conf = await fs.readFile(confPath, 'utf-8')
const serverConfStr = conf.includes('[Peer]') ?
conf.split('[Peer]')[0] :
conf
const peersStr = peers.filter((_, i) => i !== peerIndex).join('\n')
await fs.writeFile(confPath, `${serverConfStr}\n${peersStr}`)
await WGServer.update(sd.id, { confHash: await getConfigHash(sd.confId) });
}
static async getFreePeerIp(id: string): Promise<string | undefined> {
const server = await findServer(id)
if (!server) {
console.error('getFreePeerIp: server not found')
return undefined
}
const reservedIps = server.peers.map((p) => p.allowedIps)
const ips = reservedIps.map((ip) => ip.split('/')[0])
const net = server.address.split('/')[0].split('.')
for (let i = 1; i < 255; i++) {
const ip = `${net[0]}.${net[1]}.${net[2]}.${i}`
if (!ips.includes(ip) && ip !== server.address.split('/')[0]) {
return ip
}
}
console.error('getFreePeerIp: no free ip found')
return undefined
}
static async generatePeerConfig(id: string, peerId: string): Promise<string | undefined> {
const server = await findServer(id)
if (!server) {
console.error('generatePeerConfig: server not found')
return undefined
}
const peer = server.peers.find((p) => p.id === peerId)
if (!peer) {
console.error('generatePeerConfig: peer not found')
return undefined
}
return await getPeerConf({
...peer,
serverPublicKey: server.publicKey,
port: server.listen,
dns: server.dns
})
}
}
/**
* This function is for checking out WireGuard server is running
*/
async function wgCheckout(configId: number): Promise<boolean> {
const res = await Shell.exec(`ip link show | grep wg${configId}`, true)
return res.includes(`wg${configId}`)
}
export async function readWgConf(configId: number): Promise<WgServer> {
const confPath = path.join(WG_PATH, `wg${configId}.conf`)
const conf = await fs.readFile(confPath, 'utf-8')
const lines = conf.split('\n')
const server: WgServer = {
id: crypto.randomUUID(),
confId: configId,
confHash: null,
type: 'direct',
name: '',
address: '',
listen: 0,
dns: null,
privateKey: '',
publicKey: '',
preUp: null,
preDown: null,
postDown: null,
postUp: null,
peers: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: 'down'
}
let reachedPeers = false
for (const line of lines) {
const [ key, value ] = line.split('=').map((s) => s.trim())
if (reachedPeers) {
if (key === '[Peer]') {
server.peers.push({
id: crypto.randomUUID(),
name: `Unknown #${server.peers.length + 1}`,
publicKey: '',
privateKey: '', // it's okay to be empty because, we not using it on server
preSharedKey: '',
allowedIps: '',
persistentKeepalive: null
})
}
if (key === 'PublicKey') {
server.peers[server.peers.length - 1].publicKey = value
}
if (key === 'PresharedKey') {
server.peers[server.peers.length - 1].preSharedKey = value
}
if (key === 'AllowedIPs') {
server.peers[server.peers.length - 1].allowedIps = value
}
if (key === 'PersistentKeepalive') {
server.peers[server.peers.length - 1].persistentKeepalive = parseInt(value)
}
}
if (key === 'PrivateKey') {
server.privateKey = value
}
if (key === 'Address') {
server.address = value
}
if (key === 'ListenPort') {
server.listen = parseInt(value)
}
if (key === 'DNS') {
server.dns = value
}
if (key === 'PreUp') {
server.preUp = value
}
if (key === 'PreDown') {
server.preDown = value
}
if (key === 'PostUp') {
server.postUp = value
}
if (key === 'PostDown') {
server.postDown = value
}
if (key === 'PublicKey') {
server.publicKey = value
}
if (key === '[Peer]') {
reachedPeers = true
}
}
server.status = await wgCheckout(configId) ? 'up' : 'down'
return server
}
/**
* This function checks if a WireGuard config exists in file system
* @param configId
*/
async function wgConfExists(configId: number): Promise<boolean> {
const confPath = path.join(WG_PATH, `wg${configId}.conf`)
try {
await fs.access(confPath)
return true
} catch (e) {
return false
}
}
/**
* Used to read /etc/wireguard/*.conf and sync them with our
* redis server.
*/
async function syncServers(): Promise<boolean> {
// get files in /etc/wireguard
const files = await fs.readdir(WG_PATH)
// filter files that start with wg and end with .conf
const reg = new RegExp(/^wg(\d+)\.conf$/)
const confs = files.filter((f) => reg.test(f))
// read all confs
const servers = await Promise.all(confs.map((f) => readWgConf(parseInt(f.match(reg)![1]))))
// remove old servers
await client.del(WG_SEVER_PATH)
// save all servers to redis
await client.lpush(WG_SEVER_PATH, ...servers.map((s) => JSON.stringify(s)))
return true
}
async function wgPeersStr(configId: number): Promise<string[]> {
const confPath = path.join(WG_PATH, `wg${configId}.conf`)
const conf = await fs.readFile(confPath, 'utf-8')
const rawPeers = conf.split('[Peer]')
return rawPeers.slice(1).map((p) => `[Peer]\n${p}`)
}
export async function generateWgKey(): Promise<WgKey> {
const privateKey = await Shell.exec('wg genkey');
const publicKey = await Shell.exec(`echo ${privateKey} | wg pubkey`);
const preSharedKey = await Shell.exec('wg genkey');
return { privateKey, publicKey, preSharedKey }
}
export async function generateWgServer(config: {
name: string
address: string
type: WgServer['type']
port: number
dns?: string
mtu?: number
insertDb?: boolean
}): Promise<string> {
const { privateKey, publicKey } = await generateWgKey();
// inside redis create a config list
const confId = await maxConfId() + 1
const uuid = crypto.randomUUID()
let server: WgServer = {
id: uuid,
confId,
confHash: null,
type: config.type,
name: config.name,
address: config.address,
listen: config.port,
dns: config.dns || null,
privateKey,
publicKey,
preUp: null,
preDown: null,
postDown: null,
postUp: null,
peers: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: 'up'
}
// check if address or port are already reserved
const [ addresses, ports ] = (await getServers())
.map((s) => [ s.address, s.listen ])
// check for the conflict
if (Array.isArray(addresses) && addresses.includes(config.address)) {
throw new Error(`Address ${config.address} is already reserved!`)
}
if (Array.isArray(ports) && ports.includes(config.port)) {
throw new Error(`Port ${config.port} is already reserved!`)
}
// setting iptables
const iptables = await makeWgIptables(server)
server.postUp = iptables.up
server.postDown = iptables.down
// save server config
if (false !== config.insertDb) {
await client.lpush(WG_SEVER_PATH, JSON.stringify(server))
}
const CONFIG_PATH = path.join(WG_PATH, `wg${confId}.conf`)
// save server config to disk
await fs.writeFile(CONFIG_PATH, await genServerConf(server), { mode: 0o600 })
// updating hash of the config
await WGServer.update(uuid, { confHash: await getConfigHash(confId) });
// to ensure interface does not exists
await Shell.exec(`wg-quick down wg${confId}`, true)
// restart WireGuard
await Shell.exec(`wg-quick up wg${confId}`)
// return server id
return uuid
}
export async function getConfigHash(confId: number): Promise<string | undefined> {
if (!await wgConfExists(confId)) {
return undefined
}
const confPath = path.join(WG_PATH, `wg${confId}.conf`)
const conf = await fs.readFile(confPath, 'utf-8')
return enc.Hex.stringify(SHA256(conf));
}
export async function writeConfigFile(wg: WgServer): Promise<void> {
const CONFIG_PATH = path.join(WG_PATH, `wg${wg.confId}.conf`)
await fs.writeFile(CONFIG_PATH, await genServerConf(wg), { mode: 0o600 })
await WGServer.update(wg.id, { confHash: await getConfigHash(wg.confId) });
}
export async function maxConfId(): Promise<number> {
// get files in /etc/wireguard
const files = await fs.readdir(WG_PATH)
// filter files that start with wg and end with .conf
const reg = new RegExp(/^wg(\d+)\.conf$/)
const confs = files.filter((f) => reg.test(f))
const ids = confs.map((f) => {
const m = f.match(reg)
if (m) {
return parseInt(m[1])
}
return 0
})
return Math.max(0, ...ids)
}
export async function getServers(): Promise<WgServer[]> {
return (await client.lrange(WG_SEVER_PATH, 0, -1)).map((s) => JSON.parse(s))
}
export async function findServerIndex(id: string): Promise<number | undefined> {
let index = 0;
const servers = await getServers()
for (const s of servers) {
if (s.id === id) {
return index
}
index++
}
return undefined
}
export async function findServer(id: string | undefined, hash?: string): Promise<WgServer | undefined> {
const servers = await getServers()
return id ?
servers.find((s) => s.id === id) :
hash && isJson(hash) ? servers.find((s) => JSON.stringify(s) === hash) :
undefined
}
export async function makeWgIptables(s: WgServer): Promise<{ up: string, down: string }> {
const inet = await Network.defaultInterface()
const inet_address = await Shell.exec(`hostname -i | awk '{print $1}'`)
const source = `${s.address}/24`
const wg_inet = `wg${s.confId}`
if (s.type === 'direct') {
const up = dynaJoin([
`iptables -t nat -A POSTROUTING -s ${source} -o ${inet} -j MASQUERADE`,
`iptables -A INPUT -p udp -m udp --dport ${s.listen} -j ACCEPT`,
`iptables -A INPUT -p tcp -m tcp --dport ${s.listen} -j ACCEPT`,
`iptables -A FORWARD -i ${wg_inet} -j ACCEPT`,
`iptables -A FORWARD -o ${wg_inet} -j ACCEPT`,
]).join('; ')
return { up, down: up.replace(/ -A /g, ' -D ') }
}
if (s.type === 'tor') {
const up = dynaJoin([
`iptables -A INPUT -m state --state ESTABLISHED -j ACCEPT`,
`iptables -A INPUT -i ${wg_inet} -s ${source} -m state --state NEW -j ACCEPT`,
`iptables -t nat -A PREROUTING -i ${wg_inet} -p udp -s ${source} --dport 53 -j DNAT --to-destination ${inet_address}:53530`,
`iptables -t nat -A PREROUTING -i ${wg_inet} -p tcp -s ${source} -j DNAT --to-destination ${inet_address}:9040`,
`iptables -t nat -A PREROUTING -i ${wg_inet} -p udp -s ${source} -j DNAT --to-destination ${inet_address}:9040`,
`iptables -t nat -A OUTPUT -o lo -j RETURN`,
`iptables -A OUTPUT -m conntrack --ctstate INVALID -j DROP`,
`iptables -A OUTPUT -m state --state INVALID -j DROP`,
`iptables -A OUTPUT ! -o lo ! -d 127.0.0.1 ! -s 127.0.0.1 -p tcp -m tcp --tcp-flags ACK,FIN ACK,FIN -j DROP`,
]).join('; ')
return { up, down: up.replace(/-A/g, '-D') }
}
return { up: '', down: '' }
}
export async function genServerConf(server: WgServer): Promise<string> {
const iptables = await makeWgIptables(server)
server.postUp = iptables.up
server.postDown = iptables.down
const lines = [
'# Autogenerated by WireGuard UI (WireAdmin)',
'[Interface]',
`PrivateKey = ${server.privateKey}`,
`Address = ${server.address}/24`,
`ListenPort = ${server.listen}`,
`${server.dns ? `DNS = ${server.dns}` : 'OMIT'}`,
'',
`${server.preUp ? `PreUp = ${server.preUp}` : 'OMIT'}`,
`${server.postUp ? `PostUp = ${server.postUp}` : 'OMIT'}`,
`${server.preDown ? `PreDown = ${server.preDown}` : 'OMIT'}`,
`${server.postDown ? `PostDown = ${server.postDown}` : 'OMIT'}`,
]
server.peers.forEach((peer, index) => {
lines.push('')
lines.push(`## ${peer.name || `Peer #${index + 1}`}`)
lines.push('[Peer]')
lines.push(`PublicKey = ${peer.publicKey}`)
lines.push(`${peer.preSharedKey ? `PresharedKey = ${peer.preSharedKey}` : 'OMIT'}`)
lines.push(`AllowedIPs = ${peer.allowedIps}/32`)
lines.push(`${peer.persistentKeepalive ? `PersistentKeepalive = ${peer.persistentKeepalive}` : 'OMIT'}`)
})
return lines
.filter((l) => l !== 'OMIT')
.join('\n')
}

View File

@@ -1,20 +0,0 @@
import { NextApiResponse } from "next";
import { ZodError } from "zod";
export function zodErrorToResponse(res: NextApiResponse, z: ZodError) {
return res
.status(400)
.json({
ok: false,
message: 'Bad Request',
details: zodErrorMessage(z)
})
}
export function zodEnumError(message: string) {
return { message }
}
export function zodErrorMessage(ze: ZodError): string[] {
return ze.errors.map((e) => e.message)
}

View File

@@ -1,18 +0,0 @@
import { withAuth } from "next-auth/middleware";
// More on how NextAuth.js middleware works: https://next-auth.js.org/configuration/nextjs#middleware
export default withAuth({
callbacks: {
authorized({ token }) {
return !!token
},
},
});
// See "Matching Paths" below to learn more
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matching-paths
export const config = {
matcher: [
'/((?!api/auth|api/healthcheck|_next/static|_next/image|login|logo.png|fonts|favicon.ico).*)',
],
};

5
src/next-env.d.ts vendored
View File

@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -1,18 +0,0 @@
function publicENV(ex = {}) {
Object.entries(process.env)
.filter(([ key ]) => key.startsWith('NEXT_PUBLIC_'))
.forEach(([ key, value ]) => ex[key] = value)
return ex
}
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: [],
env: publicENV({}),
images: {
domains: [ 'img.shields.io' ]
}
}
module.exports = nextConfig;

3612
src/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +0,0 @@
{
"name": "web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"packageManager": "npm@9.7.2",
"workspaces": [
"packages/*"
],
"dependencies": {
"antd": "5.10.2",
"clsx": "^2.0.0",
"crypto-js": "^4.2.0",
"deepmerge": "^4.3.1",
"dotenv": "16.3.1",
"ioredis": "5.3.2",
"next": "13.5.6",
"next-auth": "4.24.3",
"qrcode": "^1.5.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"sharp": "^0.32.6",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@next/eslint-plugin-next": "13.5.6",
"@types/crypto-js": "^4.1.3",
"@types/node": "20.8.9",
"@types/react": "18.2.33",
"@types/react-dom": "18.2.14",
"autoprefixer": "10.4.16",
"postcss": "8.4.31",
"tailwindcss": "3.3.5",
"tsconfig": "*",
"typescript": "5.2.2"
}
}

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Default",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": true,
"moduleResolution": "node",
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true
},
"exclude": ["node_modules"]
}

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Next.js",
"extends": "./base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"allowJs": true,
"declaration": false,
"declarationMap": false,
"incremental": true,
"jsx": "preserve",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"noEmit": true,
"resolveJsonModule": true,
"strict": false,
"target": "es5",
},
"include": ["src", "next-env.d.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,9 +0,0 @@
{
"name": "tsconfig",
"version": "0.0.0",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
}
}

View File

@@ -1,11 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "React Library",
"extends": "./base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2015", "DOM"],
"module": "ESNext",
"target": "es6"
}
}

View File

@@ -1,368 +0,0 @@
import { Button, Card, List } from "antd";
import BasePage from "@ui/pages/BasePage";
import PageRouter from "@ui/pages/PageRouter";
import React from "react";
import { PlusOutlined } from "@ant-design/icons";
import useSWR from "swr";
import { APIResponse, Peer, WgServer } from "@lib/typings";
import useSWRMutation from "swr/mutation";
import { useRouter } from "next/router";
import { MiddleEllipsis } from "@ui/MiddleEllipsis";
import StatusBadge from "@ui/StatusBadge";
import { SmartModalRef } from "@ui/modal/SmartModal";
import CreateClientModal from "@ui/modal/CreateClientModal";
import { twMerge } from "tailwind-merge";
import QRCodeModal from "@ui/modal/QRCodeModal";
import { getPeerConf } from "@lib/wireguard-utils";
import EditableText from "@ui/EditableText";
import { RLS_NAME_INPUT } from "@lib/form-rules";
import { UPDATE_CLIENT } from "@lib/swr-fetch";
import CopiableWrapper from "@ui/CopiableWrapper";
export async function getServerSideProps(context: any) {
return {
props: {
serverId: context.params.serverId
}
}
}
type PageProps = {
serverId: string
}
export default function ServerPage(props: PageProps) {
const router = useRouter()
const createClientRef = React.useRef<SmartModalRef | null>(null)
const { data, error, isLoading, mutate: refresh } = useSWR(
`/api/wireguard/${props.serverId}`,
async (url: string) => {
const resp = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
})
if (resp.status === 404) {
router.replace('/').catch()
return false
}
const data = await resp.json() as APIResponse<WgServer>
if (!data.ok) throw new Error('Server responded with error status')
return data.result
}
)
const { isMutating: isChangingStatus, trigger: changeStatus } = useSWRMutation(
`/api/wireguard/${props.serverId}`,
async (url: string, { arg }: { arg: string }) => {
const resp = await fetch(url, {
method: arg === 'remove' ? 'DELETE' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: arg === 'remove' ? undefined : JSON.stringify({ status: arg })
})
if (resp.status === 404) {
router.replace('/').catch()
return false
}
const data = await resp.json() as APIResponse<any>
if (!data.ok) throw new Error('Server responded with error status')
return true
},
{
onSuccess: async () => await refresh(),
onError: async () => await refresh(),
}
)
const lastChangeStatus = React.useRef<string | null>(null)
return (
<BasePage>
<CreateClientModal
ref={createClientRef}
serverId={props.serverId}
refreshTrigger={() => refresh()}
/>
<PageRouter
route={[
{ title: data ? data.name.toString() : 'LOADING...' }
]}
/>
{error || (!isLoading && !data) ? (
<Card className={'flex items-center justify-center p-4'}>
! ERROR !
</Card>
) : isLoading ? (
<Card className={'flex items-center justify-center p-4'}>
Loading...
</Card>
) : data && (
<div className={'space-y-4'}>
<Card className={'[&>.ant-card-body]:max-md:p1-2'}>
<List>
<Row label={'IP address'}>
<pre> {data.address}/24 </pre>
</Row>
<Row label={'Listen Port'}>
<pre> {data.listen} </pre>
</Row>
<Row label={'Status'}>
<StatusBadge status={data.status} />
</Row>
<Row label={'Public Key'}>
<CopiableWrapper content={data.publicKey}>
<MiddleEllipsis
content={data.publicKey}
maxLength={16}
/>
</CopiableWrapper>
</Row>
</List>
<div className={'flex flex-wrap items-center gap-2 mt-6'}>
{data.status === 'up' ? (
<React.Fragment>
<Button
className={'max-md:col-span-12'}
loading={isChangingStatus && lastChangeStatus.current === 'restart'}
disabled={isChangingStatus}
onClick={() => changeStatus('restart')}
> Restart </Button>
<Button
danger={true}
className={'max-md:col-span-12'}
loading={isChangingStatus && lastChangeStatus.current === 'stop'}
disabled={isChangingStatus}
onClick={() => changeStatus('stop')}
> Stop </Button>
</React.Fragment>
) : (
<React.Fragment>
<Button
type={'primary'}
className={'max-md:col-span-12 bg-green-500'}
loading={isChangingStatus && lastChangeStatus.current === 'start'}
disabled={isChangingStatus}
onClick={() => changeStatus('start')}
> Start </Button>
<Button
danger={true}
className={'max-md:col-span-12'}
loading={isChangingStatus && lastChangeStatus.current === 'remove'}
disabled={isChangingStatus}
onClick={() => {
changeStatus('remove').finally(() => {
lastChangeStatus.current = null
router.replace('/').catch()
})
}}
> Remove </Button>
</React.Fragment>
)}
</div>
</Card>
<Card
className={'[&>.ant-card-body]:p-0'}
title={(
<div className={'flex items-center justify-between'}>
<span> Clients </span>
{data && data.peers.length > 0 && (
<div>
<Button
type={'primary'}
icon={<PlusOutlined />}
onClick={() => createClientRef.current?.open()}
>
Add a client
</Button>
</div>
)}
</div>
)}
>
{data && data.peers.length > 0 ? (
<List>
{data.peers.map((s) => (
<Client
key={s.id}
{...s}
serverId={props.serverId}
serverPublicKey={data?.publicKey}
dns={data?.dns}
listenPort={data?.listen}
refreshTrigger={() => refresh()}
/>
))}
</List>
) : (
<div className={'flex flex-col items-center justify-center gap-y-4 py-8'}>
<p className={'text-gray-400 text-md'}>
There are no clients yet!
</p>
<Button type={'primary'} icon={<PlusOutlined />} onClick={() => createClientRef.current?.open()}>
Add a client
</Button>
</div>
)}
</Card>
</div>
)}
</BasePage>
);
}
interface ClientProps extends Peer, Pick<WgServer, 'dns'> {
serverId: string
serverPublicKey: string
listenPort: number
refreshTrigger: () => void
}
function Client(props: ClientProps) {
const qrcodeRef = React.useRef<SmartModalRef | null>(null)
const [ conf, setConf ] = React.useState<string | null>(null)
React.useEffect(() => {
getPeerConf({
...props,
serverPublicKey: props.serverPublicKey,
port: props.listenPort,
dns: props.dns,
})
.then((s) => setConf(s))
}, [ props ])
const RefreshOptions = {
onSuccess: () => props.refreshTrigger(),
onError: () => props.refreshTrigger()
}
const { isMutating: removingClient, trigger: removeClient } = useSWRMutation(
`/api/wireguard/${props.serverId}/${props.id}`,
async (url: string,) => {
const resp = await fetch(url, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
})
const data = await resp.json() as APIResponse<any>
if (!data.ok) throw new Error('Server responded with error status')
return true
},
RefreshOptions
)
const { isMutating, trigger } = useSWRMutation(
`/api/wireguard/${props.serverId}/${props.id}`,
UPDATE_CLIENT,
RefreshOptions
)
return (
<List.Item key={props.id} className={'flex items-center justify-between p-4'}>
<QRCodeModal ref={qrcodeRef} content={conf?.trim() || 'null'} />
<div className={'w-full flex flex-row items-center gap-x-2'}>
<div
className={'w-12 aspect-square flex items-center justify-center mr-4 rounded-full bg-gray-200 max-md:hidden'}>
<i className={'fas fa-user text-gray-400 text-lg'} />
</div>
<div className={'flex flex-col items-start justify-between'}>
<EditableText
disabled={isMutating}
rules={RLS_NAME_INPUT}
rootClassName={'font-medium col-span-4'}
inputClassName={'w-20'}
content={props.name}
onChange={(v) => trigger({ name: v })}
/>
<CopiableWrapper content={props.allowedIps} className={'text-sm'} showInHover={true}>
<span className={'font-mono text-gray-400 text-xs'}> {props.allowedIps} </span>
</CopiableWrapper>
</div>
</div>
<div className={'flex items-center justify-center gap-x-3'}>
{/* QRCode */}
<ClientBaseButton disabled={removingClient} onClick={() => {
qrcodeRef.current?.open()
}}>
<i className={'fal text-neutral-700 group-hover:text-primary fa-qrcode'} />
</ClientBaseButton>
{/* Download */}
<ClientBaseButton disabled={removingClient} onClick={() => {
if (!conf) {
console.error('conf is null')
return
}
console.log('conf', conf)
// create a blob
const blob = new Blob([ conf ], { type: 'text/plain' })
// create a link
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = `${props.name}.conf`
// click the link
link.click()
// remove the link
link.remove()
}}>
<i className={'fal text-neutral-700 group-hover:text-primary fa-download'} />
</ClientBaseButton>
{/* Remove */}
<ClientBaseButton loading={removingClient} onClick={() => removeClient()}>
<i className={'fal text-neutral-700 group-hover:text-primary text-lg fa-trash-can'} />
</ClientBaseButton>
</div>
</List.Item>
)
}
function ClientBaseButton(props: {
onClick: () => void
loading?: boolean
disabled?: boolean
children: React.ReactNode
}) {
return (
<div
className={twMerge(
'group flex items-center justify-center w-10 aspect-square rounded-md',
'bg-gray-200/80 hover:bg-gray-100/50',
'border border-transparent hover:border-primary',
'transition-colors duration-200 ease-in-out',
'cursor-pointer',
props.disabled && 'opacity-50 cursor-not-allowed',
props.loading && 'animate-pulse'
)}
onClick={props.onClick}
>
{props.children}
</div>
)
}
function Row(props: {
label: string
children: React.ReactNode
}) {
return (
<List.Item className={'flex flex-wrap items-center gap-2 leading-none relative overflow-ellipsis'}>
<div className={'flex items-center text-gray-400 text-sm col-span-12 md:col-span-3'}>
{props.label}
</div>
<div className={'flex items-center gap-x-2 col-span-12 md:col-span-9'}>
{props.children}
</div>
</List.Item>
)
}

View File

@@ -1,22 +0,0 @@
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import { SessionProvider } from "next-auth/react";
import type { ThemeConfig } from "antd";
import { ConfigProvider } from "antd";
const theme: ThemeConfig = {
token: {
fontSize: 16,
colorPrimary: '#991b1b',
},
};
export default function App({ Component, pageProps: { session, ...pageProps } }: AppProps) {
return (
<SessionProvider session={session}>
<ConfigProvider theme={theme}>
<Component {...pageProps} />
</ConfigProvider>
</SessionProvider>
);
}

View File

@@ -1,15 +0,0 @@
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang="en">
<Head>
<link rel={'stylesheet'} href={'/fonts/fontawesome/index.css'} />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}

View File

@@ -1,42 +0,0 @@
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import "dotenv/config";
export default NextAuth({
providers: [
// Credentials-based authentication providers
// https://next-auth.js.org/configuration/providers#credentials-based-authentication-providers
CredentialsProvider({
name: 'Credentials',
credentials: {
password: { label: "Password", type: "password" },
},
async authorize(_, request) {
const { password } = request.query || {}
const { HASHED_PASSWORD } = process.env
if (
// Skip if no password is set
!HASHED_PASSWORD ||
// Accept if password was matching
password && Buffer.from(password).toString('hex').toLowerCase() === HASHED_PASSWORD.toLowerCase()
) {
return { id: crypto.randomUUID() }
}
// Reject the rest
return null
}
}),
],
pages: {
signIn: '/login'
},
session: {
// Seconds - How long until an idle session expires and is no longer valid.
maxAge: 24 * 60 * 60, // 24 hours
// Seconds - Throttle how frequently to write to database to extend a session.
// Use it to limit write operations. Set to 0 to always update the database.
// Note: This option is ignored if using JSON Web Tokens
updateAge: 60 * 60, // 1 hour
}
});

View File

@@ -1,29 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import safeServe from "@lib/safe-serve";
import { getConfigHash, getServers, WGServer } from "@lib/wireguard";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
const servers = await getServers()
for (const s of servers) {
const HASH = await getConfigHash(s.confId);
if (s.confId && HASH && s.confHash === HASH) {
// Skip, due to no changes on the config
continue;
}
// Start server
if (s.status === 'up') {
await WGServer.start(s.id);
}
}
return res
.status(200)
.end('OK')
})
}

View File

@@ -1,31 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import safeServe from "@lib/safe-serve";
import Shell from "@lib/shell";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
let { WG_HOST } = process.env
// if the host is not set, then we are using the server's public IP
if (!WG_HOST) {
const resp = await Shell.exec('curl -s ifconfig.me', true)
WG_HOST = resp.trim()
}
// check if WG_HOST is still not set
if (!WG_HOST) {
console.error('WG_HOST is not set')
return res
.status(500)
.setHeader('Content-Type', 'text/plain')
.end('NOT_SET')
}
return res
.status(200)
.setHeader('Content-Type', 'text/plain')
.end(WG_HOST)
})
}

View File

@@ -1,18 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { client } from "@/lib/redis";
import safeServe from "@lib/safe-serve";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
// create a list for storing the last 10 pings
await client.lpush("pings", Date.now().toString())
await client.ltrim("pings", 0, 9)
const pings = await client.lrange("pings", 0, -1)
return res
.status(200)
.json({ message: 'Pong!', pings })
})
}

View File

@@ -1,47 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import safeServe from "@lib/safe-serve";
import { zodErrorToResponse } from "@lib/zod";
import { z } from "zod";
import { findServer, WGServer } from "@lib/wireguard";
import { ServerId } from "@lib/schemas/WireGuard";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
if (req.method !== 'GET') {
return res
.status(400)
.json({ ok: false, details: 'Method not allowed' })
}
const parsed = RequestSchema.safeParse(req.query)
if (!parsed.success) {
return zodErrorToResponse(res, parsed.error)
}
const { serverId, clientId } = req.query as z.infer<typeof RequestSchema>
const server = await findServer(serverId)
if (!server) {
return res
.status(404)
.json({ ok: false, message: 'Not Found' })
}
const conf = await WGServer.generatePeerConfig(server.id, clientId)
if (!conf) {
return res
.status(500)
.json({ ok: false, message: 'Server Internal Error' })
}
return res
.status(200)
.end(conf)
})
}
const RequestSchema = z.object({
serverId: ServerId,
clientId: z.string().uuid()
})

View File

@@ -1,37 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import safeServe from "@lib/safe-serve";
import { zodErrorToResponse } from "@lib/zod";
import { z } from "zod";
import { findServer } from "@lib/wireguard";
import { AddressSchema, ServerId } from "@lib/schemas/WireGuard";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
if (req.method !== 'GET') {
return res
.status(400)
.json({ ok: false, details: 'Method not allowed' })
}
const parsed = RequestSchema.safeParse(req.query)
if (!parsed.success) {
return zodErrorToResponse(res, parsed.error)
}
const { serverId, clientId } = req.query as z.infer<typeof RequestSchema>
const server = await findServer(serverId)
if (!server) {
return res
.status(404)
.json({ ok: false, message: 'Not Found' })
}
})
}
const RequestSchema = z.object({
serverId: ServerId,
clientId: AddressSchema
})

View File

@@ -1,92 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import safeServe from "@lib/safe-serve";
import { findServer, WGServer } from "@lib/wireguard";
import { z } from "zod";
import { ClientId, NameSchema, ServerId } from "@lib/schemas/WireGuard";
import { WgServer } from "@lib/typings";
import { zodErrorToResponse } from "@lib/zod";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
const parsed = RequestSchema.safeParse(req.query)
if (!parsed.success) {
return zodErrorToResponse(res, parsed.error)
}
const { serverId, clientId } = req.query as z.infer<typeof RequestSchema>
const server = await findServer(serverId)
if (!server) {
return res
.status(404)
.json({ ok: false, message: 'Not Found' })
}
const peer = server.peers.find((p) => p.id === clientId)
if (!peer) {
return res
.status(404)
.json({ ok: false, message: 'Not Found' })
}
if (req.method === 'GET') {
return res
.status(200)
.json({ ok: true, result: peer })
}
if (req.method === 'PUT') {
return await update(server, peer, req, res)
}
if (req.method === 'DELETE') {
return await remove(server, peer, req, res)
}
return res
.status(400)
.json({ ok: false, details: 'Method not allowed' })
})
}
const RequestSchema = z.object({
serverId: ServerId,
clientId: ClientId
})
type Peer = WgServer['peers'][0]
async function update(server: WgServer, peer: Peer, req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
const parsed = PutRequestSchema.safeParse(req.body)
if (!parsed.success) {
return zodErrorToResponse(res, parsed.error)
}
const { name } = req.body as z.infer<typeof PutRequestSchema>
if (name) {
await WGServer.updatePeer(server.id, peer.publicKey, { name })
}
return res
.status(200)
.json({ ok: true })
})
}
const PutRequestSchema = z.object({
name: NameSchema.optional()
})
async function remove(server: WgServer, peer: Peer, req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
await WGServer.removePeer(server.id, peer.publicKey)
return res
.status(200)
.json({ ok: true })
})
}

View File

@@ -1,74 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import safeServe from "@lib/safe-serve";
import { findServer, generateWgKey, WGServer } from "@lib/wireguard";
import { z } from "zod";
import { NameSchema, ServerId } from "@lib/schemas/WireGuard";
import { zodErrorToResponse } from "@lib/zod";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
if (req.method !== 'POST') {
return res
.status(400)
.json({ ok: false, details: 'Method not allowed' })
}
const parsed = RequestQuerySchema.safeParse(req.query)
if (!parsed.success) {
return zodErrorToResponse(res, parsed.error)
}
const bodyParsed = RequestBodySchema.safeParse(req.body)
if (!bodyParsed.success) {
return zodErrorToResponse(res, bodyParsed.error)
}
const { name } = req.body as z.infer<typeof RequestBodySchema>
const { serverId } = req.query as z.infer<typeof RequestQuerySchema>
const server = await findServer(serverId)
if (!server) {
return res
.status(404)
.json({ ok: false, message: 'Not Found' })
}
const freeAddress = await WGServer.getFreePeerIp(server.id)
if (!freeAddress) {
return res
.status(400)
.json({ ok: false, details: 'No free addresses' })
}
const peerKeys = await generateWgKey()
const addedPeer = await WGServer.addPeer(server.id, {
id: crypto.randomUUID(),
name,
allowedIps: freeAddress,
publicKey: peerKeys.publicKey,
privateKey: peerKeys.privateKey,
preSharedKey: peerKeys.preSharedKey,
persistentKeepalive: 0,
})
if (!addedPeer) {
return res
.status(500)
.json({ ok: false, details: 'Failed to add peer' })
}
return res
.status(200)
.json({ ok: true })
})
}
const RequestQuerySchema = z.object({
serverId: ServerId,
})
const RequestBodySchema = z.object({
name: NameSchema,
})

View File

@@ -1,103 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import safeServe from "@lib/safe-serve";
import { findServer, WGServer } from "@lib/wireguard";
import { z } from "zod";
import { NameSchema, ServerId } from "@lib/schemas/WireGuard";
import { WgServer } from "@lib/typings";
import { zodEnumError, zodErrorToResponse } from "@lib/zod";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
const parsed = RequestSchema.safeParse(req.query)
if (!parsed.success) {
return zodErrorToResponse(res, parsed.error)
}
const { serverId } = req.query as z.infer<typeof RequestSchema>
const server = await findServer(serverId)
if (!server) {
return res
.status(404)
.json({ ok: false, message: 'Not Found' })
}
if (req.method === 'GET') {
return res
.status(200)
.json({ ok: true, result: server })
}
if (req.method === 'PUT') {
return await update(server, req, res)
}
if (req.method === 'DELETE') {
return await remove(server, req, res)
}
return res
.status(400)
.json({ ok: false, details: 'Method not allowed' })
})
}
const RequestSchema = z.object({
serverId: ServerId
})
async function update(server: WgServer, req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
const parsed = PutRequestSchema.safeParse(req.body)
if (!parsed.success) {
return zodErrorToResponse(res, parsed.error)
}
const { status, name } = req.body as z.infer<typeof PutRequestSchema>
switch (status) {
case 'start':
await WGServer.start(server.id)
break;
case 'stop':
await WGServer.stop(server.id)
break;
case 'restart':
await WGServer.stop(server.id)
await WGServer.start(server.id)
break;
}
if (name) {
await WGServer.update(server.id, { name })
}
return res
.status(200)
.json({ ok: true })
})
}
const PutRequestSchema = z.object({
name: NameSchema.optional(),
status: z
.enum(
[ 'start', 'stop', 'restart' ],
{ errorMap: () => zodEnumError('Invalid status') }
)
.optional(),
})
async function remove(server: WgServer, req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
await WGServer.remove(server.id)
return res
.status(200)
.json({ ok: true })
})
}

View File

@@ -1,42 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import safeServe from "@lib/safe-serve";
import { findServer, WGServer } from "@lib/wireguard";
import { z } from "zod";
import { ServerId } from "@lib/schemas/WireGuard";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
if (req.method !== 'GET') {
return res
.status(400)
.json({ ok: false, details: 'Method not allowed' })
}
const { serverId } = req.query as z.infer<typeof RequestSchema>
const server = await findServer(serverId)
if (!server) {
return res
.status(404)
.json({ ok: false, message: 'Not Found' })
}
const updated = await WGServer.stop(serverId)
if (!updated) {
return res
.status(500)
.json({ ok: false, details: 'Server Internal Error' })
}
return res
.status(200)
.json({ ok: true })
})
}
const RequestSchema = z.object({
serverId: ServerId
})

View File

@@ -1,47 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import safeServe from "@lib/safe-serve";
import { z } from "zod";
import { AddressSchema, DnsSchema, MtuSchema, NameSchema, PortSchema, TypeSchema } from "@lib/schemas/WireGuard";
import { generateWgServer } from "@lib/wireguard";
import { zodErrorToResponse } from "@lib/zod";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
if (req.method !== 'POST') {
return res
.status(400)
.json({ ok: false, details: 'Method not allowed' })
}
const parsed = RequestSchema.safeParse(req.body)
if (!parsed.success) {
return zodErrorToResponse(res, parsed.error)
}
const { name, address, type, port, dns = null, mtu = 1420 } = req.body
const serverId = await generateWgServer({
name,
address,
port,
type,
mtu,
dns
})
return res
.status(200)
.json({ ok: true, result: serverId })
})
}
const RequestSchema = z.object({
name: NameSchema,
address: AddressSchema,
port: PortSchema,
type: TypeSchema,
dns: DnsSchema,
mtu: MtuSchema
})

View File

@@ -1,23 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import safeServe from "@lib/safe-serve";
import { getServers } from "@lib/wireguard";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
if (req.method !== 'GET') {
return res
.status(400)
.json({ ok: false, details: 'Method not allowed' })
}
return res
.status(200)
.json({
ok: true,
result: (await getServers()).map((s) => s)
})
})
}

View File

@@ -1,38 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import safeServe from "@lib/safe-serve";
import { client, WG_SEVER_PATH } from "@lib/redis";
import fs from "fs";
import Shell from "@lib/shell";
import path from "path";
import { WG_PATH } from "@lib/constants";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => {
if (req.method !== 'GET') {
return res
.status(400)
.json({ ok: false, details: 'Method not allowed' })
}
// delete all servers
await client.del(WG_SEVER_PATH)
// delete every file in /etc/wireguard
fs.readdirSync(WG_PATH).forEach((file) => {
const reg = new RegExp(/^wg(\d+)\.conf$/)
const m = file.match(reg)
if (m) {
const confId = parseInt(m[1])
Shell.exec(`ip link set down dev wg${confId}`).catch()
fs.unlinkSync(path.join(WG_PATH, file))
}
})
return res
.status(200)
.json({ ok: true })
})
}

View File

@@ -1,134 +0,0 @@
import React from "react";
import { Button, Card, List } from "antd";
import BasePage from "@ui/pages/BasePage";
import { APIResponse, WgServer } from "@lib/typings";
import { PlusOutlined } from "@ant-design/icons";
import Link from "next/link";
import PageRouter from "@ui/pages/PageRouter";
import useSWR from "swr";
import { SmartModalRef } from "@ui/modal/SmartModal";
import CreateServerModal from "@ui/modal/CreateServerModal";
import StatusBadge from "@ui/StatusBadge";
import EditableText from "@ui/EditableText";
import useSWRMutation from "swr/mutation";
import { UPDATE_SERVER } from "@lib/swr-fetch";
import { RLS_NAME_INPUT } from "@lib/form-rules";
import CopiableWrapper from "@ui/CopiableWrapper";
import ServerIcon from "@ui/icons/ServerIcon";
export default function Home() {
const { data, error, isLoading, mutate } = useSWR(
'/api/wireguard/listServers',
async (url: string) => {
const resp = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
})
const data = await resp.json() as APIResponse<WgServer[]>
if (!data.ok) throw new Error('Server responded with error status')
return data.result
}
)
const createServerRef = React.useRef<SmartModalRef | null>(null)
return (
<BasePage>
<PageRouter className={'flex items-center justify-between'}>
<h2 className={'font-bold text-xl'}> Hello there 👋 </h2>
{data && data.length > 0 && (
<Button type={'default'} icon={<PlusOutlined />} onClick={() => createServerRef.current?.open()}>
New server
</Button>
)}
</PageRouter>
<CreateServerModal ref={createServerRef} refreshTrigger={() => mutate()} />
<div className={'space-y-4'}>
{error ? (
<Card className={'flex items-center justify-center p-4'}>
! ERROR !
</Card>
) : isLoading ? (
<Card className={'flex items-center justify-center p-4'}>
Loading...
</Card>
) : Array.isArray(data) && data.length > 0 ? (
<Card
className={'[&>.ant-card-body]:p-0'}
title={<span> Servers </span>}
>
<List>
{data.map((s) => (
<ServerListItem
{...s}
key={s.id}
refreshTrigger={() => mutate()}
/>
))}
</List>
</Card>
) : (
<Card>
<div className={'flex flex-col items-center justify-center gap-y-4 py-8'}>
<p className={'text-gray-400 text-md'}>
There are no servers yet!
</p>
<Button type={'primary'} icon={<PlusOutlined />} onClick={() => createServerRef.current?.open()}>
Add a server
</Button>
</div>
</Card>
)}
</div>
</BasePage>
);
}
interface ServerListItemProps extends WgServer {
refreshTrigger: () => void
}
function ServerListItem(props: ServerListItemProps) {
const { isMutating, trigger } = useSWRMutation(
`/api/wireguard/${props.id}`,
UPDATE_SERVER,
{
onSuccess: () => props.refreshTrigger(),
onError: () => props.refreshTrigger(),
}
)
return (
<List.Item className={'flex items-center justify-between p-4'}>
<div className={'w-full grid grid-cols-8 md:grid-cols-12 items-center gap-x-1'}>
<ServerIcon type={props.type} className={'max-md:hidden md:col-span-1'} />
<div className={'flex flex-col justify-between col-span-4'}>
<EditableText
disabled={isMutating}
rules={RLS_NAME_INPUT}
rootClassName={'font-medium'}
inputClassName={'w-full max-w-[120px]'}
content={props.name}
onChange={(v) => trigger({ name: v })}
/>
<CopiableWrapper
content={`${props.address}:${props.listen}`}
className={'text-sm'}
rootClassName={'mt-0.5'}
showInHover={true}
>
<span className={'font-mono text-gray-400 text-xs'}> {props.address}:{props.listen} </span>
</CopiableWrapper>
</div>
<div className={'col-span-4 justify-end'}>
<StatusBadge status={props.status} />
</div>
</div>
<Link href={`/${props.id}`}>
<Button type={'primary'}>
Manage
</Button>
</Link>
</List.Item>
)
}

View File

@@ -1,61 +0,0 @@
import { signIn } from "next-auth/react";
import PageFooter from "@ui/pages/PageFooter";
import React from "react";
import Image from "next/image";
import { Button, Form, Input } from "antd";
import { useRouter } from "next/router";
export default function LoginPage() {
const router = useRouter()
const [ form ] = Form.useForm()
async function handleFinish({ password }: { password: string | undefined }) {
if (!password) {
return form.resetFields()
}
await signIn(
'credentials',
{ redirect: false },
{ password }
)
await router.push('/')
}
return (
<div className={'w-full min-h-screen flex justify-center px-2 md:px-6 py-2'}>
<div className={'w-full mx-auto max-w-3xl flex flex-col items-center gap-y-3.5'}>
<header className={'flex items-center gap-x-2 text-3xl font-medium py-4'}>
<Image
src={'/logo.png'}
alt={'WireAdmin'}
width={40}
height={40}
/>
<h1> WireAdmin </h1>
</header>
<main className={'py-4'}>
<Form
form={form}
onFinish={handleFinish}
rootClassName={'bg-white rounded-lg shadow-sm'}
className={'p-4 space-y-8'}
>
<div className={'flex items-center justify-center'}>
<div className={'w-16 aspect-square flex items-center justify-center rounded-full bg-gray-200'}>
<i className={'fas fa-user text-primary text-2xl'} />
</div>
</div>
<Form.Item name={'password'}>
<Input placeholder={'Password...'} />
</Form.Item>
<Button block={true} onClick={form.submit}> Sign In </Button>
</Form>
</main>
<PageFooter />
</div>
</div>
);
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,99 +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">
<meta name="csrf-token" content="KVk0SCZ2CVA7excpJHEWdT0CIQYhLxMaq-ZxeGh6ZLCnV6Z1zEcRXaY-">
<link rel="icon" href="/favicon.ico">
<link rel="icon" href="/images/favicon/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/images/favicon/apple-touch-icon.png">
<link rel="manifest" href="/manifest/app.json">
<meta name="theme-color" content="#528DD7">
<title>Font Awesome</title>
<meta id="meta-application-name" name="application-name" content="Font Awesome" />
<meta id="meta-description" name="description" content="The worlds most popular and easiest to use icon set just got an upgrade. More icons. More styles. More Options." />
<meta id="meta-keywords" name="keywords" content="icons, vector icons, svg icons, free icons, icon font, webfont, desktop icons, svg, font awesome, font awesome free, font awesome pro" />
<!-- Schema.org markup for Google+ -->
<meta id="meta-item-name" itemprop="name" content="Font Awesome" />
<meta id="meta-item-description" itemprop="description" content="The worlds most popular and easiest to use icon set just got an upgrade. More icons. More styles. More Options." />
<meta id="meta-item-image" itemprop="image" content="https://img.fortawesome.com/349cfdf6/fontawesome-open-graph.png" />
<!-- Twitter Card data -->
<meta id="twt-card" name="twitter:card" content="summary" />
<meta id="twt-site" name="twitter:site" content="@fontawesome" />
<meta id="twt-title" name="twitter:title" content="Font Awesome" />
<meta id="twt-description" name="twitter:description" content="The worlds most popular and easiest to use icon set just got an upgrade. More icons. More styles. More Options." />
<meta id="twt-creator" name="twitter:creator" content="@fontawesome" />
<meta id="twt-image" name="twitter:image" content="https://img.fortawesome.com/349cfdf6/fontawesome-open-graph.png" />
<!-- Open Graph data -->
<meta id="og-title" property="og:title" content="Font Awesome" />
<meta id="og-type" property="og:type" content="website" />
<meta id="og-url" property="og:url" content="https://fontawesome.com" />
<meta id="og-image" property="og:image" content="https://img.fortawesome.com/349cfdf6/fontawesome-open-graph.png" />
<meta id="og-description" property="og:description" content="The worlds most popular and easiest to use icon set just got an upgrade. More icons. More styles. More Options." />
<link
rel="stylesheet"
data-purpose="Layout StyleSheet"
title="Default"
href="/css/app-eebc29a5e707f565ed420d151441665a.css?vsn=d"
>
<link
rel="stylesheet"
data-purpose="Layout StyleSheet"
title="Web Awesome"
disabled
href="/css/app-wa-b464d3417b7a8fcc0b8bfe9cf7d4bc92.css?vsn=d"
>
<link
rel="stylesheet"
disabled
href="https://pro.fontawesome.com/releases/v6.0.0-beta3/css/all.css"
>
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-30136587-4"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'UA-30136587-4', { cookie_flags: 'max-age=7200;secure;samesite=none' });
</script>
<link rel="preload" href="/js/settings-445a010757cbad60e82500f2dc6c83dc.js?vsn=d" as="script">
<link rel="preload" href="/js/app-8be57055a5918601da69ea599f03aee0.js?vsn=d" as="script">
<script defer src="/js/settings-445a010757cbad60e82500f2dc6c83dc.js?vsn=d"></script>
<script defer src="/js/app-8be57055a5918601da69ea599f03aee0.js?vsn=d"></script>
<script type="text/javascript">!function(e,t,n){function a(){var e=t.getElementsByTagName("script")[0],n=t.createElement("script");n.type="text/javascript",n.async=!0,n.src="https://beacon-v2.helpscout.net",e.parentNode.insertBefore(n,e)}if(e.Beacon=n=function(t,n,a){e.Beacon.readyQueue.push({method:t,options:n,data:a})},n.readyQueue=[],"complete"===t.readyState)return a();e.attachEvent?e.attachEvent("onload",a):e.addEventListener("load",a,!1)}(window,document,window.Beacon||function(){});</script>
<script type="text/javascript">
window.Beacon('init', '8b4d2c82-4277-4380-9212-e4e7f03c1ea4');
window.Beacon('config', {display: {style: 'manual'}})
</script>
</head>
<body class="min-vh-100 bg-gray0 gray7 ma0 overflow-x-hidden">
<div id="vue-container">
</div>
<div id="modal"></div>
<div id="shade"></div>
<script>
window.__inline_data__ = [{"data":[{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Early Access","percentage-complete":100,"updated-at":"2017-08-02T21:36:24","view-order":1},"id":"1","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Core Font Awesome 4 Icons Redesigned","percentage-complete":100,"updated-at":"2017-08-02T21:36:24","view-order":2},"id":"2","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Icons in SVG Format","percentage-complete":100,"updated-at":"2017-12-06T15:32:04","view-order":3},"id":"3","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2018-03-29T14:07:48","name":"Revamped Icon Font Framework","percentage-complete":100,"updated-at":"2018-07-24T14:40:06","view-order":4},"id":"14","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"SVG Icon Framework","percentage-complete":100,"updated-at":"2017-12-06T15:32:16","view-order":5},"id":"4","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Regular and Light Icon Styles","percentage-complete":100,"updated-at":"2017-08-02T21:36:24","view-order":6},"id":"5","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Icon Font Ligatures","percentage-complete":100,"updated-at":"2018-07-24T14:43:16","view-order":7},"id":"7","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Dedicated CDN","percentage-complete":100,"updated-at":"2018-07-24T14:43:27","view-order":8},"id":"10","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Duotone Colored Icons","percentage-complete":100,"updated-at":"2018-07-24T14:45:51","view-order":9},"id":"6","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Desktop Icon Subsetter","percentage-complete":100,"updated-at":"2018-07-24T14:45:29","view-order":10},"id":"8","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Community Voting","percentage-complete":75,"updated-at":"2018-07-24T14:44:56","view-order":11},"id":"11","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2018-03-29T14:07:48","name":"iOS and Android Support","percentage-complete":0,"updated-at":"2018-07-24T14:46:05","view-order":12},"id":"15","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Desktop Design Plugins","percentage-complete":0,"updated-at":"2018-07-24T14:46:20","view-order":13},"id":"9","links":{"self":"/api/product-updates"},"type":"product-update"}],"jsonapi":{"version":"1.0"}}]
</script>
<script src="https://use.fortawesome.com/349cfdf6.js"></script>
<script defer src="https://m.servedby-buysellads.com/monetization.js"></script>
<script defer src="https://js.stripe.com/v3/"></script>
<script defer src="https://www.google.com/recaptcha/api.js?render=6Lfwy8YZAAAAAOymsOdsZ7xDAG-TFKW_fij1Wnjg"></script>
<script defer src="https://embed.typeform.com/embed.js"></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,18 +0,0 @@
<svg width="32" height="32" version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient x1="50%" y1="100%" x2="50%" y2="0%" id="linearGradient-1">
<stop stop-color="#420C5D" offset="0%"></stop>
<stop stop-color="#951AD1" offset="100%"></stop>
</linearGradient>
</defs>
<g transform="translate(-58.12 -303.3)" fill="url(#linearGradient-1)" fill-rule="evenodd">
<path d="m77.15 303.3c-1.608 1.868-0.09027 2.972-0.9891 4.84 1.514-2.129 5.034-2.862 7.328-3.643-3.051 2.72-5.457 6.326-8.489 9.009l-1.975-0.8374c-0.4647-4.514-1.736-4.705 4.125-9.369z"
fill-rule="evenodd" />
<path d="m74.04 313.1 2.932 0.9454c-0.615 2.034 0.3559 2.791 0.9472 3.123 1.324 0.7332 2.602 1.49 3.619 2.412 1.916 1.75 3.004 4.21 3.004 6.812 0 2.578-1.183 5.061-3.169 6.717-1.868 1.561-4.446 2.223-6.953 2.223-1.561 0-2.956-0.0708-4.47-0.5677-3.453-1.159-6.031-4.115-6.244-7.663-0.1893-2.767 0.4257-4.872 2.578-7.072 1.111-1.159 2.563-2.749 4.1-3.813 0.757-0.5204 1.119-1.191-0.4183-3.958l1.28-1.076 2.795 1.918-2.352-0.3007c0.1656 0.2366 1.189 0.7706 1.284 1.078 0.2128 0.8751-0.1911 1.771-0.3804 2.149-0.9696 1.75-1.86 2.275-3.066 3.268-2.129 1.75-4.27 2.836-4.01 7.637 0.1183 2.365 1.433 5.295 4.2 6.643 1.561 0.757 2.859 1.189 4.68 1.284 1.632 0.071 4.754-0.8988 6.457-2.318 1.821-1.514 2.838-3.808 2.838-6.149 0-2.365-0.9461-4.612-2.72-6.197-1.017-0.9223-2.696-2.034-3.737-2.625-1.041-0.5912-2.782-2.06-2.356-3.645z" />
<path d="m73.41 316.6c-0.186 1.088-0.4177 3.117-0.8909 3.917-0.3293 0.5488-0.4126 0.8101-0.7846 1.094-1.09 1.535-1.45 1.761-2.132 4.552-0.1447 0.5914-0.3832 1.516-0.2591 2.107 0.372 1.703 0.6612 2.874 1.316 4.103 0 0 0.1271 0.1217 0.1271 0.169 0.6821 0.9225 0.6264 1.05 2.665 2.246l-0.06204 0.3313c-1.55-0.4729-2.604-0.9591-3.41-2.024 0-0.0236-0.1513-0.1558-0.1513-0.1558-0.868-1.135-1.753-2.788-2.021-4.546-0.1447-0.7097-0.0769-1.341 0.08833-2.075 0.7026-2.885 1.415-4.093 2.744-5.543 0.3514-0.2601 0.6704-0.6741 1.001-1.092 0.4859-0.6764 1.462-2.841 1.814-4.189z" />
<path d="m74.09 318.6c0.0237 1.04 0.0078 3.036 0.3389 3.796 0.0945 0.2599 0.3274 1.414 0.9422 2.794 0.4258 0.96 0.5418 1.933 0.6128 2.193 0.2838 1.14-0.4002 3.086-0.8734 4.906-0.2364 0.98-0.6051 1.773-1.371 2.412l0.2796 0.3593c0.5204-0.02 1.954-1.096 2.403-2.416 0.757-2.24 1.328-3.317 0.9729-5.797-0.0473-0.2402-0.2094-1.134-0.6588-2.014-0.6622-1.34-1.474-2.614-1.592-2.874-0.213-0.4198-1.007-2.119-1.054-3.359z" />
<path d="m74.88 313.9 0.9727 0.4962c-0.09145 0.6403 0.04572 2.059 0.686 2.424 2.836 1.761 5.512 3.683 6.565 5.604 3.751 6.771-2.63 13.04-8.143 12.44 2.996-2.219 4.428-6.583 3.307-11.55-0.4574-1.944-1.729-3.893-2.987-5.883-0.545-0.9768-0.3547-2.188-0.4006-3.538z"
fill-rule="evenodd" />
<rect x="73.07" y="312.8" width="1" height="22" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="36px" height="36px" viewBox="0 0 36 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 62 (91390) - https://sketch.com -->
<title>vps</title>
<desc>Created with Sketch.</desc>
<g id="vps" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M33,26 L33,31 C33,32.6568542 31.6568542,34 30,34 L6,34 C4.34314575,34 3,32.6568542 3,31 L3,26 L33,26 Z M30,29 L6,29 L6,31 L30,31 L30,29 Z M33,15 L33,23 L3,23 L3,15 L33,15 Z M30,18 L6,18 L6,20 L30,20 L30,18 Z M30,4 C31.6568542,4 33,5.34314575 33,7 L33,12 L3,12 L3,7 C3,5.34314575 4.34314575,4 6,4 L30,4 Z M30,7 L6,7 L6,9 L30,9 L30,7 Z" fill="#991b1b"></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 779 B

View File

@@ -1,62 +0,0 @@
@tailwind base;
@layer base {
button:not(:not(.ant-btn-primary)),
[type='button']:not(:not(.ant-btn-primary)),
[type='reset']:not(:not(.ant-btn-primary)),
[type='submit']:not(:not(.ant-btn-primary))
{
background-color: var(--primary-color);
}
}
@layer base {
h1 {
@apply text-4xl font-bold;
}
h2 {
@apply text-3xl font-bold;
}
h3 {
@apply text-2xl font-semibold;
}
h4 {
@apply text-xl font-semibold;
}
h5 {
@apply text-lg font-medium;
}
h6 {
@apply text-base font-medium;
}
a {
text-decoration: none;
transition: color 0.2s ease-in-out;
}
}
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-rgb: 249, 250, 251;
--primary-color: theme('colors.red.800');
}
body {
color: rgb(var(--foreground-rgb));
background-color: rgb(var(--background-rgb));
}

View File

@@ -1,57 +0,0 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: 'class',
important: true,
content: [
'./ui/**/*.{js,ts,jsx,tsx,mdx}',
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}'
],
theme: {
extend: {
screens: {
'xs': '375px',
'2xl': '1440px',
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
colors: {
primary: {
DEFAULT: '#88161a'
},
transparent: 'transparent',
},
fontSize: {
'3xs': '.5rem',
'2xs': '.625rem',
'sm/2': '.8125rem',
'md': '.9375rem',
},
fontWeight: {
'thin': '100',
'light': '300',
'normal': '400',
'medium': '500',
'semibold': '600',
'bold': '700',
'extrabold': '800',
'black': '900',
},
zIndex: {
'-100': '100',
'-1000': '1000',
'-10000': '10000',
'100': '100',
'1000': '1000',
'10000': '10000',
}
},
},
plugins: [],
}
export default config

View File

@@ -1,14 +0,0 @@
{
"extends": "tsconfig/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@lib/*": ["./lib/*"],
"@ui/*": ["./ui/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,40 +0,0 @@
import React from "react";
import { ReactHTMLProps } from "@lib/typings";
import { message } from "antd";
import { twMerge } from "tailwind-merge";
export interface CopiableWrapperProps extends Omit<ReactHTMLProps<HTMLSpanElement>, 'content'> {
rootClassName?: string
content: string | number
showInHover?: boolean
}
export default function CopiableWrapper(props: CopiableWrapperProps) {
const {
content,
children,
rootClassName,
className,
showInHover = false,
...rest
} = props
const [ messageApi, contextHolder ] = message.useMessage()
return (
<div className={twMerge('group flex items-center', rootClassName)}>
{contextHolder}
{children}
<i {...rest}
className={twMerge(
'ml-2 mb-0.5 far fa-copy cursor-pointer text-gray-400/80 hover:text-primary',
showInHover && 'group-hover:opacity-100 opacity-0',
className
)}
onClick={() => {
navigator.clipboard.writeText(content.toString())
.then(() => messageApi.success('Copied!'))
.catch()
}}
/>
</div>
)
}

View File

@@ -1,86 +0,0 @@
import React from "react";
import type { ReactHTMLProps } from "@lib/typings";
import { Form, Input, InputRef } from "antd";
import { twMerge } from "tailwind-merge";
import type { SizeType } from "antd/lib/config-provider/SizeContext";
import type { Rule } from "rc-field-form/lib/interface";
export interface EditableTextProps extends Omit<ReactHTMLProps<HTMLSpanElement>, 'onChange' | 'children'> {
content?: string
rootClassName?: string
inputClassName?: string
inputWidth?: string | number | undefined
inputSize?: SizeType
rules?: Rule[]
onChange?: (val: string) => void
disabled?: boolean
}
export default function EditableText(props: EditableTextProps) {
const {
rootClassName,
disabled,
inputClassName,
rules,
inputSize,
inputWidth,
onChange,
content,
...rest
} = props
const [ editMode, setEditMode ] = React.useState(false)
const inputRef = React.useRef<InputRef | null>(null)
const [ val, setVal ] = React.useState(content)
React.useEffect(() => {
const { input } = inputRef.current || {}
if (input) {
input.value = val || ''
}
}, [ val ])
const [ form ] = Form.useForm()
return (
<div className={twMerge('group block', rootClassName)}>
<span {...rest} className={twMerge(
editMode ? 'hidden' : 'flex items-center gap-x-2',
'leading-none'
)}>
{val}
<i
className={'fal fa-pen-to-square text-sm opacity-0 group-hover:opacity-100 text-neutral-400 hover:text-primary cursor-pointer'}
onClick={() => setEditMode(true)}
/>
</span>
<Form
rootClassName={twMerge(editMode ? 'block' : 'hidden')}
form={form}
onFinish={() => {
setEditMode(false)
const newVal = inputRef.current?.input?.value || ''
onChange && onChange(newVal)
setVal(newVal)
}}
>
<Form.Item
name={'input'}
rules={rules}
rootClassName={'m-0'}
className={'[&>.ant-row>.ant-col>div>.ant-form-item-explain]:hidden'}
>
<Input
disabled={disabled}
size={inputSize || 'small'}
ref={inputRef}
style={{ width: inputWidth }}
defaultValue={val}
className={inputClassName}
onKeyDown={async (evt) => {
if (evt.key === 'Enter') {
form.submit()
}
}}
/>
</Form.Item>
</Form>
</div>
)
}

View File

@@ -1,33 +0,0 @@
import React from "react";
import { twMerge } from "tailwind-merge";
import { ReactHTMLProps } from "@lib/typings";
export interface MiddleEllipsisProps extends ReactHTMLProps<HTMLSpanElement> {
content: string
maxLength: number
rootClassName?: string
}
export function MiddleEllipsis(props: MiddleEllipsisProps) {
const { content, maxLength, className, rootClassName, ...rest } = props
const [ leftL, rightL ] = React.useMemo(() => {
const left = Math.floor(maxLength / 2)
const right = Math.ceil(maxLength / 2)
return [ left, right ]
}, [ maxLength ])
const [ left, right ] = React.useMemo(() => {
if (content?.length <= maxLength) return [ content, '' ]
return [ content.slice(0, leftL), content.slice(content?.length - rightL) ]
}, [ content, leftL, rightL ])
return (
<span {...rest} className={rootClassName}>
{left}
<span className={twMerge('text-gray-400', className)}> ... </span>
{right}
</span>
)
}

View File

@@ -1,19 +0,0 @@
import { Badge } from "antd";
import React from "react";
import { BadgeProps } from "antd/es/badge";
export interface StatusBadgeProps extends Omit<BadgeProps, 'status'> {
status: 'up' | 'down'
}
export default function StatusBadge(props: StatusBadgeProps) {
const { status,...rest } = props
return (
<Badge
size={'small'}
color={status === 'up' ? 'rgb(82, 196, 26)' : 'rgb(255, 77, 79)'}
text={status === 'up' ? 'Running' : 'Stopped'}
{...rest}
/>
)
}

View File

@@ -1,36 +0,0 @@
import { WgServer } from "@lib/typings";
import { twMerge } from "tailwind-merge";
import Image from "next/image";
import React from "react";
export interface ServerIconProps {
type: WgServer['type']
className?: string
}
export default function ServerIcon(props: ServerIconProps) {
return (
<div className={twMerge('flex items-start', props.className)}>
<div className={'w-fit h-full relative'}>
<Image
src={'/vps.29373866.svg'}
alt={'VPS'}
width={40}
height={40}
/>
{props.type !== 'direct' && (
<div className={'absolute -bottom-1 -right-2 rounded-full bg-white'}>
{props.type === 'tor' && (
<Image
src={'/tor-onion.svg'}
alt={'Tor'}
width={20}
height={20}
/>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,16 +0,0 @@
import Image, { ImageProps } from "next/image";
import { twMerge } from "tailwind-merge";
import React from "react";
export default function TorOnion(props: Omit<ImageProps, 'src' | 'alt'>) {
return (
<Image
width={20}
height={20}
{...props}
alt={'Tor'}
className={twMerge('inline-block', props.className)}
src={'/tor-onion.svg'}
/>
)
}

View File

@@ -1,108 +0,0 @@
import React from "react";
import SmartModal, { SmartModalRef } from "@ui/modal/SmartModal";
import { Button, Form, Input, notification } from "antd";
import { z } from "zod";
import { APIResponse } from "@lib/typings";
import useSWRMutation from "swr/mutation";
import { NameSchema } from "@lib/schemas/WireGuard";
import { zodErrorMessage } from "@lib/zod";
import { RLS_NAME_INPUT } from "@lib/form-rules";
type CreateClientModalProps = {
serverId: string
refreshTrigger: () => void
}
const CreateClientModal = React.forwardRef<
SmartModalRef,
CreateClientModalProps
>((props, ref) => {
const [ notificationApi, contextHolder ] = notification.useNotification()
const innerRef = React.useRef<SmartModalRef | null>(null)
const [ form ] = Form.useForm()
React.useImperativeHandle(ref, () => innerRef.current as SmartModalRef)
React.useEffect(() => {
form?.resetFields()
}, [])
const { isMutating, trigger } = useSWRMutation(
`/api/wireguard/${props.serverId}/createClient`,
async (url: string, { arg }: { arg: FormValues }) => {
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(arg)
})
const data = await resp.json() as APIResponse<any>
if (!data.ok) throw new Error('Server responded with error status')
return true
},
{
onSuccess: () => {
props.refreshTrigger()
notificationApi.success({
message: 'Success',
description: 'Client has been created!'
})
innerRef.current?.close()
form?.resetFields()
},
onError: () => {
props.refreshTrigger()
notificationApi.error({
message: 'Error',
description: 'Failed to create Client'
})
}
}
)
const onFinish = (values: Record<string, string | undefined>) => {
if (isMutating) return
const parsed = FormSchema.safeParse(values)
if (!parsed.success) {
console.error(zodErrorMessage(parsed.error))
return;
}
trigger(values as FormValues).catch()
}
return (
<SmartModal
ref={innerRef}
title={null}
footer={null}
rootClassName={'w-full max-w-[340px]'}
>
{contextHolder}
<h4 className={'mb-6'}> Create Client </h4>
<Form form={form} onFinish={onFinish}>
<Form.Item name={'name'} label={'Name'} rules={RLS_NAME_INPUT}>
<Input placeholder={'Unicorn 🦄'} />
</Form.Item>
<Button type={'primary'} htmlType={'submit'} className={'w-full'} loading={isMutating}>
Create
</Button>
</Form>
</SmartModal>
)
})
export default CreateClientModal
const FormSchema = z.object({
name: NameSchema
})
type FormValues = z.infer<typeof FormSchema>

View File

@@ -1,207 +0,0 @@
import React from "react";
import SmartModal, { SmartModalRef } from "@ui/modal/SmartModal";
import { Button, Form, Input, notification, Segmented } from "antd";
import { z } from "zod";
import { APIResponse } from "@lib/typings";
import useSWRMutation from "swr/mutation";
import { isPrivateIP } from "@lib/utils";
import { AddressSchema, DnsSchema, MtuSchema, NameSchema, PortSchema, TypeSchema } from "@lib/schemas/WireGuard";
import { zodErrorMessage } from "@lib/zod";
import TorOnion from "@ui/icons/TorOnionIcon";
type CreateServerModalProps = {
refreshTrigger: () => void
}
const CreateServerModal = React.forwardRef<
SmartModalRef,
CreateServerModalProps
>((props, ref) => {
const [ notificationApi, contextHolder ] = notification.useNotification()
const innerRef = React.useRef<SmartModalRef | null>(null)
const [ form ] = Form.useForm()
const [ type, setType ] = React.useState<ServerType>('direct')
React.useImperativeHandle(ref, () => innerRef.current as SmartModalRef)
React.useEffect(() => {
form?.resetFields()
}, [])
const { isMutating, trigger } = useSWRMutation(
'/api/wireguard/createServer',
async (url: string, { arg }: { arg: FormValues }) => {
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(arg)
})
const data = await resp.json() as APIResponse<any>
if (!data.ok) throw new Error('Server responded with error status')
props.refreshTrigger()
return data.result
},
{
onSuccess: () => {
notificationApi.success({
message: 'Success',
description: 'Server has been created!'
})
innerRef.current?.close()
form?.resetFields()
},
onError: () => {
notificationApi.error({
message: 'Error',
description: 'Failed to create server'
})
}
}
)
const onFinish = (values: Record<string, string | undefined>) => {
if (isMutating) return
const data = { ...values, type }
const parsed = FormSchema.safeParse(data)
if (!parsed.success) {
console.error(zodErrorMessage(parsed.error))
return;
}
trigger(data as FormValues)
}
return (
<SmartModal
ref={innerRef}
title={null}
footer={null}
rootClassName={'w-full max-w-[340px]'}
>
{contextHolder}
<h4 className={'mb-6'}> Create Server </h4>
<Form form={form} onFinish={onFinish}>
<Form.Item name={'name'} label={'Name'} rules={[
{
required: true,
message: 'Name is required'
},
{
validator: (_, value) => {
if (!value) return Promise.resolve()
const res = NameSchema.safeParse(value)
if (res.success) return Promise.resolve()
return Promise.reject(zodErrorMessage(res.error)[0])
}
}
]}>
<Input placeholder={'Kitty World'} />
</Form.Item>
<Form.Item name={'address'} label={'Address'} rules={[
{
required: true,
message: 'Address is required'
},
{
validator: (_, value) => {
if (value && !isPrivateIP(value)) {
return Promise.reject('Address must be a private IP address')
}
return Promise.resolve()
}
}
]}>
<Input placeholder={'10.0.0.1'} />
</Form.Item>
<Form.Item name={'port'} label={'Port'} rules={[
{
required: true,
message: 'Port is required'
},
{
validator: (_, value) => {
const port = parseInt(value || '')
if (port > 0 && port < 65535) {
return Promise.resolve()
}
return Promise.reject('Port must be a valid port number')
}
}
]}>
<Input placeholder={'51820'} />
</Form.Item>
<Form.Item label={'Server Mode'}>
<Segmented
className={'select-none'}
defaultValue={type}
onChange={(v) => setType(v as any)}
options={[
{ label: 'Direct', value: 'direct', icon: <i className={'fal fa-arrows-left-right-to-line'} /> },
{ label: 'Tor', value: 'tor', icon: <TorOnion width={18} height={18} /> }
]}
/>
</Form.Item>
<Form.Item name={'dns'} label={'DNS'} rules={[
{
validator: (_, value) => {
if (!value) return Promise.resolve()
const res = DnsSchema.safeParse(value)
if (res.success) return Promise.resolve()
return Promise.reject(zodErrorMessage(res.error)[0])
}
}
]}>
<Input placeholder={'dns.google'} />
</Form.Item>
<Form.Item name={'mtu'} label={'MTU'} rules={[
{
validator: (_, value) => {
if (!value) return Promise.resolve()
const res = MtuSchema.safeParse(value)
if (res.success) return Promise.resolve()
return Promise.reject(zodErrorMessage(res.error)[0])
}
}
]}>
<Input placeholder={'1420'} />
</Form.Item>
<Button
type={'primary'}
htmlType={'submit'}
className={'w-full'}
loading={isMutating}
>
Create
</Button>
</Form>
</SmartModal>
)
})
export default CreateServerModal
const FormSchema = z.object({
name: NameSchema,
address: AddressSchema,
port: PortSchema,
type: TypeSchema,
dns: DnsSchema,
mtu: MtuSchema
})
type ServerType = z.infer<typeof TypeSchema>
type FormValues = z.infer<typeof FormSchema>

View File

@@ -1,39 +0,0 @@
import React from "react";
import SmartModal, { SmartModalRef } from "@ui/modal/SmartModal";
import { QRCodeCanvas } from "qrcode.react";
import { SHA1 } from "crypto-js";
type QRCodeModalProps = {
content: string
}
const QRCodeModal = React.forwardRef<
SmartModalRef,
QRCodeModalProps
>((props, ref) => {
const innerRef = React.useRef<SmartModalRef | null>(null)
React.useImperativeHandle(ref, () => innerRef.current as SmartModalRef)
return (
<SmartModal
key={SHA1(props.content || '').toString()}
ref={innerRef}
title={null}
footer={null}
className={'flex items-center justify-center'}
>
<div className={'flex items-center justify-center p-5'}>
<QRCodeCanvas
size={256}
level={'M'}
value={props.content || ''}
/>
</div>
</SmartModal>
)
})
export default QRCodeModal

View File

@@ -1,33 +0,0 @@
import React from "react";
import { Modal, type ModalProps } from "antd";
export type SmartModalProps = ModalProps
export type SmartModalRef = {
open: () => void
close: () => void
toggle: () => void
}
const SmartModal = React.forwardRef<SmartModalRef, SmartModalProps>((props, ref) => {
const [ visible, setVisible ] = React.useState<boolean>(false)
React.useImperativeHandle(ref, () => ({
open: () => setVisible(true),
close: () => setVisible(false),
toggle: () => setVisible(!visible)
}))
return (
<Modal
onCancel={() => setVisible(false)}
onOk={() => setVisible(!visible)}
{...props}
open={visible}
/>
)
})
export default SmartModal

View File

@@ -1,32 +0,0 @@
import React from "react";
import { twMerge } from "tailwind-merge";
import clsx from "clsx";
import PageHeader from "@ui/pages/PageHeader";
import PageFooter from "@ui/pages/PageFooter";
export type BasePageProps = {
rootClassName?: string
className?: string
children: React.ReactNode
}
export default function BasePage(props: BasePageProps): React.ReactElement {
return (
<div className={clsx(
'w-full min-h-screen flex justify-center',
'px-2 md:px-6 py-2'
)}>
<div className={twMerge(
'w-full mx-auto max-w-3xl',
'space-y-3.5',
props.rootClassName
)}>
<PageHeader />
<main className={twMerge('py-2', props.className)}>
{props.children}
</main>
<PageFooter />
</div>
</div>
)
}

View File

@@ -1,30 +0,0 @@
import Link from "next/link";
import { ReactHTMLProps } from "@lib/typings";
export type PageFooterProps = {}
export default function PageFooter(props: PageFooterProps) {
return (
<footer className={'flex items-center justify-center'}>
<Link
href={'https://github.com/shahradelahi'}
title={'Find me on Github'}
className={'px-2 font-medium text-gray-400/80 hover:text-gray-500 text-xs'}
>
Made by <span className={'font-medium'}> Shahrad Elahi </span>
</Link>
<DotDivider className={'font-bold text-gray-400'} />
<Link
href={'https://github.com/shahradelahi/wireadmin'}
title={'Github'}
className={'px-2 font-medium text-gray-400/80 hover:text-gray-500 text-xs'}
>
Github
</Link>
</footer>
)
}
function DotDivider(props: ReactHTMLProps<HTMLSpanElement>) {
return <span {...props}> · </span>
}

View File

@@ -1,36 +0,0 @@
import Image from "next/image";
import Link from "next/link";
export type PageHeaderProps = {}
export default function PageHeader(props: PageHeaderProps) {
return (
<header className={'w-full py-3 px-2'}>
<nav className={'w-full flex items-center justify-between'}>
<div className={'flex items-center gap-x-2 text-3xl font-medium'}>
<Image
src={'/logo.png'}
alt={'WireAdmin'}
width={40}
height={40}
/>
<h1> WireAdmin </h1>
</div>
<div className={'flex items-center gap-x-2'}>
<Link
href={'https://github.com/shahradelahi/wireadmin'}
title={'Giv me a star on Github'}
>
<img
src={'https://img.shields.io/github/stars/shahradelahi/wireadmin.svg?style=social&label=Star'}
alt={'Giv me a star on Github'}
/>
</Link>
</div>
</nav>
</header>
)
}

View File

@@ -1,38 +0,0 @@
import { LeastOne, ReactHTMLProps } from "@lib/typings";
import React from "react";
import { twMerge } from "tailwind-merge";
import { Breadcrumb } from "antd";
import { HomeOutlined } from "@ant-design/icons";
export type PageRouterProps = ReactHTMLProps<HTMLDivElement> & LeastOne<{
children: React.ReactNode
route: RouteItem[]
}>
type RouteItem = {
href?: string
title: React.ReactNode
}
export default function PageRouter(props: PageRouterProps) {
const { children, route, className, ...rest } = props
return (
<div {...rest} className={twMerge('py-3 px-2', className)}>
{route && route.length > 0 && (
<Breadcrumb items={[
{
href: '/',
title: (
<>
<HomeOutlined />
<span>Home</span>
</>
),
},
...route
]} />
)}
{children}
</div>
)
}

10
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
web/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

13
web/.prettierignore Normal file
View File

@@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

21
web/.prettierrc Normal file
View File

@@ -0,0 +1,21 @@
{
"useTabs": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte"
],
"pluginSearchDirs": [
"."
],
"tabWidth": 2,
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

41
web/README.md Normal file
View File

@@ -0,0 +1,41 @@
# create-svelte
Everything you need to build a Svelte project, powered
by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a
development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target
> environment.

BIN
web/bun.lockb Normal file

Binary file not shown.

27
web/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check .",
"format": "prettier --plugin-search-dir . --write .",
"start": "bun ./build/index.js"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/kit": "^1.20.4",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.5",
"svelte-check": "^3.4.3",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^4.4.2"
},
"type": "module"
}

12
web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};

12
web/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

1
web/src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,2 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>

BIN
web/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Some files were not shown because too many files have changed in this diff Show More