feat: challenge registry-driven platform + XVI/XVII imports + admin toggle + domain rename
- Rename repo/domain: attack-defense-platform / attackdefense.imrnes.team (all refs replaced)
- challenge_registry.json: single source of truth (28 challs across gemastik18/xvi/xvii)
- teams.py: registry-driven CHALLENGES, set_challenge_enabled, sync_challenge_runtime
(apply enable/disable to live teams: build/up or stop/remove + receiver restart)
- compose_gen.py: render per-team compose from canonical per-challenge templates
(image reuse, per-team ports 30xxx, flag mounts, passwords)
- gen_canonical_composes.py: canonical docker-compose.yml for all services
- import_new_challenges.py: import XVI/XVII services + EOL base image fixes
(debian:buster→bookworm, node:14→20, python:3.7-slim→3.11)
- receiver: xvi package (10 checkers) + xvii package (12 generic checkers),
Challenge base reads PASSWORD_<team_port> from env; gen_receiver_main.py
generates per-team main.py from registry
- main.py: /api/challenges returns full registry; PATCH /api/challenges/<name>
toggles enabled + applies to live teams
- index.html: 🏗️ Challenge Manager tab (toggle per challenge, grouped by set)
- SLA bonus now dynamic (all enabled challenges, not hardcoded 6)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
*.beam
|
||||
*.ez
|
||||
/build
|
||||
erl_crash.dump
|
||||
*.db
|
||||
@@ -0,0 +1,36 @@
|
||||
FROM public.ecr.aws/docker/library/alpine:latest
|
||||
|
||||
ARG PASSWORD
|
||||
|
||||
RUN \
|
||||
apk update && \
|
||||
apk add openrc --no-cache && \
|
||||
apk add openssh-server && \
|
||||
apk add openssl && \
|
||||
rc-update add sshd && \
|
||||
rc-status && \
|
||||
touch /run/openrc/softlevel
|
||||
|
||||
RUN echo root:${PASSWORD} | chpasswd
|
||||
RUN echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config
|
||||
RUN echo "PermitRootLogin yes" >> /etc/ssh/sshd_config
|
||||
|
||||
RUN apk add bash curl shadow
|
||||
RUN chsh -s /bin/bash root
|
||||
|
||||
RUN apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/community gleam rebar3 make gcc musl-dev
|
||||
ADD https://raw.githubusercontent.com/openembedded/openembedded-core/master/meta/recipes-core/musl/bsd-headers/sys-queue.h /usr/include/sys/queue.h
|
||||
|
||||
WORKDIR /ctf/gleam-drive
|
||||
|
||||
COPY src src/
|
||||
COPY *.toml .
|
||||
COPY start.sh .
|
||||
|
||||
RUN gleam build
|
||||
|
||||
RUN cat /dev/urandom | head -c 16 > .aes-key
|
||||
RUN openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=GleamDriveState/L=GleamDriveCity/O=GleamDriveCompany/OU=GleamDriveCompanySection/CN=GleamDrive"
|
||||
|
||||
RUN chmod +x start.sh
|
||||
CMD ./start.sh
|
||||
@@ -0,0 +1,3 @@
|
||||
Acquire::AllowInsecureRepositories "true";
|
||||
Acquire::AllowDowngradeToInsecureRepositories "true";
|
||||
Apt::Get::AllowUnauthenticated "true";
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
gleam-drive:
|
||||
container_name: gleam-drive_container
|
||||
hostname: gleam-drive
|
||||
restart: always
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- PASSWORD=$PASSWORD_12000
|
||||
volumes:
|
||||
- ../receiver/flags/gleam-drive.txt:/flag.txt:ro
|
||||
- ../utils/bashrc:/root/.bashrc:ro
|
||||
- ../utils/preexec.sh:/root/.preexec.sh:ro
|
||||
ports:
|
||||
- "12000:8000"
|
||||
- "12022:22"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
@@ -0,0 +1,29 @@
|
||||
name = "gdrive"
|
||||
version = "1.0.0"
|
||||
|
||||
# Fill out these fields if you intend to generate HTML documentation or publish
|
||||
# your project to the Hex package manager.
|
||||
#
|
||||
# description = ""
|
||||
# licences = ["Apache-2.0"]
|
||||
# repository = { type = "github", user = "", repo = "" }
|
||||
# links = [{ title = "Website", href = "" }]
|
||||
#
|
||||
# For a full reference of all the available options, you can have a look at
|
||||
# https://gleam.run/writing-gleam/gleam-toml/.
|
||||
|
||||
[dependencies]
|
||||
gleam_stdlib = ">= 0.34.0 and < 2.0.0"
|
||||
filepath = ">= 1.0.0 and < 2.0.0"
|
||||
gleam_erlang = ">= 0.25.0 and < 1.0.0"
|
||||
mist = ">= 1.2.0 and < 2.0.0"
|
||||
gleam_http = ">= 3.6.0 and < 4.0.0"
|
||||
simplifile = ">= 2.0.1 and < 3.0.0"
|
||||
beecrypt = ">= 0.3.1 and < 1.0.0"
|
||||
sqlight = ">= 0.9.0 and < 1.0.0"
|
||||
birl = ">= 1.7.1 and < 2.0.0"
|
||||
gleam_json = ">= 1.0.1 and < 2.0.0"
|
||||
wisp = ">= 0.16.0 and < 1.0.0"
|
||||
|
||||
[dev-dependencies]
|
||||
gleeunit = ">= 1.0.0 and < 2.0.0"
|
||||
@@ -0,0 +1,44 @@
|
||||
# This file was generated by Gleam
|
||||
# You typically do not need to edit this file
|
||||
|
||||
packages = [
|
||||
{ name = "bcrypt", version = "1.2.2", build_tools = ["rebar3"], requirements = ["poolboy"], otp_app = "bcrypt", source = "hex", outer_checksum = "6428D68BB2608490E150BE406A7AB75B5BCE4A2B5A3B0C3F36A12E367A141561" },
|
||||
{ name = "beecrypt", version = "0.3.1", build_tools = ["gleam"], requirements = ["bcrypt", "gleam_crypto", "gleam_erlang", "gleam_stdlib"], otp_app = "beecrypt", source = "hex", outer_checksum = "9698221A179C4D823DE2F00954D7A10BBC2B577B6C40A269BD676C308A788C15" },
|
||||
{ name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" },
|
||||
{ name = "esqlite", version = "0.8.8", build_tools = ["rebar3"], requirements = [], otp_app = "esqlite", source = "hex", outer_checksum = "374902457C7D94DC9409C98D3BDD1CA0D50A60DC9F3BDF1FD8EB74C0DCDF02D6" },
|
||||
{ name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" },
|
||||
{ name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" },
|
||||
{ name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" },
|
||||
{ name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" },
|
||||
{ name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" },
|
||||
{ name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" },
|
||||
{ name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" },
|
||||
{ name = "gleam_stdlib", version = "0.39.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "2D7DE885A6EA7F1D5015D1698920C9BAF7241102836CE0C3837A4F160128A9C4" },
|
||||
{ name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" },
|
||||
{ name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" },
|
||||
{ name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" },
|
||||
{ name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" },
|
||||
{ name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" },
|
||||
{ name = "marceau", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "5188D643C181EE350D8A20A3BDBD63AF7B6C505DE333CFBE05EF642ADD88A59B" },
|
||||
{ name = "mist", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "109B4D64E68C104CC23BB3CC5441ECD479DD7444889DA01113B75C6AF0F0E17B" },
|
||||
{ name = "poolboy", version = "1.5.2", build_tools = ["rebar3"], requirements = [], otp_app = "poolboy", source = "hex", outer_checksum = "DAD79704CE5440F3D5A3681C8590B9DC25D1A561E8F5A9C995281012860901E3" },
|
||||
{ name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" },
|
||||
{ name = "simplifile", version = "2.0.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "5FFEBD0CAB39BDD343C3E1CCA6438B2848847DC170BA2386DF9D7064F34DF000" },
|
||||
{ name = "sqlight", version = "0.9.0", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "2D9C9BA420A5E7DCE7DB2DAAE4CAB0BE6218BEB48FD1531C583550B3D1316E94" },
|
||||
{ name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" },
|
||||
{ name = "wisp", version = "0.16.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "A13DAD6A84BED78FF5D7656F3B5125086801BE1AF47427A9A759EA8C484345AB" },
|
||||
]
|
||||
|
||||
[requirements]
|
||||
beecrypt = { version = ">= 0.3.1 and < 1.0.0" }
|
||||
birl = { version = ">= 1.7.1 and < 2.0.0" }
|
||||
filepath = { version = ">= 1.0.0 and < 2.0.0" }
|
||||
gleam_erlang = { version = ">= 0.25.0 and < 1.0.0" }
|
||||
gleam_http = { version = ">= 3.6.0 and < 4.0.0" }
|
||||
gleam_json = { version = ">= 1.0.1 and < 2.0.0" }
|
||||
gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" }
|
||||
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
|
||||
mist = { version = ">= 1.2.0 and < 2.0.0" }
|
||||
simplifile = { version = ">= 2.0.1 and < 3.0.0" }
|
||||
sqlight = { version = ">= 0.9.0 and < 1.0.0" }
|
||||
wisp = { version = ">= 0.16.0 and < 1.0.0"}
|
||||
@@ -0,0 +1,10 @@
|
||||
import gleam/result
|
||||
import simplifile
|
||||
|
||||
pub const upload_dir = "/tmp/gleam-drive/"
|
||||
|
||||
pub const max_file_size = 524_288
|
||||
|
||||
pub fn aes_key() -> String {
|
||||
result.unwrap(simplifile.read(".aes-key"), "change-me-please")
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import gleam/bit_array
|
||||
import gleam/result
|
||||
import gleam/string
|
||||
|
||||
const block_size = 16
|
||||
|
||||
@external(erlang, "crypto_ffi", "encrypt")
|
||||
pub fn encrypt_ffi(key: BitArray, data: BitArray) -> BitArray
|
||||
|
||||
@external(erlang, "crypto_ffi", "decrypt")
|
||||
pub fn decrypt_ffi(key: BitArray, data: BitArray) -> BitArray
|
||||
|
||||
@external(erlang, "crypto_ffi", "hash")
|
||||
pub fn hash(data: String) -> String
|
||||
|
||||
pub fn encrypt(key: String, data: String) -> String {
|
||||
let key = bit_array.from_string(key)
|
||||
let data = bit_array.from_string(pad(data))
|
||||
let ciphertext = encrypt_ffi(key, data)
|
||||
bit_array.base16_encode(ciphertext)
|
||||
}
|
||||
|
||||
pub fn decrypt(key: String, data: String) -> String {
|
||||
let key = bit_array.from_string(key)
|
||||
let data = bit_array.base16_decode(data)
|
||||
case data {
|
||||
Ok(data) -> {
|
||||
let plaintext = decrypt_ffi(key, data)
|
||||
unpad(result.unwrap(bit_array.to_string(plaintext), ""))
|
||||
}
|
||||
Error(_) -> ""
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pad(data: String) -> String {
|
||||
let remainder = string.byte_size(data) % block_size
|
||||
let padding = string.repeat(" ", block_size - remainder)
|
||||
string.append(data, padding)
|
||||
}
|
||||
|
||||
pub fn unpad(data: String) -> String {
|
||||
string.trim_right(data)
|
||||
}
|
||||
|
||||
pub fn encode(data: String) -> String {
|
||||
bit_array.base16_encode(bit_array.from_string(data))
|
||||
}
|
||||
|
||||
pub fn decode(data: String) -> String {
|
||||
case bit_array.base16_decode(data) {
|
||||
Ok(data) -> result.unwrap(bit_array.to_string(data), "")
|
||||
Error(_) -> ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import gleam/dynamic
|
||||
import gleam/json.{int, object, string}
|
||||
|
||||
pub type User {
|
||||
User(email: String, password: String)
|
||||
}
|
||||
|
||||
pub type Session {
|
||||
Session(email: String, expired_at: Int)
|
||||
}
|
||||
|
||||
pub type Upload {
|
||||
Upload(email: String, filename: String, filesize: Int, timestamp: String)
|
||||
}
|
||||
|
||||
pub type Shared {
|
||||
Shared(filepath: String, recepient: String)
|
||||
}
|
||||
|
||||
pub fn session_to_json(session: Session) -> String {
|
||||
object([
|
||||
#("email", string(session.email)),
|
||||
#("expired_at", int(session.expired_at)),
|
||||
])
|
||||
|> json.to_string
|
||||
}
|
||||
|
||||
pub fn session_from_json(data: String) -> Result(Session, json.DecodeError) {
|
||||
let session_decoder =
|
||||
dynamic.decode2(
|
||||
Session,
|
||||
dynamic.field("email", of: dynamic.string),
|
||||
dynamic.field("expired_at", of: dynamic.int),
|
||||
)
|
||||
json.decode(from: data, using: session_decoder)
|
||||
}
|
||||
|
||||
pub fn shared_to_json(shared: Shared) -> String {
|
||||
object([
|
||||
#("filepath", string(shared.filepath)),
|
||||
#("recepient", string(shared.recepient)),
|
||||
])
|
||||
|> json.to_string
|
||||
}
|
||||
|
||||
pub fn shared_from_json(data: String) -> Result(Shared, json.DecodeError) {
|
||||
let shared_decoder =
|
||||
dynamic.decode2(
|
||||
Shared,
|
||||
dynamic.field("filepath", of: dynamic.string),
|
||||
dynamic.field("recepient", of: dynamic.string),
|
||||
)
|
||||
json.decode(from: data, using: shared_decoder)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import app/crypto
|
||||
import app/model
|
||||
import gleam/dynamic
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import sqlight
|
||||
|
||||
pub fn init(db: sqlight.Connection) {
|
||||
let assert Ok(Nil) =
|
||||
sqlight.exec(
|
||||
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT, password TEXT)",
|
||||
db,
|
||||
)
|
||||
|
||||
let assert Ok(Nil) =
|
||||
sqlight.exec(
|
||||
"CREATE TABLE IF NOT EXISTS uploads (id INTEGER PRIMARY KEY, email TEXT, filename TEXT, filesize INTEGER, timestamp TEXT)",
|
||||
db,
|
||||
)
|
||||
|
||||
let assert Ok(Nil) =
|
||||
sqlight.exec(
|
||||
"CREATE INDEX IF NOT EXISTS uploads_owner_index ON uploads (email)",
|
||||
db,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn login(db: sqlight.Connection, email: String, password: String) -> Bool {
|
||||
let sql = "SELECT * FROM users WHERE email = ? AND password = ?"
|
||||
let user_decoder = dynamic.tuple3(dynamic.int, dynamic.string, dynamic.string)
|
||||
let assert Ok(users) =
|
||||
sqlight.query(
|
||||
sql,
|
||||
on: db,
|
||||
with: [
|
||||
sqlight.text(crypto.encode(email)),
|
||||
sqlight.text(crypto.hash(password)),
|
||||
],
|
||||
expecting: user_decoder,
|
||||
)
|
||||
list.length(users) > 0
|
||||
}
|
||||
|
||||
pub fn create_user(
|
||||
db: sqlight.Connection,
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result(String, String) {
|
||||
let sql =
|
||||
"INSERT INTO users (email, password) VALUES ('"
|
||||
<> crypto.encode(email)
|
||||
<> "', '"
|
||||
<> crypto.hash(password)
|
||||
<> "')"
|
||||
|
||||
case sqlight.exec(sql, db) {
|
||||
Ok(_) -> Ok("User created")
|
||||
Error(_) -> Error("Failed to create user")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_exists(db: sqlight.Connection, email: String) -> Bool {
|
||||
let sql = "SELECT * FROM users WHERE email = ?"
|
||||
let user_decoder = dynamic.tuple3(dynamic.int, dynamic.string, dynamic.string)
|
||||
let assert Ok(users) =
|
||||
sqlight.query(
|
||||
sql,
|
||||
on: db,
|
||||
with: [sqlight.text(crypto.encode(email))],
|
||||
expecting: user_decoder,
|
||||
)
|
||||
list.length(users) > 0
|
||||
}
|
||||
|
||||
pub fn create_upload(
|
||||
db: sqlight.Connection,
|
||||
email: String,
|
||||
filename: String,
|
||||
filesize: Int,
|
||||
) {
|
||||
let sql =
|
||||
"INSERT INTO uploads (email, filename, filesize, timestamp) VALUES ('"
|
||||
<> crypto.encode(email)
|
||||
<> "', '"
|
||||
<> crypto.encode(filename)
|
||||
<> "', "
|
||||
<> int.to_string(filesize)
|
||||
<> ", datetime('now'))"
|
||||
let assert Ok(Nil) = sqlight.exec(sql, db)
|
||||
}
|
||||
|
||||
pub fn list_uploads(db: sqlight.Connection, email: String) -> List(model.Upload) {
|
||||
let sql = "SELECT * FROM uploads WHERE email = ?"
|
||||
let upload_decoder =
|
||||
dynamic.tuple5(
|
||||
dynamic.int,
|
||||
dynamic.string,
|
||||
dynamic.string,
|
||||
dynamic.int,
|
||||
dynamic.string,
|
||||
)
|
||||
let assert Ok(uploads) =
|
||||
sqlight.query(
|
||||
sql,
|
||||
on: db,
|
||||
with: [sqlight.text(crypto.encode(email))],
|
||||
expecting: upload_decoder,
|
||||
)
|
||||
list.map(uploads, fn(upload) {
|
||||
model.Upload(
|
||||
crypto.decode(upload.1),
|
||||
crypto.decode(upload.2),
|
||||
upload.3,
|
||||
upload.4,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_file(
|
||||
db: sqlight.Connection,
|
||||
email: String,
|
||||
filename: String,
|
||||
) -> Result(model.Upload, String) {
|
||||
let sql = "SELECT * FROM uploads WHERE email = ? AND filename = ?"
|
||||
let upload_decoder =
|
||||
dynamic.tuple5(
|
||||
dynamic.int,
|
||||
dynamic.string,
|
||||
dynamic.string,
|
||||
dynamic.int,
|
||||
dynamic.string,
|
||||
)
|
||||
let assert Ok(uploads) =
|
||||
sqlight.query(
|
||||
sql,
|
||||
on: db,
|
||||
with: [
|
||||
sqlight.text(crypto.encode(email)),
|
||||
sqlight.text(crypto.encode(filename)),
|
||||
],
|
||||
expecting: upload_decoder,
|
||||
)
|
||||
case list.first(uploads) {
|
||||
Ok(upload) ->
|
||||
Ok(model.Upload(
|
||||
crypto.decode(upload.1),
|
||||
crypto.decode(upload.2),
|
||||
upload.3,
|
||||
upload.4,
|
||||
))
|
||||
Error(_) -> Error("File not found")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import app/config
|
||||
import app/model
|
||||
import app/service
|
||||
import app/util
|
||||
import app/view
|
||||
import app/web
|
||||
import birl
|
||||
import filepath
|
||||
import gleam/http.{Get, Post}
|
||||
import gleam/io
|
||||
import gleam/list
|
||||
import gleam/result
|
||||
import gleam/string_builder
|
||||
import wisp.{type Request, type Response}
|
||||
|
||||
pub fn handle_request(req: Request, ctx: web.Context) -> Response {
|
||||
use req <- web.middleware(req)
|
||||
let session_cookie = case wisp.get_cookie(req, "session", wisp.PlainText) {
|
||||
Ok(cookie) -> cookie
|
||||
Error(_) -> ""
|
||||
}
|
||||
|
||||
let session = service.extract_session_token(session_cookie)
|
||||
case session.expired_at > birl.to_unix(birl.now()) {
|
||||
True -> handle_protected_routes(req, ctx, session)
|
||||
False -> handle_routes(req, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_routes(req: Request, ctx: web.Context) -> Response {
|
||||
case wisp.path_segments(req) {
|
||||
[] -> show_home(req)
|
||||
["login"] ->
|
||||
case req.method {
|
||||
Post -> handle_login(req, ctx)
|
||||
_ -> show_login()
|
||||
}
|
||||
["register"] ->
|
||||
case req.method {
|
||||
Post -> handle_register(req, ctx)
|
||||
_ -> show_register()
|
||||
}
|
||||
["error"] -> show_error(req)
|
||||
_ -> wisp.redirect("/login")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_protected_routes(
|
||||
req: Request,
|
||||
ctx: web.Context,
|
||||
session: model.Session,
|
||||
) -> Response {
|
||||
case wisp.path_segments(req) {
|
||||
["drive"] ->
|
||||
case req.method {
|
||||
Post -> handle_upload(req, ctx, session)
|
||||
_ -> show_drive(ctx, session)
|
||||
}
|
||||
["share"] -> handle_share(req, ctx, session)
|
||||
["details"] -> handle_details(req, ctx, session)
|
||||
["download"] -> handle_download(req, session)
|
||||
["logout"] -> destroy_session(req)
|
||||
["error"] -> show_error(req)
|
||||
_ -> wisp.redirect("/drive")
|
||||
}
|
||||
}
|
||||
|
||||
fn destroy_session(req: Request) -> Response {
|
||||
let resp = wisp.redirect("/login")
|
||||
wisp.set_cookie(resp, req, "session", "", wisp.PlainText, 0)
|
||||
}
|
||||
|
||||
fn create_session(req: Request, token: String) -> Response {
|
||||
let resp = wisp.redirect("/drive")
|
||||
wisp.set_cookie(resp, req, "session", token, wisp.PlainText, 30 * 60)
|
||||
}
|
||||
|
||||
fn error_response(message: String) -> Response {
|
||||
wisp.redirect("/error?message=" <> message)
|
||||
}
|
||||
|
||||
fn show_share(download_link: String) -> Response {
|
||||
view.render_share_html(download_link)
|
||||
|> string_builder.from_string
|
||||
|> wisp.html_response(200)
|
||||
}
|
||||
|
||||
fn send_file(shared: model.Shared, session: model.Session) -> Response {
|
||||
case shared.recepient == session.email {
|
||||
True -> {
|
||||
wisp.ok()
|
||||
|> wisp.set_header("content-type", "application/octet-stream")
|
||||
|> wisp.file_download(
|
||||
named: filepath.base_name(shared.filepath),
|
||||
from: shared.filepath,
|
||||
)
|
||||
}
|
||||
False -> error_response("Invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
fn show_details(upload: model.Upload, token: String) -> Response {
|
||||
view.render_detail_html(upload, token)
|
||||
|> string_builder.from_string
|
||||
|> wisp.html_response(200)
|
||||
}
|
||||
|
||||
fn handle_details(
|
||||
req: Request,
|
||||
ctx: web.Context,
|
||||
session: model.Session,
|
||||
) -> Response {
|
||||
let query = wisp.get_query(req)
|
||||
let filename = case list.key_find(query, "filename") {
|
||||
Ok(value) -> value
|
||||
Error(_) -> ""
|
||||
}
|
||||
|
||||
case service.find_upload(ctx.db, session, filename) {
|
||||
Ok(upload) -> {
|
||||
let filepath =
|
||||
filepath.join(filepath.join(config.upload_dir, session.email), filename)
|
||||
let token =
|
||||
service.generate_shared_token(model.Shared(filepath, upload.email))
|
||||
show_details(upload, token)
|
||||
}
|
||||
Error(message) -> error_response(message)
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_download(req: Request, session: model.Session) -> Response {
|
||||
let query = wisp.get_query(req)
|
||||
case list.key_find(query, "token") {
|
||||
Ok(token) -> {
|
||||
case service.extract_shared_token(token) {
|
||||
Ok(shared) -> send_file(shared, session)
|
||||
Error(message) -> error_response(message)
|
||||
}
|
||||
}
|
||||
Error(_) -> error_response("Invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_share(
|
||||
req: Request,
|
||||
ctx: web.Context,
|
||||
session: model.Session,
|
||||
) -> Response {
|
||||
use forms <- wisp.require_form(req)
|
||||
let query = wisp.get_query(req)
|
||||
|
||||
let payload = {
|
||||
use recepient <- result.try(list.key_find(forms.values, "recepient"))
|
||||
use filename <- result.try(list.key_find(query, "filename"))
|
||||
Ok(#(recepient, filename))
|
||||
}
|
||||
|
||||
case payload {
|
||||
Ok(payload) -> {
|
||||
case service.share(ctx.db, payload.0, payload.1, session) {
|
||||
Ok(token) ->
|
||||
show_share(util.get_base_url(req) <> "/download?token=" <> token)
|
||||
Error(message) -> error_response(message)
|
||||
}
|
||||
}
|
||||
Error(_) -> wisp.bad_request()
|
||||
}
|
||||
}
|
||||
|
||||
fn show_error(req: Request) -> Response {
|
||||
let query = wisp.get_query(req)
|
||||
let message = case list.key_find(query, "message") {
|
||||
Ok(value) -> value
|
||||
Error(_) -> "Unknown error"
|
||||
}
|
||||
view.render_error_html(message)
|
||||
|> string_builder.from_string
|
||||
|> wisp.html_response(200)
|
||||
}
|
||||
|
||||
fn show_home(req: Request) -> Response {
|
||||
use <- wisp.require_method(req, Get)
|
||||
view.index_html
|
||||
|> string_builder.from_string
|
||||
|> wisp.html_response(200)
|
||||
}
|
||||
|
||||
fn show_login() -> Response {
|
||||
view.login_html
|
||||
|> string_builder.from_string
|
||||
|> wisp.html_response(200)
|
||||
}
|
||||
|
||||
fn handle_login(req: Request, ctx: web.Context) -> Response {
|
||||
use form <- wisp.require_form(req)
|
||||
let payload = {
|
||||
use email <- result.try(list.key_find(form.values, "email"))
|
||||
use password <- result.try(list.key_find(form.values, "password"))
|
||||
Ok(model.User(email, password))
|
||||
}
|
||||
case payload {
|
||||
Ok(user) -> {
|
||||
case service.login(ctx.db, user) {
|
||||
Ok(token) -> create_session(req, token)
|
||||
Error(message) -> error_response(message)
|
||||
}
|
||||
}
|
||||
Error(_) -> wisp.bad_request()
|
||||
}
|
||||
}
|
||||
|
||||
fn show_register() -> Response {
|
||||
view.register_html
|
||||
|> string_builder.from_string
|
||||
|> wisp.html_response(200)
|
||||
}
|
||||
|
||||
fn handle_register(req: Request, ctx: web.Context) -> Response {
|
||||
use form <- wisp.require_form(req)
|
||||
let payload = {
|
||||
use email <- result.try(list.key_find(form.values, "email"))
|
||||
use password <- result.try(list.key_find(form.values, "password"))
|
||||
Ok(model.User(email, password))
|
||||
}
|
||||
case payload {
|
||||
Ok(user) -> {
|
||||
case service.register(ctx.db, user) {
|
||||
Ok(_) -> wisp.redirect("/login")
|
||||
Error(message) -> error_response(message)
|
||||
}
|
||||
}
|
||||
Error(_) -> wisp.bad_request()
|
||||
}
|
||||
}
|
||||
|
||||
fn show_drive(ctx: web.Context, session: model.Session) -> Response {
|
||||
let drives = service.list_uploads(ctx.db, session)
|
||||
|
||||
view.render_drives_html(drives)
|
||||
|> string_builder.from_string
|
||||
|> wisp.html_response(200)
|
||||
}
|
||||
|
||||
fn handle_upload(
|
||||
req: Request,
|
||||
ctx: web.Context,
|
||||
session: model.Session,
|
||||
) -> Response {
|
||||
use form <- wisp.require_form(req)
|
||||
let payload = {
|
||||
use file <- result.try(list.key_find(form.files, "uploaded-file"))
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
case payload {
|
||||
Ok(file) -> {
|
||||
case service.upload(ctx.db, file, session) {
|
||||
Ok(_) -> wisp.redirect("/drive")
|
||||
Error(message) -> error_response(message)
|
||||
}
|
||||
}
|
||||
Error(_) -> {
|
||||
io.debug("Failed to upload file")
|
||||
wisp.bad_request()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import app/config
|
||||
import app/crypto
|
||||
import app/model
|
||||
import app/repository
|
||||
import app/util
|
||||
import birl
|
||||
import birl/duration
|
||||
import filepath
|
||||
import gleam/result
|
||||
import simplifile
|
||||
import sqlight
|
||||
import wisp
|
||||
|
||||
pub fn login(db: sqlight.Connection, user: model.User) -> Result(String, String) {
|
||||
case repository.login(db, user.email, user.password) {
|
||||
True -> Ok(generate_session_token(user.email))
|
||||
False -> Error("Invalid credentials")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(
|
||||
db: sqlight.Connection,
|
||||
user: model.User,
|
||||
) -> Result(String, String) {
|
||||
let user_dir = filepath.join(config.upload_dir, user.email)
|
||||
case repository.user_exists(db, user.email) || util.path_exists(user_dir) {
|
||||
True -> Error("User already exists")
|
||||
False -> {
|
||||
result.unwrap(simplifile.create_directory_all(user_dir), Nil)
|
||||
repository.create_user(db, user.email, user.password)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_uploads(
|
||||
db: sqlight.Connection,
|
||||
session: model.Session,
|
||||
) -> List(model.Upload) {
|
||||
repository.list_uploads(db, session.email)
|
||||
}
|
||||
|
||||
pub fn find_upload(
|
||||
db: sqlight.Connection,
|
||||
session: model.Session,
|
||||
filename: String,
|
||||
) -> Result(model.Upload, String) {
|
||||
repository.find_file(db, session.email, filename)
|
||||
}
|
||||
|
||||
pub fn upload(
|
||||
db: sqlight.Connection,
|
||||
file: wisp.UploadedFile,
|
||||
session: model.Session,
|
||||
) -> Result(String, String) {
|
||||
let user_dir = filepath.join(config.upload_dir, session.email)
|
||||
let filepath = filepath.join(user_dir, file.file_name)
|
||||
let filesize = util.get_file_size(file.path)
|
||||
case repository.create_upload(db, session.email, file.file_name, filesize) {
|
||||
Ok(_) -> util.upload_file(file.path, filepath)
|
||||
Error(_) -> Error("Failed to upload file")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn share(
|
||||
db: sqlight.Connection,
|
||||
recepient: String,
|
||||
filename: String,
|
||||
session: model.Session,
|
||||
) -> Result(String, String) {
|
||||
let drive = repository.find_file(db, session.email, filename)
|
||||
let user_dir = filepath.join(config.upload_dir, session.email)
|
||||
let shared_file = filepath.join(user_dir, filename)
|
||||
case drive {
|
||||
Ok(_) -> Ok(generate_shared_token(model.Shared(shared_file, recepient)))
|
||||
Error(_) -> Error("File not found")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_session_token(token: String) -> model.Session {
|
||||
let key = config.aes_key()
|
||||
case model.session_from_json(crypto.decrypt(key, token)) {
|
||||
Ok(session) -> session
|
||||
Error(_) -> model.Session("", 0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_shared_token(token: String) -> Result(model.Shared, String) {
|
||||
let key = config.aes_key()
|
||||
case model.shared_from_json(crypto.decrypt(key, token)) {
|
||||
Ok(shared) -> Ok(shared)
|
||||
Error(_) -> Error("Invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_shared_token(data: model.Shared) -> String {
|
||||
let key = config.aes_key()
|
||||
crypto.encrypt(key, model.shared_to_json(data))
|
||||
}
|
||||
|
||||
fn generate_session_token(email: String) -> String {
|
||||
let key = config.aes_key()
|
||||
crypto.encrypt(
|
||||
key,
|
||||
model.session_to_json(model.Session(
|
||||
email,
|
||||
birl.to_unix(birl.add(birl.now(), duration.minutes(30))),
|
||||
)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import app/config
|
||||
import gleam/http
|
||||
import gleam/int
|
||||
import gleam/option
|
||||
import gleam/result
|
||||
import simplifile
|
||||
import wisp
|
||||
|
||||
pub fn path_exists(path: String) -> Bool {
|
||||
result.unwrap(simplifile.is_directory(path), True)
|
||||
|| result.unwrap(simplifile.is_file(path), True)
|
||||
|| result.unwrap(simplifile.is_symlink(path), True)
|
||||
}
|
||||
|
||||
pub fn upload_file(src: String, dest: String) -> Result(String, String) {
|
||||
case !path_exists(dest) && config.max_file_size > get_file_size(src) {
|
||||
True -> {
|
||||
result.unwrap(simplifile.copy_file(src, dest), Nil)
|
||||
Ok("File uploaded")
|
||||
}
|
||||
False -> Error("Failed to upload file")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_file_size(path: String) -> Int {
|
||||
case simplifile.file_info(path) {
|
||||
Ok(info) -> info.size
|
||||
Error(_) -> 0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_base_url(req: wisp.Request) -> String {
|
||||
let scheme = http.scheme_to_string(req.scheme)
|
||||
let host = req.host
|
||||
let port = int.to_string(option.unwrap(req.port, 80))
|
||||
scheme <> "://" <> host <> ":" <> port
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
|
||||
import app/config
|
||||
import sqlight
|
||||
import wisp
|
||||
|
||||
pub type Context {
|
||||
Context(db: sqlight.Connection)
|
||||
}
|
||||
|
||||
pub fn middleware(
|
||||
req: wisp.Request,
|
||||
handle_request: fn(wisp.Request) -> wisp.Response,
|
||||
) -> wisp.Response {
|
||||
use <- wisp.log_request(req)
|
||||
use <- wisp.rescue_crashes
|
||||
use req <- wisp.handle_head(req)
|
||||
handle_request(req |> wisp.set_max_files_size(config.max_file_size))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-module(crypto_ffi).
|
||||
-export([encrypt/2, decrypt/2, hash/1]).
|
||||
|
||||
encrypt(Key, Data) ->
|
||||
crypto:crypto_one_time(aes_128_ecb, Key, Data, true).
|
||||
|
||||
decrypt(Key, Data) ->
|
||||
crypto:crypto_one_time(aes_128_ecb, Key, Data, false).
|
||||
|
||||
hash(Data) ->
|
||||
binary:encode_hex(crypto:hash(sha256, Data), uppercase).
|
||||
@@ -0,0 +1,23 @@
|
||||
import app/repository
|
||||
import app/router
|
||||
import app/web
|
||||
import gleam/erlang/process
|
||||
import mist
|
||||
import sqlight
|
||||
import wisp
|
||||
|
||||
pub fn main() {
|
||||
wisp.configure_logger()
|
||||
let secret_key_base = wisp.random_string(64)
|
||||
use db <- sqlight.with_connection("gdrive.db")
|
||||
let assert Ok(Nil) = repository.init(db)
|
||||
let context = web.Context(db: db)
|
||||
let handler = router.handle_request(_, context)
|
||||
let assert Ok(_) =
|
||||
wisp.mist_handler(handler, secret_key_base)
|
||||
|> mist.new
|
||||
|> mist.port(8000)
|
||||
|> mist.start_https("cert.pem", "key.pem")
|
||||
|
||||
process.sleep_forever()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
# run sshd
|
||||
service sshd restart
|
||||
# run the command
|
||||
gleam run
|
||||
Reference in New Issue
Block a user