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:
1 parent
c35b23a37f
commit
c6fd9ec268
1317 files changed
+306586
-128
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import app/model
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
import gleam/string_builder
|
||||
|
||||
pub const index_html = "<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||
<title>gleam-drive</title>
|
||||
<script src='https://cdn.tailwindcss.com'></script>
|
||||
</head>
|
||||
<div class='flex flex-col min-h-screen'>
|
||||
<div class='px-4 lg:px-6 h-14 flex items-center'>
|
||||
<div class='flex items-center justify-center' href='#'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke-width='1'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
class='h-6 w-6'
|
||||
>
|
||||
<path fill-rule='evenodd' clip-rule='evenodd' d='M12 1.25C11.3953 1.25 10.8384 1.40029 10.2288 1.65242C9.64008 1.89588 8.95633 2.25471 8.1049 2.70153L6.03739 3.78651C4.99242 4.33487 4.15616 4.77371 3.51047 5.20491C2.84154 5.65164 2.32632 6.12201 1.95112 6.75918C1.57718 7.39421 1.40896 8.08184 1.32829 8.90072C1.24999 9.69558 1.24999 10.6731 1.25 11.9026V12.0974C1.24999 13.3268 1.24999 14.3044 1.32829 15.0993C1.40896 15.9182 1.57718 16.6058 1.95112 17.2408C2.32632 17.878 2.84154Line truncated
|
||||
</svg>
|
||||
<span class='sr-only'>kode-viewer</span>
|
||||
</div>
|
||||
</div>
|
||||
<main class='flex-1 flex flex-col items-center justify-center space-y-4 text-center'>
|
||||
<div class='space-y-2'>
|
||||
<h1 class='text-3xl font-bold tracking-tighter sm:text-5xl xl:text-6xl/none'>gleam-drive</h1>
|
||||
<p class='max-w-[600px] text-muted-foreground text-gray-500 md:text-xl'>
|
||||
Elevate your file-share experience with gleam-drive.
|
||||
</p>
|
||||
</div>
|
||||
<a class='flex justify-center' href='/login'>
|
||||
<button class='rounded-md text-sm font-medium bg-background hover:bg-gray-200 text-black font-bold py-2 px-4 border border-accent rounded'>
|
||||
Get Started
|
||||
</button>
|
||||
</a>
|
||||
</main>
|
||||
</div>
|
||||
</html>"
|
||||
|
||||
pub const login_html = "<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||
<title>gleam-drive</title>
|
||||
<script src='https://cdn.tailwindcss.com'></script>
|
||||
</head>
|
||||
<div class='flex flex-col min-h-screen'>
|
||||
<div class='px-4 lg:px-6 h-14 flex items-center'>
|
||||
<div class='flex items-center justify-center' href='#'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke-width='1'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
class='h-6 w-6'
|
||||
>
|
||||
<path fill-rule='evenodd' clip-rule='evenodd' d='M12 1.25C11.3953 1.25 10.8384 1.40029 10.2288 1.65242C9.64008 1.89588 8.95633 2.25471 8.1049 2.70153L6.03739 3.78651C4.99242 4.33487 4.15616 4.77371 3.51047 5.20491C2.84154 5.65164 2.32632 6.12201 1.95112 6.75918C1.57718 7.39421 1.40896 8.08184 1.32829 8.90072C1.24999 9.69558 1.24999 10.6731 1.25 11.9026V12.0974C1.24999 13.3268 1.24999 14.3044 1.32829 15.0993C1.40896 15.9182 1.57718 16.6058 1.95112 17.2408C2.32632 17.878 2.84154Line truncated
|
||||
</svg>
|
||||
<span class='sr-only'>gleam-drive</span>
|
||||
</div>
|
||||
</div>
|
||||
<main class='flex-1 flex flex-col items-center justify-center text-center'>
|
||||
<div class='w-full max-w-md space-y-8'>
|
||||
<div>
|
||||
<h2 class='mt-6 text-center text-3xl font-bold tracking-tight text-foreground'>Sign in to Gleam Drive</h2>
|
||||
</div>
|
||||
<form class='space-y-4' action='/login' method='POST'>
|
||||
<div>
|
||||
<input
|
||||
class='h-10 bg-background text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 relative block w-full appearance-none rounded-md border border-muted px-3 py-2 text-foreground placeholder-muted-foreground focus:z-10 focus:border-primary focus:outline-none focus:ring-primary sm:text-sm'
|
||||
type='email'
|
||||
id='email'
|
||||
autocomplete='email'
|
||||
placeholder='Email'
|
||||
name='email'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sr-only'
|
||||
for='password'
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
class='h-10 bg-background text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 relative block w-full appearance-none rounded-md border border-muted px-3 py-2 text-foreground placeholder-muted-foreground focus:z-10 focus:border-primary focus:outline-none focus:ring-primary sm:text-sm'
|
||||
type='password'
|
||||
id='password'
|
||||
autocomplete='current-password'
|
||||
required
|
||||
placeholder='Password'
|
||||
name='password'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button type='submit' class='rounded-md text-sm w-full bg-background hover:bg-gray-200 text-black font-bold py-2 px-4 border border-accent rounded'>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
<div class='text-center'>
|
||||
<a class='text-sm font-medium text-muted-foreground text-gray-500 hover:text-gray-900' href='/register'>
|
||||
Don't have an account? Register
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</html>"
|
||||
|
||||
pub const register_html = "<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||
<title>gleam-drive</title>
|
||||
<script src='https://cdn.tailwindcss.com'></script>
|
||||
</head>
|
||||
<div class='flex flex-col min-h-screen'>
|
||||
<div class='px-4 lg:px-6 h-14 flex items-center'>
|
||||
<div class='flex items-center justify-center' href='#'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke-width='1'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
class='h-6 w-6'
|
||||
>
|
||||
<path fill-rule='evenodd' clip-rule='evenodd' d='M12 1.25C11.3953 1.25 10.8384 1.40029 10.2288 1.65242C9.64008 1.89588 8.95633 2.25471 8.1049 2.70153L6.03739 3.78651C4.99242 4.33487 4.15616 4.77371 3.51047 5.20491C2.84154 5.65164 2.32632 6.12201 1.95112 6.75918C1.57718 7.39421 1.40896 8.08184 1.32829 8.90072C1.24999 9.69558 1.24999 10.6731 1.25 11.9026V12.0974C1.24999 13.3268 1.24999 14.3044 1.32829 15.0993C1.40896 15.9182 1.57718 16.6058 1.95112 17.2408C2.32632 17.878 2.84154Line truncated
|
||||
</svg>
|
||||
<span class='sr-only'>gleam-drive</span>
|
||||
</div>
|
||||
</div>
|
||||
<main class='flex-1 flex flex-col items-center justify-center text-center'>
|
||||
<div class='w-full max-w-md space-y-8'>
|
||||
<div>
|
||||
<h2 class='mt-6 text-center text-3xl font-bold tracking-tight text-foreground'>Register to Gleam Drive</h2>
|
||||
</div>
|
||||
<form class='space-y-4' action='/register' method='POST'>
|
||||
<div>
|
||||
<label
|
||||
class='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sr-only'
|
||||
for='email'
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
class='h-10 bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 relative block w-full appearance-none rounded-md border border-muted px-3 py-2 text-foreground placeholder-muted-foreground focus:z-10 focus:border-primary focus:outline-none focLine truncated
|
||||
type='email'
|
||||
id='email'
|
||||
autocomplete='email'
|
||||
required
|
||||
placeholder='Email'
|
||||
name='email'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sr-only'
|
||||
for='password'
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
class='h-10 bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 relative block w-full appearance-none rounded-md border border-muted px-3 py-2 text-foreground placeholder-muted-foreground focus:z-10 focus:border-primary focus:outline-none focLine truncated
|
||||
type='password'
|
||||
id='password'
|
||||
autocomplete='current-password'
|
||||
required
|
||||
placeholder='Password'
|
||||
name='password'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button type='submit' class='rounded-md text-sm w-full bg-background hover:bg-gray-200 text-black font-bold py-2 px-4 border border-accent rounded'>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
<div class='text-center'>
|
||||
<a class='text-sm font-medium text-muted-foreground text-gray-500 hover:text-gray-900' href='/login'>
|
||||
Already have an account? Sign in
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</html>"
|
||||
|
||||
pub const drive_html = "<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||
<title>gleam-drive</title>
|
||||
<script src='https://cdn.tailwindcss.com'></script>
|
||||
</head>
|
||||
<div class='flex flex-col h-full w-full'>
|
||||
<div class='bg-background border-b flex px-4 sm:px-6 h-14'>
|
||||
<div class='flex flex-row justify-between items-center w-full'>
|
||||
<a class='flex items-center' href='/drive'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke-width='1'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
class='h-6 w-6'
|
||||
>
|
||||
<path fill-rule='evenodd' clip-rule='evenodd' d='M12 1.25C11.3953 1.25 10.8384 1.40029 10.2288 1.65242C9.64008 1.89588 8.95633 2.25471 8.1049 2.70153L6.03739 3.78651C4.99242 4.33487 4.15616 4.77371 3.51047 5.20491C2.84154 5.65164 2.32632 6.12201 1.95112 6.75918C1.57718 7.39421 1.40896 8.08184 1.32829 8.90072C1.24999 9.69558 1.24999 10.6731 1.25 11.9026V12.0974C1.24999 13.3268 1.24999 14.3044 1.32829 15.0993C1.40896 15.9182 1.57718 16.6058 1.95112 17.2408C2.32632 17.878 2.84154 1Line truncated
|
||||
</svg>
|
||||
<span class='sr-only'>gleam-drive</span>
|
||||
</a>
|
||||
<div class='flex'>
|
||||
<a href='/logout'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke-width='2'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
class='h-5 w-5'
|
||||
>
|
||||
<path d='M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4' />
|
||||
<polyline points='16 17 21 12 16 7' />
|
||||
<line x1='21' x2='9' y1='12' y2='12' />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class='flex flex-col items-center h-full w-full my-5 gap-4'>
|
||||
<div class='space-y-6 w-1/2'>
|
||||
<div class='flex items-center justify-between'>
|
||||
<div class='space-y-1'>
|
||||
<h2 class='text-2xl font-bold'>File Upload</h2>
|
||||
<p class='text-muted-foreground'>Drag and drop files or click to upload.</p>
|
||||
</div>
|
||||
<form method=post action='/drive' enctype='multipart/form-data' class='flex flex-row gap-2'>
|
||||
<input class='block my-2 text-sm focus:outline-none' id='uploaded-file' name='uploaded-file' type='file'>
|
||||
<button type='submit' class='inline-flex items-center justify-center whitespace-nowrap rounded-md text-md text-black font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-gray-200 hover:text-accent-foreground h-10 px-4 py-2'>
|
||||
Upload File
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
FILES_LIST
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</html>"
|
||||
|
||||
pub const share_html = "<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||
<title>gleam-drive</title>
|
||||
<script src='https://cdn.tailwindcss.com'></script>
|
||||
</head>
|
||||
<div class='flex flex-col h-full w-full'>
|
||||
<div class='bg-background border-b flex px-4 sm:px-6 h-14'>
|
||||
<div class='flex flex-row justify-between items-center w-full'>
|
||||
<a class='flex items-center' href='/drive'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke-width='1'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
class='h-6 w-6'
|
||||
>
|
||||
<path fill-rule='evenodd' clip-rule='evenodd' d='M12 1.25C11.3953 1.25 10.8384 1.40029 10.2288 1.65242C9.64008 1.89588 8.95633 2.25471 8.1049 2.70153L6.03739 3.78651C4.99242 4.33487 4.15616 4.77371 3.51047 5.20491C2.84154 5.65164 2.32632 6.12201 1.95112 6.75918C1.57718 7.39421 1.40896 8.08184 1.32829 8.90072C1.24999 9.69558 1.24999 10.6731 1.25 11.9026V12.0974C1.24999 13.3268 1.24999 14.3044 1.32829 15.0993C1.40896 15.9182 1.57718 16.6058 1.95112 17.2408C2.32632 17.878 2.84154 1Line truncated
|
||||
</svg>
|
||||
<span class='sr-only'>gleam-drive</span>
|
||||
</a>
|
||||
<div class='flex'>
|
||||
<a href='/logout'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke-width='2'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
class='h-5 w-5'
|
||||
>
|
||||
<path d='M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4' />
|
||||
<polyline points='16 17 21 12 16 7' />
|
||||
<line x1='21' x2='9' y1='12' y2='12' />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class='flex flex-col items-center h-full w-full my-5'>
|
||||
<div class='space-y-6 w-1/2'>
|
||||
<div class='flex items-center justify-between flex-col gap-4'>
|
||||
<div class='flex items-center justify-between mb-4'>
|
||||
<div>
|
||||
<h1 class='text-2xl font-bold'>Copy this link</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class='items-center flex justify-end gap-2'>
|
||||
<pre class='bg-gray-200 text-xs text-muted-foreground p-2 rounded-md'>DOWNLOAD_LINK</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</html>"
|
||||
|
||||
pub const error_html = "<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||
<title>gleam-drive</title>
|
||||
<script src='https://cdn.tailwindcss.com'></script>
|
||||
</head>
|
||||
<div class='flex flex-col min-h-screen'>
|
||||
<div class='px-4 lg:px-6 h-14 flex items-center'>
|
||||
<div class='flex items-center justify-center' href='#'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke-width='1'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
class='h-6 w-6'
|
||||
>
|
||||
<path fill-rule='evenodd' clip-rule='evenodd' d='M12 1.25C11.3953 1.25 10.8384 1.40029 10.2288 1.65242C9.64008 1.89588 8.95633 2.25471 8.1049 2.70153L6.03739 3.78651C4.99242 4.33487 4.15616 4.77371 3.51047 5.20491C2.84154 5.65164 2.32632 6.12201 1.95112 6.75918C1.57718 7.39421 1.40896 8.08184 1.32829 8.90072C1.24999 9.69558 1.24999 10.6731 1.25 11.9026V12.0974C1.24999 13.3268 1.24999 14.3044 1.32829 15.0993C1.40896 15.9182 1.57718 16.6058 1.95112 17.2408C2.32632 17.878 2.84154Line truncated
|
||||
</svg>
|
||||
<span class='sr-only'>gleam-drive</span>
|
||||
</div>
|
||||
</div>
|
||||
<main class='flex-1 flex flex-col items-center justify-center text-center'>
|
||||
<div class='w-full max-w-md space-y-8'>
|
||||
<div>
|
||||
<h2 class='mt-6 text-center text-3xl font-bold tracking-tight text-foreground'>Oops</h2>
|
||||
</div>
|
||||
<div>
|
||||
<p class='text-center text-foreground'>ERROR_MESSAGE</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href='/drive' class='rounded-md text-sm w-full bg-background hover:bg-gray-200 text-black font-bold py-2 px-4 border border-green-700 rounded'>
|
||||
Go back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</html>"
|
||||
|
||||
pub const detail_html = "<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||
<title>gleam-drive</title>
|
||||
<script src='https://cdn.tailwindcss.com'></script>
|
||||
</head>
|
||||
<div class='flex flex-col h-full w-full'>
|
||||
<div class='bg-background border-b flex px-4 sm:px-6 h-14'>
|
||||
<div class='flex flex-row justify-between items-center w-full'>
|
||||
<a class='flex items-center' href='/drive'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke-width='1'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
class='h-6 w-6'
|
||||
>
|
||||
<path fill-rule='evenodd' clip-rule='evenodd' d='M12 1.25C11.3953 1.25 10.8384 1.40029 10.2288 1.65242C9.64008 1.89588 8.95633 2.25471 8.1049 2.70153L6.03739 3.78651C4.99242 4.33487 4.15616 4.77371 3.51047 5.20491C2.84154 5.65164 2.32632 6.12201 1.95112 6.75918C1.57718 7.39421 1.40896 8.08184 1.32829 8.90072C1.24999 9.69558 1.24999 10.6731 1.25 11.9026V12.0974C1.24999 13.3268 1.24999 14.3044 1.32829 15.0993C1.40896 15.9182 1.57718 16.6058 1.95112 17.2408C2.32632 17.878 2.84154 1Line truncated
|
||||
</svg>
|
||||
<span class='sr-only'>gleam-drive</span>
|
||||
</a>
|
||||
<div class='flex'>
|
||||
<a href='/logout'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke-width='2'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
class='h-5 w-5'
|
||||
>
|
||||
<path d='M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4' />
|
||||
<polyline points='16 17 21 12 16 7' />
|
||||
<line x1='21' x2='9' y1='12' y2='12' />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class='flex flex-col items-center h-full w-full my-5'>
|
||||
<div class='space-y-6 w-1/2'>
|
||||
<div class='flex items-center justify-between flex-col gap-4'>
|
||||
<div class='flex items-center justify-between mb-4'>
|
||||
<div>
|
||||
<h1 class='text-2xl font-bold text-center'>FILENAME</h1>
|
||||
<div class='space-y-4'>
|
||||
<div class='bg-muted rounded-md p-4'>
|
||||
<div class='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<p class='font-bold text-md'>File Size</p>
|
||||
<p class='text-muted-foreground text-sm'>FILESIZE KB</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class='font-bold text-md'>Last Modified</p>
|
||||
<p class='text-muted-foreground text-sm'>CREATED_AT</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='items-center flex justify-end gap-2'>
|
||||
<form class='flex h-10 w-full' action='/share?filename=FILENAME' method='POST'>
|
||||
<div class='flex flex-row justify-center items-center h h-full w-full gap-4'>
|
||||
<input
|
||||
class='flex w-5/6 bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 w-full rounded-lg px-4 py-2 border border-accent focus:border-primary focus:ring-primary'
|
||||
placeholder='Recepient ...'
|
||||
type='email'
|
||||
id='recepient'
|
||||
name='recepient''
|
||||
required
|
||||
/>
|
||||
<button type='submit' class='h-10 rounded-md text-md px-5 bg-background hover:bg-gray-200 font-bold border border-accent rounded'>
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class='items-center flex justify-end gap-2 w-1/2 px-20'>
|
||||
<a href='download?token=DOWNLOAD_LINK' class='h-10 w-full rounded-md text-md px-3 bg-background hover:bg-gray-200 font-bold border border-accent rounded'>
|
||||
<p class='flex h-full items-center justify-center'>Download</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</html>"
|
||||
|
||||
pub const no_files_html = "
|
||||
<div class='flex flex-col items-center justify-center gap-4'>
|
||||
<h1 class='text-muted-foreground text-gray-500 md:text-xl'>No files found</h1>
|
||||
</div>
|
||||
"
|
||||
|
||||
pub const some_files_html = "
|
||||
<div class='divide-y rounded-md border'>
|
||||
FILES
|
||||
</div>
|
||||
"
|
||||
|
||||
pub const file_html = "
|
||||
<div class='flex items-center justify-between gap-4 p-4'>
|
||||
<div class='flex items-center gap-4'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke-width='2'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
class='h-8 w-8 text-muted-foreground'
|
||||
>
|
||||
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'></path>
|
||||
<path d='M14 2v4a2 2 0 0 0 2 2h4'></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class='font-medium'>FILENAME</p>
|
||||
<p class='text-sm text-muted-foreground'>FILESIZE KB</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class='flex items-center gap-2'>
|
||||
<a href='/details?filename=FILENAME' class='inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-gray-200 hover:text-accent-foreground h-10 w-10'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke-width='2'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
class='h-5 w-5'
|
||||
>
|
||||
<path d='M15.7955 15.8111L21 21M18 10.5C18 14.6421 14.6421 18 10.5 18C6.35786 18 3 14.6421 3 10.5C3 6.35786 6.35786 3 10.5 3C14.6421 3 18 6.35786 18 10.5Z' stroke='#000000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'></path>
|
||||
</svg>
|
||||
<span class='sr-only'>Details</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
"
|
||||
|
||||
pub fn render_error_html(message: String) -> String {
|
||||
string.replace(error_html, "ERROR_MESSAGE", message)
|
||||
}
|
||||
|
||||
pub fn render_files_html(files: List(model.Upload)) -> String {
|
||||
list.map(files, fn(file) {
|
||||
string.replace(
|
||||
some_files_html,
|
||||
"FILES",
|
||||
string.replace(
|
||||
string.replace(file_html, "FILENAME", file.filename),
|
||||
"FILESIZE",
|
||||
int.to_string(file.filesize / 1024),
|
||||
),
|
||||
)
|
||||
})
|
||||
|> string_builder.from_strings
|
||||
|> string_builder.to_string
|
||||
}
|
||||
|
||||
pub fn render_drives_html(files: List(model.Upload)) -> String {
|
||||
case list.length(files) {
|
||||
0 -> string.replace(drive_html, "FILES_LIST", no_files_html)
|
||||
_ -> string.replace(drive_html, "FILES_LIST", render_files_html(files))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_detail_html(file: model.Upload, download_link: String) -> String {
|
||||
string.replace(
|
||||
string.replace(
|
||||
string.replace(
|
||||
string.replace(detail_html, "FILENAME", file.filename),
|
||||
"FILESIZE",
|
||||
int.to_string(file.filesize / 1024),
|
||||
),
|
||||
"CREATED_AT",
|
||||
file.timestamp,
|
||||
),
|
||||
"DOWNLOAD_LINK",
|
||||
download_link,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn render_share_html(download_link: String) -> String {
|
||||
string.replace(share_html, "DOWNLOAD_LINK", download_link)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user