Showing preview only (261K chars total). Download the full file or copy to clipboard to get everything.
Repository: anthonybudd/s3-from-scratch
Branch: master
Commit: ed3b50a45e32
Files: 171
Total size: 224.5 KB
Directory structure:
gitextract_fbxgv27m/
├── .gitignore
├── ReadMe.md
├── ansible/
│ ├── .gitignore
│ ├── .yamllint
│ ├── README.md
│ ├── ansible.cfg
│ ├── inventory/
│ │ ├── .gitignore
│ │ └── example/
│ │ ├── group_vars/
│ │ │ └── all.yml
│ │ └── hosts.ini
│ ├── reset.yml
│ ├── roles/
│ │ ├── download/
│ │ │ └── tasks/
│ │ │ └── main.yml
│ │ ├── k3s/
│ │ │ ├── master/
│ │ │ │ ├── tasks/
│ │ │ │ │ └── main.yml
│ │ │ │ └── templates/
│ │ │ │ └── k3s.service.j2
│ │ │ └── node/
│ │ │ ├── tasks/
│ │ │ │ └── main.yml
│ │ │ └── templates/
│ │ │ └── k3s.service.j2
│ │ ├── prereq/
│ │ │ └── tasks/
│ │ │ └── main.yml
│ │ ├── raspberrypi/
│ │ │ ├── handlers/
│ │ │ │ └── main.yml
│ │ │ └── tasks/
│ │ │ ├── main.yml
│ │ │ └── prereq/
│ │ │ ├── CentOS.yml
│ │ │ ├── Raspbian.yml
│ │ │ ├── Ubuntu.yml
│ │ │ └── default.yml
│ │ └── reset/
│ │ └── tasks/
│ │ ├── main.yml
│ │ └── umount_with_children.yml
│ └── site.yml
├── api/
│ ├── .dockerignore
│ ├── .eslintignore
│ ├── .gitignore
│ ├── .gitlab-ci.yml
│ ├── .sequelizerc
│ ├── Dockerfile
│ ├── LICENSE
│ ├── ReadMe.md
│ ├── docker-compose.yml
│ ├── k8s/
│ │ ├── Deploy.md
│ │ ├── api.deployment.yml
│ │ ├── api.ingress.yml
│ │ ├── api.service.yml
│ │ ├── api.ssl.ingress.yml
│ │ ├── db.yml
│ │ ├── prod.clusterissuer.yml
│ │ ├── secrets.example.yml
│ │ └── sync.job.yml
│ ├── package.json
│ ├── postman.json
│ ├── requests.http
│ ├── src/
│ │ ├── database/
│ │ │ ├── migrations/
│ │ │ │ ├── 20180726090304-create-Users.js
│ │ │ │ ├── 20180726090404-create-Groups.js
│ │ │ │ ├── 20180726090405-create-GroupsUsers.js
│ │ │ │ ├── 20240411041313-create-Buckets.js
│ │ │ │ └── 20240430101608-create-Blacklist.js
│ │ │ └── seeders/
│ │ │ ├── 20180726092449-Users.js
│ │ │ ├── 20180726093449-Group.js
│ │ │ ├── 20180726093449-GroupsUsers.js
│ │ │ ├── 20240411041313-Buckets.js
│ │ │ └── 20240430101608-Blacklist.js
│ │ ├── index.js
│ │ ├── models/
│ │ │ ├── Blacklist.js
│ │ │ ├── Bucket.js
│ │ │ ├── Group.js
│ │ │ ├── GroupsUsers.js
│ │ │ ├── User.js
│ │ │ └── index.js
│ │ ├── providers/
│ │ │ ├── bucket.yml
│ │ │ ├── connections.js
│ │ │ ├── db.js
│ │ │ ├── errorHandler.js
│ │ │ ├── generateJWT.js
│ │ │ ├── hCaptcha.js
│ │ │ └── passport.js
│ │ ├── routes/
│ │ │ ├── Buckets.js
│ │ │ ├── auth.js
│ │ │ ├── groups.js
│ │ │ ├── middleware/
│ │ │ │ ├── canAccessBucket.js
│ │ │ │ ├── checkPassword.js
│ │ │ │ ├── hCaptcha.js
│ │ │ │ ├── index.js
│ │ │ │ ├── isGroupOwner.js
│ │ │ │ ├── isInGroup.js
│ │ │ │ └── isNotSelf.js
│ │ │ └── user.js
│ │ └── scripts/
│ │ ├── blacklist.js
│ │ ├── buckets.js
│ │ ├── deleteUser.js
│ │ ├── env
│ │ ├── forgotPassword.js
│ │ ├── generate.js
│ │ ├── generator/
│ │ │ ├── Migration.js
│ │ │ ├── Model.js
│ │ │ ├── Route.js
│ │ │ └── Seeder.js
│ │ ├── inviteUser.js
│ │ ├── jwt.js
│ │ ├── refresh
│ │ ├── resetPassword.js
│ │ ├── seed
│ │ ├── sync.js
│ │ └── users.js
│ └── tests/
│ ├── Auth.js
│ ├── Group.js
│ ├── HealthCheck.js
│ └── User.js
├── automation-test/
│ ├── .gitlab-ci.yml
│ ├── Dockerfile
│ ├── bucket.yml
│ └── deployment.yml
├── aws-sdk-test/
│ ├── .gitignore
│ ├── index.js
│ └── package.json
├── deployment-test/
│ ├── .gitlab-ci.yml
│ ├── Dockerfile
│ ├── index.html
│ └── k8s.yml
├── frontend/
│ ├── .browserslistrc
│ ├── .editorconfig
│ ├── .eslintrc.js
│ ├── .gitignore
│ ├── .gitlab-ci.yml
│ ├── Dockerfile
│ ├── ReadMe.md
│ ├── default.conf
│ ├── index.html
│ ├── jsconfig.json
│ ├── k8s/
│ │ ├── clusterissuer.yml
│ │ ├── frontend-ssl.ingress.yml
│ │ ├── frontend.deployment.yml
│ │ ├── frontend.ingress.yml
│ │ └── frontend.service.yml
│ ├── package.json
│ ├── src/
│ │ ├── App.vue
│ │ ├── api/
│ │ │ ├── Auth.js
│ │ │ ├── Buckets.js
│ │ │ ├── Service.js
│ │ │ ├── User.js
│ │ │ └── index.js
│ │ ├── components/
│ │ │ ├── CreateBucketForm.vue
│ │ │ └── TermsOfService.vue
│ │ ├── layouts/
│ │ │ └── default/
│ │ │ ├── AppBar.vue
│ │ │ ├── Auth.vue
│ │ │ ├── Default.vue
│ │ │ └── View.vue
│ │ ├── main.js
│ │ ├── plugins/
│ │ │ ├── errorHandler.js
│ │ │ ├── index.js
│ │ │ ├── router.js
│ │ │ ├── store.js
│ │ │ ├── vuetify.js
│ │ │ └── webfontloader.js
│ │ ├── styles/
│ │ │ └── settings.scss
│ │ └── views/
│ │ ├── Buckets.vue
│ │ ├── Login.vue
│ │ └── SignUp.vue
│ └── vite.config.js
├── k3s/
│ ├── alpine.deployment.yml
│ ├── echo.s3.ssl.yml
│ ├── echo.ssl.yml
│ └── echo.yml
├── longhorn/
│ ├── longhorn.ingress.yml
│ ├── longhorn.lb.yml
│ └── longhorn.storageclass.yml
├── node/
│ └── node-config-script.sh
└── sections/
├── automated-bucket-deployment.md
├── console.md
├── deploying-from-gitlab-to-k3s.md
├── gitlab.md
├── internet.md
├── networking.md
├── node.md
├── production-cluster.md
├── ssl.md
└── storage-cluster.md
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
.DS_Store
notes/*
*.crt
*.k8s
api_
frontend_
website_
================================================
FILE: ReadMe.md
================================================
# S3 From Scratch
<p align="center">
<img width="300" src="https://raw.githubusercontent.com/anthonybudd/s3-from-scratch/master/_img/s3.png">
</p>
For the past few years I’ve been thinking about how I could build SaaS and deploy it on my own infrastructure without needing to use any cloud platforms like AWS or GCP. In this repo I document my progress on building a clone of AWS S3 that functions the same as S3 (automated bucket deployment, dynamically expanding volumes, security, etc) using an exclusively open-source technology stack.
- [Console](./sections/console.md)
- [Nodes](./sections/node.md)
- [Source Control: GitLab](./sections/gitlab.md)
- [K3s: Production Cluster](./sections/production-cluster.md)
- [Deploying From GitLab Registry To Local K3s Cluster](./sections/deploying-from-gitlab-to-k3s.md)
- [K3s: Storage Cluster](./sections/storage-cluster.md)
- [Automated Bucket Deployment](./sections/automated-bucket-deployment.md)
- [API](./api/ReadMe.md)
- [Frontend](./frontend/ReadMe.md)
- [Connecting to the Internet](./sections/internet.md)
### Live POC working with `@aws-sdk/client-s3`
<p align="center">
<img width="500" src="https://raw.githubusercontent.com/anthonybudd/s3-from-scratch/master/_img/live-demo.gif">
</p>
```js
const { S3Client, ListObjectsV2Command, PutObjectCommand } = require("@aws-sdk/client-s3");
const Bucket = 'BUCKET_NAME_HERE';
const Namespace = 'NAMESPACE_HERE';
const accessKeyId = "xxxxxxxxxxxxxxxxxxxx";
const secretAccessKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
(async function () {
const client = new S3Client({
region: 'us-west-2',
endpoint: `https://${Bucket}.${Namespace}.s3.anthonybudd.io`,
forcePathStyle: true,
sslEnabled: true,
credentials: {
accessKeyId,
secretAccessKey
},
});
const Key = `${Date.now().toString()}.txt`;
await client.send(new PutObjectCommand({
Bucket,
Key,
Body: `The time now is ${new Date().toLocaleString()}`,
ACL: 'public-read',
ContentType: 'text/plain',
}));
console.log(`New object successfully written to: ${Bucket}://${Key}\n`);
const { Contents } = await client.send(new ListObjectsV2Command({ Bucket }));
console.log("Bucket Contents:");
console.log(Contents.map(({ Key }) => Key).join("\n"));
})();
```
### Technical Overview
<p align="center">
<img src="https://raw.githubusercontent.com/anthonybudd/s3-from-scratch/master/_img/infrastructure.png?v=4">
</p>
### [Node](./sections/node.md)
<img height="200" src="https://raw.githubusercontent.com/anthonybudd/s3-from-scratch/master/_img/node.png">
Since this project needs to be "Enterprise-grade" we need a distinct and replicable compute unit that we can buy and build in bulk. I call this a "Node" which is a Raspberry Pi with a 1TB SSD and POE hat. I have also 3D printed a rack-mount solution (Source: [Merocle From UpTimeLabs](https://www.thingiverse.com/thing:4756812)) for easy install into a rack.
### [Console](./sections/console.md)
<img height="200" src="https://raw.githubusercontent.com/anthonybudd/s3-from-scratch/master/_img/console-close-up.png">
We will need a "console" so we can locally interact with the infrastructure. In the past I have tried using a Raspberry Pi with a monitor and keyboard attached but I have found that using an old MacBook Pro works best for this. In this section I explain how to set-up the console so you can use it to store secrets, manage the network, provision K3s clusters and deploy pods.
### [Frontend](./frontend/ReadMe.md)
<img height="250" src="https://raw.githubusercontent.com/anthonybudd/s3-from-scratch/master/_img/frontend.gif">
This represents the AWS management console found at [aws.amazon.com/console](https://aws.amazon.com/console/). This is a Vue.js SPA that makes HTTP requests to the [S3 REST API](./api/ReadMe.md) for users to create, manage and delete their S3 buckets.
### [API](./api/ReadMe.md)
```sh
curl -X POST \
-H 'Authorization: Bearer $JWT' \
-H 'Content-Type: application/json' \
-d '{ "name":"s3-test-bucket"}' \
https://s3-api.anthonybudd.io/buckets
```
This API simulates the back-end of the AWS Console. A user can sign-up, login, create a bucket then delete the bucket.
### [Source Control](./sections/gitlab.md)
<img height="75" src="https://raw.githubusercontent.com/anthonybudd/s3-from-scratch/master/_img/gitlab-logo.svg">
We will need a way to store the code for the landing website, the front-end ui and the back-end REST API. We will also need CI/CD to compile the code and deploy it into our infrastructure. GitLab will work perfectly for these two tasks. This will allow us to commit code to the local GitLab instance, compile it into a Docker image, store the image in a repository and deploy from it directy into our local kubernetes cluster.
### [Networking](./sections/networking.md)
<img height="75" src="https://raw.githubusercontent.com/anthonybudd/s3-from-scratch/master/_img/openwrt_.png">
We will need a network for the nodes to communicate. For the a router I have chosen OpenWRT. This allows me to use a Raspberry Pi with a USB 3.0 Ethernet adapter so it can work as a router between the internet and the datacenter.
### [Automation](./sections/automated-bucket-deployment.md)
When you create an S3 bucket on AWS, everything is automated, there isn’t a human in a datacenter somewhere typing out CLI commands to get your bucket scheduled. I want my project to work the same way, when a user wants to create a bucket it must not require any human input to provision and deploy it.
### [Resource Utilization](./sections/storage-cluster.md)
AWS doesn't give each user their own dedicated server with a hard drive attached, instead the hardware is virtualized, allowing multiple tenants to share a single physical CPU. Similarly it would not be practical to assign a whole node and SSD to each bucket, to maximize resource utilization my platform must be able to allow multiple tenants to share the pool of compute and SSD storage space available. In addition, AWS S3 buckets can store an unlimited amount of data, so my platform will also need to allow a user to have a dynamically increasing volume that will auto-scale based on the storage space required.
### Notes
You will need to SSH into multiple devices simultaneously I have added an annotation (example: `[Console] nano /boot/config.txt`) to all commands in this repo, to show where you should be executing each command. Generally you will see `[Console]` and `[Node X]`.
Because this is still very much a work-in-progress you will see my notes in italics "_AB:_" throughout, please ignore.
================================================
FILE: ansible/.gitignore
================================================
Notes.md
================================================
FILE: ansible/.yamllint
================================================
---
extends: default
rules:
line-length:
max: 120
level: warning
truthy:
allowed-values: ['true', 'false', 'yes', 'no']
================================================
FILE: ansible/README.md
================================================
# Ansible
Source: [https://github.com/k3s-io/k3s-ansible](https://github.com/k3s-io/k3s-ansible)
================================================
FILE: ansible/ansible.cfg
================================================
[defaults]
nocows = True
roles_path = ./roles
inventory = ./hosts.ini
remote_tmp = $HOME/.ansible/tmp
local_tmp = $HOME/.ansible/tmp
pipelining = True
become = True
host_key_checking = False
deprecation_warnings = False
callback_whitelist = profile_tasks
================================================
FILE: ansible/inventory/.gitignore
================================================
*-cluster/
!exmaple/
!.gitignore
!sample/
================================================
FILE: ansible/inventory/example/group_vars/all.yml
================================================
---
k3s_version: v1.26.9+k3s1
ansible_user: node
systemd_dir: /etc/systemd/system
master_ip: "{{ hostvars[groups['master'][0]]['ansible_host'] | default(groups['master'][0]) }}"
extra_server_args: ""
extra_agent_args: ""
================================================
FILE: ansible/inventory/example/hosts.ini
================================================
[master]
10.0.0.5
[node]
10.0.0.5
10.0.0.6
10.0.0.7
[k3s_cluster:children]
master
node
================================================
FILE: ansible/reset.yml
================================================
---
- hosts: k3s_cluster
gather_facts: yes
become: yes
roles:
- role: reset
================================================
FILE: ansible/roles/download/tasks/main.yml
================================================
---
- name: Download k3s binary x64
get_url:
url: https://github.com/k3s-io/k3s/releases/download/{{ k3s_version }}/k3s
checksum: sha256:https://github.com/k3s-io/k3s/releases/download/{{ k3s_version }}/sha256sum-amd64.txt
dest: /usr/local/bin/k3s
owner: root
group: root
mode: 0755
when: ansible_facts.architecture == "x86_64"
- name: Download k3s binary arm64
get_url:
url: https://github.com/k3s-io/k3s/releases/download/{{ k3s_version }}/k3s-arm64
checksum: sha256:https://github.com/k3s-io/k3s/releases/download/{{ k3s_version }}/sha256sum-arm64.txt
dest: /usr/local/bin/k3s
owner: root
group: root
mode: 0755
when:
- ( ansible_facts.architecture is search("arm") and
ansible_facts.userspace_bits == "64" ) or
ansible_facts.architecture is search("aarch64")
- name: Download k3s binary armhf
get_url:
url: https://github.com/k3s-io/k3s/releases/download/{{ k3s_version }}/k3s-armhf
checksum: sha256:https://github.com/k3s-io/k3s/releases/download/{{ k3s_version }}/sha256sum-arm.txt
dest: /usr/local/bin/k3s
owner: root
group: root
mode: 0755
when:
- ansible_facts.architecture is search("arm")
- ansible_facts.userspace_bits == "32"
================================================
FILE: ansible/roles/k3s/master/tasks/main.yml
================================================
---
- name: Copy K3s service file
register: k3s_service
template:
src: "k3s.service.j2"
dest: "{{ systemd_dir }}/k3s.service"
owner: root
group: root
mode: 0644
- name: Enable and check K3s service
systemd:
name: k3s
daemon_reload: yes
state: restarted
enabled: yes
- name: Wait for node-token
wait_for:
path: /var/lib/rancher/k3s/server/node-token
- name: Register node-token file access mode
stat:
path: /var/lib/rancher/k3s/server
register: p
- name: Change file access node-token
file:
path: /var/lib/rancher/k3s/server
mode: "g+rx,o+rx"
- name: Read node-token from master
slurp:
src: /var/lib/rancher/k3s/server/node-token
register: node_token
- name: Store Master node-token
set_fact:
token: "{{ node_token.content | b64decode | regex_replace('\n', '') }}"
- name: Restore node-token file access
file:
path: /var/lib/rancher/k3s/server
mode: "{{ p.stat.mode }}"
- name: Create directory .kube
file:
path: ~{{ ansible_user }}/.kube
state: directory
owner: "{{ ansible_user }}"
mode: "u=rwx,g=rx,o="
- name: Copy config file to user home directory
copy:
src: /etc/rancher/k3s/k3s.yaml
dest: ~{{ ansible_user }}/.kube/config
remote_src: yes
owner: "{{ ansible_user }}"
mode: "u=rw,g=,o="
- name: Replace https://localhost:6443 by https://master-ip:6443
command: >-
k3s kubectl config set-cluster default
--server=https://{{ master_ip }}:6443
--kubeconfig ~{{ ansible_user }}/.kube/config
changed_when: true
- name: Create kubectl symlink
file:
src: /usr/local/bin/k3s
dest: /usr/local/bin/kubectl
state: link
- name: Create crictl symlink
file:
src: /usr/local/bin/k3s
dest: /usr/local/bin/crictl
state: link
================================================
FILE: ansible/roles/k3s/master/templates/k3s.service.j2
================================================
[Unit]
Description=Lightweight Kubernetes
Documentation=https://k3s.io
After=network-online.target
[Service]
Type=notify
ExecStartPre=-/sbin/modprobe br_netfilter
ExecStartPre=-/sbin/modprobe overlay
ExecStart=/usr/local/bin/k3s server {{ extra_server_args | default("") }}
KillMode=process
Delegate=yes
# Having non-zero Limit*s causes performance problems due to accounting overhead
# in the kernel. We recommend using cgroups to do container-local accounting.
LimitNOFILE=1048576
LimitNPROC=infinity
LimitCORE=infinity
TasksMax=infinity
TimeoutStartSec=0
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
================================================
FILE: ansible/roles/k3s/node/tasks/main.yml
================================================
---
- name: Copy K3s service file
template:
src: "k3s.service.j2"
dest: "{{ systemd_dir }}/k3s-node.service"
owner: root
group: root
mode: 0755
- name: Enable and check K3s service
systemd:
name: k3s-node
daemon_reload: yes
state: restarted
enabled: yes
================================================
FILE: ansible/roles/k3s/node/templates/k3s.service.j2
================================================
[Unit]
Description=Lightweight Kubernetes
Documentation=https://k3s.io
After=network-online.target
[Service]
Type=notify
ExecStartPre=-/sbin/modprobe br_netfilter
ExecStartPre=-/sbin/modprobe overlay
ExecStart=/usr/local/bin/k3s agent --server https://{{ master_ip }}:6443 --token {{ hostvars[groups['master'][0]]['token'] }} {{ extra_agent_args | default("") }}
KillMode=process
Delegate=yes
# Having non-zero Limit*s causes performance problems due to accounting overhead
# in the kernel. We recommend using cgroups to do container-local accounting.
LimitNOFILE=1048576
LimitNPROC=infinity
LimitCORE=infinity
TasksMax=infinity
TimeoutStartSec=0
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
================================================
FILE: ansible/roles/prereq/tasks/main.yml
================================================
---
- name: Set SELinux to disabled state
selinux:
state: disabled
when: ansible_distribution in ['CentOS', 'Red Hat Enterprise Linux']
- name: Enable IPv4 forwarding
sysctl:
name: net.ipv4.ip_forward
value: "1"
state: present
reload: yes
- name: Enable IPv6 forwarding
sysctl:
name: net.ipv6.conf.all.forwarding
value: "1"
state: present
reload: yes
- name: Add br_netfilter to /etc/modules-load.d/
copy:
content: "br_netfilter"
dest: /etc/modules-load.d/br_netfilter.conf
mode: "u=rw,g=,o="
when: ansible_distribution in ['CentOS', 'Red Hat Enterprise Linux']
- name: Load br_netfilter
modprobe:
name: br_netfilter
state: present
when: ansible_distribution in ['CentOS', 'Red Hat Enterprise Linux']
- name: Set bridge-nf-call-iptables (just to be sure)
sysctl:
name: "{{ item }}"
value: "1"
state: present
reload: yes
when: ansible_distribution in ['CentOS', 'Red Hat Enterprise Linux']
loop:
- net.bridge.bridge-nf-call-iptables
- net.bridge.bridge-nf-call-ip6tables
- name: Add /usr/local/bin to sudo secure_path
lineinfile:
line: 'Defaults secure_path = /sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin'
regexp: "Defaults(\\s)*secure_path(\\s)*="
state: present
insertafter: EOF
path: /etc/sudoers
validate: 'visudo -cf %s'
when: ansible_distribution in ['CentOS', 'Red Hat Enterprise Linux']
================================================
FILE: ansible/roles/raspberrypi/handlers/main.yml
================================================
---
- name: reboot
reboot:
================================================
FILE: ansible/roles/raspberrypi/tasks/main.yml
================================================
---
- name: Test for raspberry pi /proc/cpuinfo
command: grep -E "Raspberry Pi|BCM2708|BCM2709|BCM2835|BCM2836" /proc/cpuinfo
register: grep_cpuinfo_raspberrypi
failed_when: false
changed_when: false
- name: Test for raspberry pi /proc/device-tree/model
command: grep -E "Raspberry Pi" /proc/device-tree/model
register: grep_device_tree_model_raspberrypi
failed_when: false
changed_when: false
- name: Set raspberry_pi fact to true
set_fact:
raspberry_pi: true
when:
grep_cpuinfo_raspberrypi.rc == 0 or grep_device_tree_model_raspberrypi.rc == 0
- name: Set detected_distribution to Raspbian
set_fact:
detected_distribution: Raspbian
when: >
raspberry_pi|default(false) and
( ansible_facts.lsb.id|default("") == "Raspbian" or
ansible_facts.lsb.description|default("") is match("[Rr]aspbian.*") )
- name: Set detected_distribution to Raspbian (ARM64 on Debian Buster)
set_fact:
detected_distribution: Raspbian
when:
- ansible_facts.architecture is search("aarch64")
- raspberry_pi|default(false)
- ansible_facts.lsb.description|default("") is match("Debian.*buster")
- name: Set detected_distribution_major_version
set_fact:
detected_distribution_major_version: "{{ ansible_facts.lsb.major_release }}"
when:
- detected_distribution | default("") == "Raspbian"
- name: execute OS related tasks on the Raspberry Pi
include_tasks: "{{ item }}"
with_first_found:
- "prereq/{{ detected_distribution }}-{{ detected_distribution_major_version }}.yml"
- "prereq/{{ detected_distribution }}.yml"
- "prereq/{{ ansible_distribution }}-{{ ansible_distribution_major_version }}.yml"
- "prereq/{{ ansible_distribution }}.yml"
- "prereq/default.yml"
when:
- raspberry_pi|default(false)
================================================
FILE: ansible/roles/raspberrypi/tasks/prereq/CentOS.yml
================================================
---
- name: Enable cgroup via boot commandline if not already enabled for Centos
lineinfile:
path: /boot/cmdline.txt
backrefs: yes
regexp: '^((?!.*\bcgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory\b).*)$'
line: '\1 cgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory'
notify: reboot
================================================
FILE: ansible/roles/raspberrypi/tasks/prereq/Raspbian.yml
================================================
---
- name: Activating cgroup support
lineinfile:
path: /boot/cmdline.txt
regexp: '^((?!.*\bcgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory\b).*)$'
line: '\1 cgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory'
backrefs: true
notify: reboot
- name: Flush iptables before changing to iptables-legacy
iptables:
flush: true
changed_when: false # iptables flush always returns changed
- name: Changing to iptables-legacy
alternatives:
path: /usr/sbin/iptables-legacy
name: iptables
register: ip4_legacy
- name: Changing to ip6tables-legacy
alternatives:
path: /usr/sbin/ip6tables-legacy
name: ip6tables
register: ip6_legacy
================================================
FILE: ansible/roles/raspberrypi/tasks/prereq/Ubuntu.yml
================================================
---
- name: Enable cgroup via boot commandline if not already enabled for Ubuntu on a Raspberry Pi
lineinfile:
path: /boot/firmware/cmdline.txt
backrefs: yes
regexp: '^((?!.*\bcgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory\b).*)$'
line: '\1 cgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory'
notify: reboot
================================================
FILE: ansible/roles/raspberrypi/tasks/prereq/default.yml
================================================
---
================================================
FILE: ansible/roles/reset/tasks/main.yml
================================================
---
- name: Disable services
systemd:
name: "{{ item }}"
state: stopped
enabled: no
failed_when: false
with_items:
- k3s
- k3s-node
- name: pkill -9 -f "k3s/data/[^/]+/bin/containerd-shim-runc"
register: pkill_containerd_shim_runc
command: pkill -9 -f "k3s/data/[^/]+/bin/containerd-shim-runc"
changed_when: "pkill_containerd_shim_runc.rc == 0"
failed_when: false
- name: Umount k3s filesystems
include_tasks: umount_with_children.yml
with_items:
- /run/k3s
- /var/lib/kubelet
- /run/netns
- /var/lib/rancher/k3s
loop_control:
loop_var: mounted_fs
- name: Remove service files, binaries and data
file:
name: "{{ item }}"
state: absent
with_items:
- /usr/local/bin/k3s
- "{{ systemd_dir }}/k3s.service"
- "{{ systemd_dir }}/k3s-node.service"
- /etc/rancher/k3s
- /var/lib/kubelet
- /var/lib/rancher/k3s
- name: daemon_reload
systemd:
daemon_reload: yes
================================================
FILE: ansible/roles/reset/tasks/umount_with_children.yml
================================================
---
- name: Get the list of mounted filesystems
shell: set -o pipefail && cat /proc/mounts | awk '{ print $2}' | grep -E "^{{ mounted_fs }}"
register: get_mounted_filesystems
args:
executable: /bin/bash
failed_when: false
changed_when: get_mounted_filesystems.stdout | length > 0
check_mode: false
- name: Umount filesystem
mount:
path: "{{ item }}"
state: unmounted
with_items:
"{{ get_mounted_filesystems.stdout_lines | reverse | list }}"
================================================
FILE: ansible/site.yml
================================================
---
- hosts: k3s_cluster
gather_facts: yes
become: yes
roles:
- role: prereq
- role: download
- role: raspberrypi
- hosts: master
become: yes
roles:
- role: k3s/master
- hosts: node
become: yes
roles:
- role: k3s/node
================================================
FILE: api/.dockerignore
================================================
node_modules
package-lock.json
================================================
FILE: api/.eslintignore
================================================
src/database/
tests
================================================
FILE: api/.gitignore
================================================
node_modules/
.vol/
.env
dev.js
private.pem
public.pem
.DS_Store
*/.DS_Store
k8s/secrets.yml
kubeconfig.yml
================================================
FILE: api/.gitlab-ci.yml
================================================
stages:
- build
build-job:
image: docker:dind
stage: build
services:
- docker:dind
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
script:
- docker login $CI_REGISTRY -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD
- docker build -t $IMAGE_TAG .
- docker push $IMAGE_TAG
================================================
FILE: api/.sequelizerc
================================================
const path = require('path');
module.exports = {
'config': path.resolve('src/providers', 'connections.js'),
'models-path': path.resolve('src/', 'models'),
'seeders-path': path.resolve('src/database', 'seeders'),
'migrations-path': path.resolve('src/database', 'migrations')
}
================================================
FILE: api/Dockerfile
================================================
FROM node:20
RUN apt-get update && apt-get install -y curl nano
RUN curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/arm64/kubectl"
RUN install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
RUN npm install -g nodemon mocha sequelize sequelize-cli mysql2 eslint
WORKDIR /app
COPY . /app
RUN npm install
ENTRYPOINT [ "node", "/app/src/index.js" ]
================================================
FILE: api/LICENSE
================================================
The MIT License
Copyright Anthony C. Budd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================
FILE: api/ReadMe.md
================================================
# S3 API
This API simulates the back-end of the AWS Console. A user can sign-up, login, create a bucket then delete the bucket.
This API was built using my project [anthonybudd/express-api-boilerplate.](https://github.com/anthonybudd/express-api-boilerplate)
### Main Files
- Auth Controller: [./src/routes/Auth.js](./src/routes/auth.js)
- Bucket Controller: [./src/routes/Buckets.js](./src/routes/Buckets.js)
- Model: [./src/models/Bucket.js](./src/models/Bucket.js)
### Set-up
```
cp .env.example .env
npm install
# Private RSA key for JWT signing
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
# Start the app
docker compose up
npm run _db:refresh
npm run _test
```
### Routes
| Method | Route | Description | Payload | Response |
| ----------- | -------------------------------- | ------------------------------------- | ------------------------------------- | ----------------- |
| **Buckets** | | | | |
| GET | `/api/v1/buckets` | Get all buckets for the current user | -- | [Bucket, Bucket] |
| POST | `/api/v1/buckets` | Create new bucket | { name: "test-bucket" } | {Bucket} |
| GET | `/api/v1/buckets/:bucketID` | Get a single bucket | -- | {Bucket} |
| DELETE | `/api/v1/buckets/:bucketID` | Returns HTTP 202 {id} | -- | {bucketID} |
| **Auth** | | | | |
| POST | `/api/v1/auth/login` | Login | {email, password} | {accessToken} |
| POST | `/api/v1/auth/sign-up` | Sign-up | {email, password, firstName, tos} | {accessToken} |
| GET | `/api/v1/_authcheck` | Returns {auth: true} if has auth | -- | {auth: true} |
| **User** | | | | |
| GET | `/api/v1/user` | Get the current user | | {User} |
| POST | `/api/v1/user` | Update the current user | {firstName, lastName} | {User} |
================================================
FILE: api/docker-compose.yml
================================================
version: "3"
services:
s3-api:
build: .
entrypoint: "nodemon /app/src/index.js --watch /app --legacy-watch"
container_name: s3-api
volumes:
- ./:/app
- ./.vol/tmp:/tmp
links:
- s3-api-db
- s3-api-db-test
ports:
- "8888:80"
environment:
PORT: 80
s3-api-db:
image: mysql:oracle
container_name: s3-api-db
ports:
- "3306:3306"
volumes:
- ./.vol/s3-api:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: supersecret
MYSQL_DATABASE: $DB_DATABASE
MYSQL_USER: $DB_USERNAME
MYSQL_PASSWORD: $DB_PASSWORD
s3-api-db-test:
image: mysql:oracle
container_name: s3-api-db-test
ports:
- "3307:3306"
volumes:
- ./.vol/s3-api-test:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: supersecret
MYSQL_DATABASE: $DB_DATABASE
MYSQL_USER: $DB_USERNAME
MYSQL_PASSWORD: $DB_PASSWORD
================================================
FILE: api/k8s/Deploy.md
================================================
# Deploy
kubectl --kubeconfig=.kube/config create namespace s3-api
namespace/s3-api created
[Console]:~> kubectl --kubeconfig=.kube/config apply -f db.yml
service/s3-db created
deployment.apps/s3-db created
----
Find & Replace (case-sensaive, whole repo): "s3-api" => "your-api-name"
Save kubeconfig.yml to root of repo
### Namespace
Create a namespace
`kubectl --kubeconfig=./kubeconfig.yml create namespace s3-api`
### JWT
```
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
kubectl --kubeconfig=.kube/config --namespace=s3-api create secret generic s3-api-jwt-secret \
--from-file=./private.pem \
--from-file=./public.pem
rm ./private.pem ./public.pem
```
### Secrets
Make a new secrets config file
`cp secrets.example.yml secrets.yml`
__Add Secrets in Base64__
Hint: `echo -n 'my-secret-string' | base64`
Create the secrets
`kubectl --kubeconfig=./kubeconfig.yml apply -f ./k8s/secrets.yml`
### Build & Push Container Image
```
docker buildx build --platform linux/amd64 --push -t registry.digitalocean.com/s3-api/app:latest
```
### Create Deployment
```
kubectl --kubeconfig=./kubeconfig.yml apply -f ./k8s/api.deployment.yml
kubectl --kubeconfig=./kubeconfig.yml apply -f ./k8s/api.service.yml
```
### Deploy
```
docker buildx build --platform linux/amd64 --push -t registry.digitalocean.com/s3-api/app:latest . &&
kubectl --kubeconfig=./kubeconfig.yml rollout restart deployment s3-api && \
kubectl --kubeconfig=./kubeconfig.yml get pods -w
```
### Migrate
Migrate the DB
```
export POD="$(kubectl --kubeconfig=kubeconfig.yml --namespace=s3-api get pods --field-selector=status.phase==Running --no-headers -o custom-columns=":metadata.name")"
kubectl --kubeconfig=./kubeconfig.yml --namespace=s3-api exec -ti $POD -- /bin/bash -c 'sequelize db:migrate:undo:all && sequelize db:migrate && sequelize db:seed:all'
```
### SSL
ReadMore: https://www.digitalocean.com/community/tutorials/how-to-set-up-an-nginx-ingress-with-cert-manager-on-digitalocean-kubernetes
```
kubectl --kubeconfig=./kubeconfig.yml apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.1.1/deploy/static/provider/do/deploy.yaml
kubectl --kubeconfig=./kubeconfig.yml get pods -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx --watch
kubectl --kubeconfig=./kubeconfig.yml apply -f ./k8s/api.ingress.yml
kubectl --kubeconfig=./kubeconfig.yml apply -f https://github.com/jetstack/cert-manager/releases/download/v1.7.1/cert-manager.yaml
kubectl --kubeconfig=./kubeconfig.yml get pods --namespace cert-manager
kubectl --kubeconfig=./kubeconfig.yml create -f k8s/prod-issuer.yml
```
### Useful K8S commands
##### Set $POD as the name of the pod in K8s
`export POD="$(kubectl --kubeconfig=kubeconfig.yml --namespace=s3-api get pods --field-selector=status.phase==Running --no-headers -o custom-columns=":metadata.name")"`
##### Execute bash script inside running container
`kubectl --kubeconfig=kubeconfig.yml exec -ti $POD -- /bin/bash -c "sequelize db:migrate"`
##### Get logs for $POD
`kubectl --kubeconfig=kubeconfig.yml logs $POD`
##### Create a cron job
`kubectl --kubeconfig=kubeconfig.yml create job --from=cronjob/s3-api-cron-job s3-api-cron-job`
##### Delete all faild cron jobs
`kubectl --kubeconfig=kubeconfig.yml delete jobs --field-selector status.successful=0`
================================================
FILE: api/k8s/api.deployment.yml
================================================
apiVersion: apps/v1
kind: Deployment
metadata:
name: s3-api
namespace: s3-api
spec:
replicas: 1
selector:
matchLabels:
app: s3-api
template:
metadata:
labels:
app: s3-api
spec:
volumes:
- name: s3-api-jwt-secret
secret:
secretName: s3-api-jwt-secret
- name: storage-cluster-config
secret:
secretName: storage-cluster-config
containers:
- name: s3-api
image: gitlab.local:5050/anthonybudd/api:master
imagePullPolicy: Always
lifecycle:
postStart:
exec:
command: ["/bin/bash", "-c", "sequelize db:migrate"]
ports:
- containerPort: 80
volumeMounts:
- name: s3-api-jwt-secret
mountPath: "/app/private.pem"
subPath: private.pem
- name: s3-api-jwt-secret
mountPath: "/app/public.pem"
subPath: public.pem
- name: storage-k8s-config
mountPath: "/app/config"
subPath: storage-config
env:
- name: S3_ROOT
value: "s3.anthonybudd.io"
- name: K8S_CONFIG_PATH
value: "/app/k8s-config"
- name: NODE_ENV
value: "production"
- name: FRONTEND_URL
value: "https://s3.anthonybudd.io"
- name: BACKEND_URL
value: "https://s3-api.anthonybudd.io/api/v1"
- name: PORT
value: "80"
- name: PRIVATE_KEY_PATH
value: "/app/private.pem"
- name: PUBLIC_KEY_PATH
value: "/app/public.pem"
- name: DB_HOST
value: "s3-db"
- name: DB_PORT
value: "3306"
- name: DB_USERNAME
value: "app"
- name: DB_DATABASE
value: "app"
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: s3-api-secrets
key: DB_PASSWORD
- name: HCAPTCHA_SECRET
valueFrom:
secretKeyRef:
name: s3-api-secrets
key: HCAPTCHA_SECRET
================================================
FILE: api/k8s/api.ingress.yml
================================================
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
namespace: s3-api
name: s3-ingress
annotations:
kubernetes.io/ingress.class: "traefik"
spec:
rules:
- host: api.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: s3-api
port:
number: 80
================================================
FILE: api/k8s/api.service.yml
================================================
apiVersion: v1
kind: Service
metadata:
name: s3-api
namespace: s3-api
spec:
ports:
- port: 80
targetPort: 80
selector:
app: s3-api
================================================
FILE: api/k8s/api.ssl.ingress.yml
================================================
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
namespace: s3-api
name: s3-ingress
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
kubernetes.io/ingress.class: "traefik"
spec:
tls:
- hosts:
- s3-api.anthonybudd.io
secretName: s3-api-anthonybudd-io-cert
rules:
- host: s3-api.anthonybudd.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: s3-api
port:
number: 80
================================================
FILE: api/k8s/db.yml
================================================
apiVersion: v1
kind: Service
metadata:
name: s3-db
namespace: s3-api
spec:
selector:
app: s3-db
ports:
- protocol: TCP
port: 80
targetPort: 3306
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: s3-db
namespace: s3-api
spec:
selector:
matchLabels:
app: s3-db
strategy:
type: Recreate
template:
metadata:
labels:
app: s3-db
spec:
containers:
- image: mysql:8
name: s3-db
env:
- name: MYSQL_ROOT_PASSWORD
value: password
- name: MYSQL_DATABASE
value: app
- name: MYSQL_USER
value: app
- name: MYSQL_PASSWORD
value: password
ports:
- containerPort: 3306
name: s3-db
# volumeMounts:
# - name: mysql-persistent-storage
# mountPath: /var/lib/mysql
# volumes:
# - name: mysql-persistent-storage
# persistentVolumeClaim:
# claimName: mysql-pv-claim
================================================
FILE: api/k8s/prod.clusterissuer.yml
================================================
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
namespace: cert-manager
spec:
acme:
email: YOUR_EMAIL_ADDRESS
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: traefik
================================================
FILE: api/k8s/secrets.example.yml
================================================
apiVersion: v1
kind: Secret
metadata:
name: s3-api-secrets
namespace: default
type: Opaque
data:
DB_PASSWORD:
================================================
FILE: api/k8s/sync.job.yml
================================================
apiVersion: batch/v1
kind: CronJob
metadata:
name: sync
namespace: s3-api
spec:
schedule: "* * * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
volumes:
- name: storage-k8s-config
secret:
secretName: storage-k8s-config
containers:
- name: s3-api
image: gitlab.local:5050/anthonybudd/api:main
imagePullPolicy: Always
command:
- /bin/bash
- -c
- "node /app/src/scripts/sync.js"
volumeMounts:
- name: storage-k8s-config
mountPath: "/app/storage-config"
subPath: storage-config
env:
- name: S3_ROOT
value: "s3.anthonybudd.io"
- name: K8S_CONFIG_PATH
value: "/app/storage-config"
- name: NODE_ENV
value: "production"
- name: FRONTEND_URL
value: "https://s3.anthonybudd.io"
- name: BACKEND_URL
value: "https://s3-api.anthonybudd.io/api/v1"
- name: PORT
value: "80"
- name: DB_HOST
value: "s3-db"
- name: DB_PORT
value: "3306"
- name: DB_USERNAME
value: "app"
- name: DB_DATABASE
value: "app"
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: s3-api-secrets
key: DB_PASSWORD
- name: HCAPTCHA_SECRET
valueFrom:
secretKeyRef:
name: s3-api-secrets
key: HCAPTCHA_SECRET
================================================
FILE: api/package.json
================================================
{
"name": "s3-api-boilerplate",
"version": "1.0.0",
"main": "./src/index.js",
"author": "Anthony Budd",
"scripts": {
"start": "node ./src/",
"lint": "eslint src",
"_lint": "docker exec -ti s3-api npm run lint",
"jwt": "node ./src/scripts/jwt.js",
"_jwt": "docker exec -ti s3-api npm run jwt",
"env": "./src/scripts/env",
"db:migrate": "sequelize db:migrate",
"db:seed": "./src/scripts/seed",
"db:refresh": "./src/scripts/refresh",
"_db:refresh": "docker exec -ti s3-api npm run db:refresh",
"db:refresh-test": "node_modules/.bin/sequelize db:migrate:undo:all --env test && node_modules/.bin/sequelize db:migrate --env test && node_modules/.bin/sequelize db:seed:all --env test",
"test": "npm run db:refresh-test && mocha --exit --timeout 10000 tests",
"_test": "docker exec -ti s3-api npm run test"
},
"eslintConfig": {
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 8,
"sourceType": "module"
},
"env": {
"node": true,
"es6": true
},
"rules": {
"no-console": 0,
"no-unused-vars": 1
}
},
"dependencies": {
"axios": "^0.24.0",
"bcrypt-nodejs": "0.0.3",
"cors": "^2.4.1",
"dotenv": "^10.0.0",
"express": "^4.8.5",
"express-fileupload": "^1.4.0",
"express-jwt": "^6.1.0",
"express-validator": "^6.13.0",
"faker": "^4.1.0",
"i": "^0.3.6",
"install": "^0.12.1",
"jsonwebtoken": "^5.7.0",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.21",
"minimist": "^1.2.6",
"moment": "^2.30.1",
"morgan": "^1.9.1",
"mustache": "^3.2.1",
"mysql2": "^2.2.5",
"npm": "^7.20.6",
"passport": "^0.4.0",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"sequelize": "^6.11.0",
"sequelize-cli": "^6.3.0",
"sha256": "^0.2.0",
"uuid": "^3.4.0"
},
"devDependencies": {
"chai": "^3.2.0",
"chai-http": "^4.3.0",
"eslint": "^5.8.0",
"mocha": "^9.1.3",
"nyc": "^14.1.1",
"prettier": "^1.18.2"
}
}
================================================
FILE: api/postman.json
================================================
{
"info": {
"_postman_id": "994858ef-e55e-425c-9aac-1cf12496a933",
"name": "s3-api-Boilerplate",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Auth",
"item": [
{
"name": "/auth",
"event": [
{
"listen": "prerequest",
"script": {
"exec": [
""
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}",
"type": "text"
}
],
"body": {
"mode": "urlencoded",
"urlencoded": []
},
"url": {
"raw": "{{hostname}}/_authcheck",
"host": [
"{{hostname}}"
],
"path": [
"_authcheck"
]
},
"description": "The body must have `username` and `password`. It returns `id_token` and `access_token` are signed with the secret located at the `config.json` file. The `id_token` will contain the `username` and the `extra` information sent, while the `access_token` will contain the `audience`, `jti`, `issuer` and `scope`."
},
"response": []
},
{
"name": "/auth/login",
"event": [
{
"listen": "test",
"script": {
"exec": [
"var jsonData = pm.response.json()",
"pm.collectionVariables.set(\"accessToken\", jsonData.data.accessToken);",
""
],
"type": "text/javascript"
}
},
{
"listen": "prerequest",
"script": {
"exec": [
""
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/x-www-form-urlencoded"
}
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"key": "email",
"value": "user@example.com",
"type": "text"
},
{
"key": "password",
"value": "password",
"type": "text"
}
]
},
"url": {
"raw": "{{hostname}}/auth/login",
"host": [
"{{hostname}}"
],
"path": [
"auth",
"login"
]
},
"description": "The body must have `username` and `password`. It returns `id_token` and `access_token` are signed with the secret located at the `config.json` file. The `id_token` will contain the `username` and the `extra` information sent, while the `access_token` will contain the `audience`, `jti`, `issuer` and `scope`."
},
"response": []
},
{
"name": "/auth/sign-up",
"event": [
{
"listen": "test",
"script": {
"exec": [
"var jsonData = pm.response.json()",
"pm.collectionVariables.set(\"accessToken\", jsonData.data.accessToken);",
""
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"warning": "This is a duplicate header and will be overridden by the Content-Type header generated by Postman.",
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"key": "email",
"value": "anthonybudd@example.com",
"type": "text"
},
{
"key": "password",
"value": "password",
"type": "text"
},
{
"key": "firstName",
"value": "Anthony",
"type": "text"
},
{
"key": "lastName",
"value": "Budd",
"type": "text"
},
{
"key": "groupName",
"value": "GitHub",
"type": "text"
},
{
"key": "tos",
"value": "2021-21-19",
"type": "text"
}
]
},
"url": {
"raw": "{{hostname}}/auth/sign-up",
"host": [
"{{hostname}}"
],
"path": [
"auth",
"sign-up"
]
},
"description": "The body must have `username` and `password`. It returns `id_token` and `access_token` are signed with the secret located at the `config.json` file. The `id_token` will contain the `username` and the `extra` information sent, while the `access_token` will contain the `audience`, `jti`, `issuer` and `scope`."
},
"response": []
}
]
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
""
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
""
]
}
}
],
"variable": [
{
"key": "hostname",
"value": "http://localhost:8888/api/v1"
},
{
"key": "accessToken",
"value": ""
}
]
}
================================================
FILE: api/requests.http
================================================
# Install VS Code extension rest-client
# URL: https://marketplace.visualstudio.com/items?itemName=humao.rest-client
@host=http://localhost:8888/api/v1
# @host=http://api.local/api/v1
@AccessToken=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9.eyJpZCI6ImM0NjQ0NzMzLWRlZWEtNDdkOC1iMzVhLTg2ZjMwZmY5NjE4ZSIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImZpcnN0TmFtZSI6IlVzZXIiLCJsYXN0TmFtZSI6Ik9uZSIsImlhdCI6MTcxNTIxMzk0MiwiZXhwIjozNDMwNTE0MjgzfQ.khGH3zHxztsWmpwL9bWpwGr_VXcPFxGTCtgoCYJq9tz0H638kWKH_k_zLgjCQ1rD6N0fWh31pTE4l53RgUGz2iL8lAoYmq0ScwSgMmiWMKm6d1vxaN3UK0CivvZPku2Pn4MQ6p12xrfRxTUVCzxI_xP9hHEhG1VUbCA07JJnl-OJFQCwYVQWCmdK5daFe8wybddYLUCG0oAGpy7Kaf0_CBbJAeIccVCKI7fILgBxowVTwl7nqruzr3-k0biXuitkegNfHPyPwbs4AvIIYxdyLXZiT-Zz0JUazphQZncw4WBqB_PX4Eyoflf8xzQNRtgvdV3ANc6ZKeMG05jAp1IV3A
###########################################
# Auth
POST {{host}}/auth/login
content-type: application/json
{
"email": "user@example.com",
"password": "Password@1234"
}
### Check auth
GET {{host}}/_authcheck
Authorization: Bearer {{AccessToken}}
###########################################
# Buckets
GET {{host}}/buckets
Authorization: Bearer {{AccessToken}}
### Create Bucket
POST {{host}}/buckets
Authorization: Bearer {{AccessToken}}
content-type: application/json
{
"namespace": "x--xxctest",
"name": "x-0testx"
}
### Delete Bucket
DELETE {{host}}/buckets/fae8a1fb-bc90-4565-b567-1fe6846544de
Authorization: Bearer {{AccessToken}}
================================================
FILE: api/src/database/migrations/20180726090304-create-Users.js
================================================
module.exports = {
up: (queryInterface, Sequelize) => queryInterface.createTable('Users', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
unique: true
},
email: {
type: Sequelize.STRING,
allowNull: false,
unique: true
},
password: Sequelize.STRING,
firstName: Sequelize.STRING,
lastName: Sequelize.STRING,
bio: Sequelize.TEXT,
tos: Sequelize.STRING,
inviteKey: Sequelize.STRING,
passwordResetKey: Sequelize.STRING,
emailVerificationKey: Sequelize.STRING,
emailVerified: {
type: Sequelize.BOOLEAN,
defaultValue: false,
allowNull: false,
},
lastLoginAt: {
type: Sequelize.DATE,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: true,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: true,
},
}),
down: (queryInterface, Sequelize) => queryInterface.dropTable('Users'),
};
================================================
FILE: api/src/database/migrations/20180726090404-create-Groups.js
================================================
module.exports = {
up: (queryInterface, Sequelize) => queryInterface.createTable('Groups', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
unique: true
},
name: Sequelize.STRING,
ownerID: Sequelize.UUID,
createdAt: {
type: Sequelize.DATE,
allowNull: true,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: true,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
}),
down: (queryInterface, Sequelize) => queryInterface.dropTable('Groups')
};
================================================
FILE: api/src/database/migrations/20180726090405-create-GroupsUsers.js
================================================
module.exports = {
up: (queryInterface, Sequelize) => queryInterface.createTable('GroupsUsers', {
id: { // Not used. required by msq system var sql_require_primary_key
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
unique: true
},
groupID: {
type: Sequelize.UUID,
},
userID: {
type: Sequelize.UUID,
},
createdAt: {
type: Sequelize.DATE,
allowNull: true,
},
}).then(() => queryInterface.addConstraint('GroupsUsers', {
fields: ['groupID', 'userID'],
type: 'unique',
name: 'groupID_userID_index'
})),
down: (queryInterface, Sequelize) => queryInterface.dropTable('GroupsUsers'),
};
================================================
FILE: api/src/database/migrations/20240411041313-create-Buckets.js
================================================
module.exports = {
up: (queryInterface, Sequelize) => queryInterface.createTable('Buckets', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
unique: true
},
createdAt: {
type: Sequelize.DATE,
allowNull: true,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: true,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
userID: {
type: Sequelize.UUID,
allowNull: true,
},
namespace: {
type: Sequelize.STRING,
allowNull: false,
},
name: {
type: Sequelize.STRING,
allowNull: false,
},
status: {
type: Sequelize.STRING,
allowNull: false,
},
bucketCreated: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
},
endpoint: {
type: Sequelize.STRING,
allowNull: false,
},
stdout: {
type: Sequelize.TEXT,
allowNull: true,
},
stderr: {
type: Sequelize.TEXT,
allowNull: true,
},
}),
down: (queryInterface, Sequelize) => queryInterface.dropTable('Buckets'),
};
================================================
FILE: api/src/database/migrations/20240430101608-create-Blacklist.js
================================================
module.exports = {
up: (queryInterface, Sequelize) => queryInterface.createTable('Blacklist', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
unique: true
},
value: {
type: Sequelize.STRING,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: true,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: true,
},
}),
down: (queryInterface, Sequelize) => queryInterface.dropTable('Blacklist'),
};
================================================
FILE: api/src/database/seeders/20180726092449-Users.js
================================================
const bcrypt = require('bcrypt-nodejs');
const moment = require('moment');
const faker = require('faker');
const insert = [{
id: 'c4644733-deea-47d8-b35a-86f30ff9618e',
email: 'user@example.com',
password: bcrypt.hashSync('Password@1234', bcrypt.genSaltSync(10)),
firstName: 'User',
lastName: 'One',
tos: 'tos-version-2023-07-13',
createdAt: moment().format('YYYY-MM-DD HH:mm:ss'),
updatedAt: moment().format('YYYY-MM-DD HH:mm:ss'),
}, {
id: 'd700932c-4a11-427f-9183-d6c4b69368f9',
email: 'other.user@foobar.com',
password: bcrypt.hashSync('Password@1234', bcrypt.genSaltSync(10)),
firstName: faker.name.firstName(),
lastName: faker.name.lastName(),
tos: 'tos-version-2023-07-13',
inviteKey: '86f30ff9618e',
createdAt: moment().format('YYYY-MM-DD HH:mm:ss'),
updatedAt: moment().format('YYYY-MM-DD HH:mm:ss'),
}];
module.exports = {
up: (queryInterface, Sequelize) => queryInterface.bulkInsert('Users', insert).catch(err => console.log(err)),
down: (queryInterface, Sequelize) => { }
};
================================================
FILE: api/src/database/seeders/20180726093449-Group.js
================================================
const moment = require('moment');
const insert = [{
id: 'fdab7a99-2c38-444b-bcb3-f7cef61c275b',
ownerID: 'c4644733-deea-47d8-b35a-86f30ff9618e',
name: 'Group A',
createdAt: moment().format('YYYY-MM-DD HH:mm:ss'),
updatedAt: moment().format('YYYY-MM-DD HH:mm:ss'),
}, {
id: 'be1fcb4e-caf9-41c2-ac27-c06fa24da36a',
ownerID: 'd700932c-4a11-427f-9183-d6c4b69368f9',
name: 'Group B',
createdAt: moment().format('YYYY-MM-DD HH:mm:ss'),
updatedAt: moment().format('YYYY-MM-DD HH:mm:ss'),
}];
module.exports = {
up: (queryInterface, Sequelize) => queryInterface.bulkInsert('Groups', insert).catch(err => console.log(err)),
down: (queryInterface, Sequelize) => { }
};
================================================
FILE: api/src/database/seeders/20180726093449-GroupsUsers.js
================================================
const moment = require('moment');
const insert = [
{
id: '1872dcde-b79d-4f28-a36b-a22af519ac23',
userID: 'c4644733-deea-47d8-b35a-86f30ff9618e',
groupID: 'fdab7a99-2c38-444b-bcb3-f7cef61c275b',
createdAt: moment().format('YYYY-MM-DD HH:mm:ss'),
},
{
id: 'f4444505-cec7-4f91-948f-cdf3d4471c9e',
userID: 'c4644733-deea-47d8-b35a-86f30ff9618e',
groupID: 'be1fcb4e-caf9-41c2-ac27-c06fa24da36a',
createdAt: moment().add(1, 'min').format('YYYY-MM-DD HH:mm:ss'),
},
{
id: 'ed748a2d-453b-4bc8-b80d-bf1056e2b920',
userID: 'd700932c-4a11-427f-9183-d6c4b69368f9',
groupID: 'be1fcb4e-caf9-41c2-ac27-c06fa24da36a',
createdAt: moment().format('YYYY-MM-DD HH:mm:ss'),
}
];
module.exports = {
up: (queryInterface, Sequelize) => queryInterface.bulkInsert('GroupsUsers', insert).catch(err => console.log(err)),
down: (queryInterface, Sequelize) => { }
};
================================================
FILE: api/src/database/seeders/20240411041313-Buckets.js
================================================
const moment = require('moment');
const insert = [{
id: 'fae8a1fb-bc90-4565-b567-1fe6846544de',
createdAt: moment().format('YYYY-MM-DD HH:mm:ss'),
updatedAt: moment().format('YYYY-MM-DD HH:mm:ss'),
userID: 'c4644733-deea-47d8-b35a-86f30ff9618e',
namespace: 'test-bucket',
name: 'test-bucket',
status: 'Provisioned',
bucketCreated: 1,
endpoint: `test-bucket.${process.env.S3_ROOT}`,
}];
module.exports = {
up: (queryInterface, Sequelize) => queryInterface.bulkInsert('Buckets', insert).catch(err => console.log(err)),
down: (queryInterface, Sequelize) => { }
};
================================================
FILE: api/src/database/seeders/20240430101608-Blacklist.js
================================================
const { v4: uuidv4 } = require('uuid');
const blacklist = [
'about',
'aboutu',
'abuse',
'acme',
'ad',
'admanager',
'admin',
'admindashboard',
'administrator',
'ads',
'adsense',
'adult',
'adword',
'affiliate',
'affiliatepage',
'afp',
'alpha',
'anal',
'analytic',
'android',
'answer',
'anu',
'anus',
'ap',
'api',
'app',
'appengine',
'application',
'appnew',
'arse',
'asdf',
'a',
'as',
'ass',
'asset',
'asshole',
'atf',
'backup',
'ball',
'balls',
'ballsack',
'bank',
'base',
'bastard',
'beginner',
'beta',
'biatch',
'billing',
'binarie',
'binary',
'bitch',
'biz',
'blackberry',
'blog',
'blogsearch',
'bloody',
'blowjob',
'blowjobs',
'bollock',
'boner',
'boob',
'boobs',
'book',
'bugger',
'bum',
'butt',
'buttplug',
'buy',
'buzz',
'c',
'cache',
'calendar',
'cart',
'catalog',
'ceo',
'chart',
'chat',
'checkout',
'ci',
'cia',
'client',
'clitori',
'clitoris',
'cname',
'cnarne',
'cock',
'code',
'community',
'confirm',
'confirmation',
'contact',
'contact-u',
'contactu',
'content',
'controlpanel',
'coon',
'core',
'corp',
'countrie',
'country',
'cp',
'cpanel',
'crap',
'cs',
'cunt',
'cv',
'damn',
'dashboard',
'data',
'demo',
'deploy',
'deployment',
'desktop',
'dev',
'devel',
'developement',
'developer',
'development',
'dick',
'dike',
'dildo',
'dir',
'directory',
'discussion',
'dl',
'doc',
'document',
'donate',
'download',
'dyke',
'e',
'earth',
'email',
'enable',
'encrypted',
'engine',
'error',
'errorlog',
'fag',
'faggot',
'fbi',
'feature',
'feck',
'feed',
'feedburner',
'feedproxy',
'felching',
'fellate',
'fellatio',
'file',
'finance',
'flange',
'folder',
'forgotpassword',
'forum',
'friend',
'ftp',
'fuck',
'fudgepacker',
'fun',
'fusion',
'gadget',
'gear',
'geographic',
'gettingstarted',
'git',
'gitlab',
'gmail',
'go',
'goddamn',
'goto',
'gov',
'graph',
'group',
'hell',
'help',
'home',
'homo',
'html',
'htrnl',
'http',
'i',
'image',
'img',
'investor',
'invoice',
'io',
'ios',
'ipad',
'iphone',
'irnage',
'irng',
'item',
'j',
'jenkin',
'jerk',
'jira',
'jizz',
'job',
'join',
'js',
'knobend',
'lab',
'labia',
'legal',
'lesbo',
'list',
'lmao',
'lmfao',
'local',
'locale',
'location',
'log',
'login',
'logout',
'm',
'mail',
'manage',
'manager',
'map',
'marketing',
'me',
'media',
'message',
'misc',
'mm',
'mms',
'mobile',
'model',
'money',
'movie',
'muff',
'my',
'mystore',
'n',
'net',
'network',
'new',
'newsite',
'nigga',
'nigger',
'npm',
'ns',
'omg',
'online',
'order',
'org',
'other',
'p0rn',
'pack',
'packagist',
'page',
'partner',
'partnerpage',
'password',
'payment',
'peni',
'penis',
'people',
'person',
'pi',
'pis',
'piss',
'place',
'podcast',
'policy',
'poop',
'pop',
'pop3',
'popular',
'porn',
'pr0n',
'pricing',
'prick',
'print',
'privacy',
'private',
'prod',
'product',
'production',
'profile',
'promo',
'promotion',
'proxie',
'proxies',
'proxy',
'pube',
'public',
'purchase',
'pussy',
'queer',
'querie',
'queries',
'query',
'r',
'radio',
'random',
'reader',
'recover',
'redirect',
'register',
'registration',
'release',
'report',
'research',
'resolve',
'resolver',
'rnail',
'rnicrosoft',
'root',
'rs',
'rss',
'sale',
'sandbox',
'scholar',
'scrotum',
'search',
'secure',
'seminar',
'server',
'service',
'sex',
'sftp',
'sh1t',
'shit',
'shop',
'shopping',
'shortcut',
'signin',
'signup',
'site',
'sitemap',
'sitenew',
'sketchup',
'sky',
'slash',
'slashinvoice',
'slut',
'sm',
'smegma',
'sms',
'smtp',
'soap',
'software',
'sorry',
'spreadsheet',
'spunk',
'srntp',
'ssh',
'ssl',
'stage',
'staging',
'stat',
'static',
'statistic',
'statu',
'store',
'suggest',
'suggestquerie',
'suggestquery',
'support',
'survey',
'surveytool',
'svn',
'sync',
'sysadmin',
'talk',
'talkgadget',
'test',
'tester',
'testing',
'text',
'tit',
'tits',
'tool',
'toolbar',
'tosser',
'trac',
'translate',
'translation',
'translator',
'trend',
'turd',
'twat',
'txt',
'ul',
'upload',
'vagina',
'validation',
'vid',
'video',
'video-stat',
'voice',
'w',
'wank',
'wave',
'webdisk',
'webmail',
'webmaster',
'webrnail',
'whm',
'whoi',
'whore',
'wifi',
'wiki',
'wtf',
'ww',
'www',
'wwww',
'xhtml',
'xhtrnl',
'xml',
'xxx',
];
module.exports = {
up: (queryInterface, Sequelize) => queryInterface.bulkInsert('Blacklist', blacklist.map((value) => ({
id: uuidv4(),
value,
}))).catch(err => console.log(err)),
down: (queryInterface, Sequelize) => { }
};
================================================
FILE: api/src/index.js
================================================
require('dotenv').config();
require('./providers/passport');
const fileUpload = require('express-fileupload');
const express = require('express');
const morgan = require('morgan');
const cors = require('cors');
console.log('*************************************');
console.log('* Express API Boilerplate');
console.log('*');
console.log('* ENV');
console.log(`* NODE_ENV: ${process.env.NODE_ENV}`);
console.log(`* TEMP_FILE_DIR: ${process.env.TEMP_FILE_DIR}`);
if (!process.env.H_CAPTCHA_SECRET) console.log(`* H_CAPTCHA_SECRET: null ⚠️ Login/Sign-up requests will not require captcha validadation!`);
console.log('*');
console.log('*');
////////////////////////////////////////////////
// Express
const app = express();
app.disable('x-powered-by');
app.use(cors({
origin: '*',
credentials: true,
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(fileUpload({
limits: { fileSize: 50 * 1024 * 1024 },
tempFileDir: process.env.TEMP_FILE_DIR,
useTempFiles: true,
parseNested: true,
}));
app.get('/_readiness', (req, res) => res.send('healthy'));
app.get('/api/v1/_healthcheck', (req, res) => res.json({ status: 'ok' }));
if (typeof global.it !== 'function') app.use(morgan('[:date[iso]] HTTP/:http-version :status :method :url :response-time ms'));
////////////////////////////////////////////////
// HTTP
app.use('/api/v1/', require('./routes/auth'));
app.use('/api/v1/', require('./routes/user'));
app.use('/api/v1/', require('./routes/groups'));
app.use('/api/v1/', require('./routes/Buckets')); // AB: gen
////////////////////////////////////////////////
// Listen
let port = process.env.PORT || 80;
if (typeof global.it === 'function') port = 7777;
app.listen(port, () => console.log(`* Listening: http://127.0.0.1:${port}`));
module.exports = app;
================================================
FILE: api/src/models/Blacklist.js
================================================
const Sequelize = require('sequelize');
const db = require('./../providers/db');
const Blacklist = db.define('Blacklist', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
unique: true
},
value: {
type: Sequelize.STRING,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: true,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: true,
},
}, {
tableName: 'Blacklist',
defaultScope: {
attributes: {
exclude: [
]
}
},
});
module.exports = Blacklist;
================================================
FILE: api/src/models/Bucket.js
================================================
const { exec } = require('child_process');
const db = require('./../providers/db');
const Sequelize = require('sequelize');
const tmp = require('tmp');
const fs = require('fs');
const Bucket = db.define('Bucket', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
unique: true
},
createdAt: {
type: Sequelize.DATE,
allowNull: true,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: true,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
userID: {
type: Sequelize.UUID,
allowNull: true,
},
namespace: {
type: Sequelize.STRING,
allowNull: false,
},
name: {
type: Sequelize.STRING,
allowNull: false,
},
status: {
type: Sequelize.STRING,
allowNull: false,
},
bucketCreated: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
},
endpoint: {
type: Sequelize.STRING,
allowNull: false,
},
stdout: {
type: Sequelize.TEXT,
allowNull: true,
},
stderr: {
type: Sequelize.TEXT,
allowNull: true,
},
}, {
tableName: 'Buckets',
paranoid: true,
defaultScope: {
attributes: {
exclude: []
}
},
});
Bucket.prototype.createK3sAssets = async function () {
const generateAccessKeyID = () => {
const charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz23456789';
const length = 20;
let randomString = '';
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * charSet.length);
randomString += charSet.charAt(randomIndex);
}
return randomString;
};
const generateSecretAccessKey = () => {
const length = 40;
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/';
let randomString = '';
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * charset.length);
randomString += charset.charAt(randomIndex);
}
return randomString;
};
const accessKeyID = generateAccessKeyID();
const secretAccessKey = generateSecretAccessKey();
tmp.file((err, path) => {
if (err) throw err;
fs.readFile('/app/src/providers/bucket.yml', 'utf8', (err, data) => {
if (err) throw err;
const result = data.replace(/NAMESPACE_HERE/g, this.namespace)
.replace(/BUCKETNAME_HERE/g, this.name)
.replace(/ROOTUSER/g, accessKeyID)
.replace(/ROOTPASSWORD/g, secretAccessKey);
fs.writeFile(path, result, 'utf8', (err) => {
if (err) throw err;
exec(`kubectl --kubeconfig=${process.env.K8S_CONFIG_PATH} apply -f ${path}`, (err, stdout, stderr) => {
if (err) console.error(err);
console.log(`stdout: ${stdout}`);
console.log(`stderr: ${stderr}`);
let status = 'Provisioning';
if (stderr) status = 'Error';
this.update({
status,
stdout,
stderr,
});
});
});
});
});
return {
accessKeyID,
secretAccessKey
};
};
Bucket.prototype.createBucket = async function () {
const command = `kubectl --kubeconfig=${process.env.K8S_CONFIG_PATH} -n ${this.namespace} exec minio-pod -- ./s3-create-bucket-script/create-bucket.sh`;
console.log(command);
exec(command, (err, stdout, stderr) => {
if (err) console.error(err);
console.log(`stdout: ${stdout}`);
console.log(`stderr: ${stderr}`);
if (stderr) {
this.update({
status: 'Error',
createStderr: `2: ${stderr}`,
});
} else {
this.update({ bucketCreated: true });
}
});
};
Bucket.prototype.sync = async function () {
if (this.status !== 'Error') {
exec(`kubectl --kubeconfig=${process.env.K8S_CONFIG_PATH} -n ${this.namespace} get pod minio-pod --no-headers -o custom-columns=":status.phase"`, (err, stdout, stderr) => {
if (err) console.error(err);
console.log(`stdout: ${stdout}`);
console.log(`stderr: ${stderr}`);
switch (stdout.trim()) {
case 'Running':
if (this.status !== 'Provisioned') this.update({ status: 'Provisioned' });
if (!this.bucketCreated) this.createBucket();
break;
}
});
}
};
Bucket.prototype.deleteK3sAssets = async function () {
exec(`kubectl --kubeconfig=${process.env.K8S_CONFIG_PATH} -n ${this.namespace} delete pod/minio-pod service/minio-svc ingress/minio-ing persistentvolumeclaim/minio-pvc namespace/${this.namespace}`, (err, stdout, stderr) => {
if (err) console.error(err);
if (stderr) console.log(`stderr: ${stderr}`);
console.log(`stdout: ${stdout}`);
this.update({
stdout,
stderr,
});
});
};
module.exports = Bucket;
================================================
FILE: api/src/models/Group.js
================================================
const Sequelize = require('sequelize');
const db = require('./../providers/db');
module.exports = db.define('Group', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
unique: true
},
name: Sequelize.STRING,
ownerID: Sequelize.UUID,
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
}, {
tableName: 'Groups',
paranoid: true,
});
================================================
FILE: api/src/models/GroupsUsers.js
================================================
const Sequelize = require('sequelize');
const db = require('./../providers/db');
module.exports = db.define('GroupsUsers', {
id: { // Not used. required by msq system var sql_require_primary_key
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
unique: true
},
userID: Sequelize.UUID,
groupID: Sequelize.UUID,
}, {
tableName: 'GroupsUsers',
updatedAt: false,
});
================================================
FILE: api/src/models/User.js
================================================
const Sequelize = require('sequelize');
const db = require('./../providers/db');
module.exports = db.define('User', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
unique: true
},
email: {
type: Sequelize.STRING,
allowNull: false,
unique: true
},
password: Sequelize.STRING,
firstName: Sequelize.STRING,
lastName: Sequelize.STRING,
bio: Sequelize.TEXT,
tos: Sequelize.STRING,
inviteKey: Sequelize.STRING,
passwordResetKey: Sequelize.STRING,
emailVerificationKey: Sequelize.STRING,
emailVerified: {
type: Sequelize.BOOLEAN,
defaultValue: false,
allowNull: false,
},
lastLoginAt: {
type: Sequelize.DATE,
allowNull: true,
},
}, {
tableName: 'Users',
defaultScope: {
attributes: {
exclude: [
'password',
'passwordResetKey',
]
}
},
});
================================================
FILE: api/src/models/index.js
================================================
const User = require('./User');
const Group = require('./Group');
const GroupsUsers = require('./GroupsUsers');
const Bucket = require('./Bucket');
const Blacklist = require('./Blacklist');
User.belongsToMany(Group, {
through: GroupsUsers,
foreignKey: 'userID',
otherKey: 'groupID',
});
Group.belongsToMany(User, {
through: GroupsUsers,
foreignKey: 'groupID',
otherKey: 'userID',
});
module.exports = {
User,
Group,
GroupsUsers,
Bucket,
Blacklist,
};
================================================
FILE: api/src/providers/bucket.yml
================================================
apiVersion: v1
kind: Namespace
metadata:
name: NAMESPACE_HERE
labels:
name: NAMESPACE_HERE
---
apiVersion: v1
kind: Pod
metadata:
labels:
app: minio-pod
name: minio-pod
namespace: NAMESPACE_HERE
spec:
containers:
- name: minio-pod
image: quay.io/minio/minio:latest
env:
- name: MINIO_ROOT_USER
value: ROOTUSER
- name: MINIO_ROOT_PASSWORD
value: ROOTPASSWORD
- name: S3_NAMESPACE
value: NAMESPACE_HERE
- name: S3_BUCKET_NAME
value: BUCKETNAME_HERE
command:
- /bin/bash
- -c
args:
- minio server /data --console-address :9001
ports:
- name: http
containerPort: 80
- name: https
containerPort: 443
- name: console
containerPort: 9001
- name: api
containerPort: 9000
volumeMounts:
- name: longhornvolume
mountPath: /data
- name: s3-create-bucket-script
mountPath: /s3-create-bucket-script
volumes:
- name: s3-create-bucket-script
configMap:
name: s3-create-bucket-script
defaultMode: 0777
items:
- key: create-bucket.sh
path: create-bucket.sh
- name: longhornvolume
persistentVolumeClaim:
claimName: minio-pvc
---
apiVersion: v1
kind: Service
metadata:
name: minio-svc
namespace: NAMESPACE_HERE
spec:
selector:
app: minio-pod
ports:
- name: http
protocol: TCP
port: 80
targetPort: 9001
- name: api
port: 9000
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
namespace: NAMESPACE_HERE
name: minio-ing
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
kubernetes.io/ingress.class: "traefik"
spec:
tls:
- hosts:
- NAMESPACE_HERE.s3.anthonybudd.io
secretName: NAMESPACE_HERE-s3-anthonybudd-io-cert
- hosts:
- BUCKETNAME_HERE.NAMESPACE_HERE.s3.anthonybudd.io
secretName: BUCKETNAME_HERE-NAMESPACE_HERE-s3-anthonybudd-io-cert
rules:
- host: NAMESPACE_HERE.s3.anthonybudd.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: minio-svc
port:
number: 80
- host: BUCKETNAME_HERE.NAMESPACE_HERE.s3.anthonybudd.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: minio-svc
port:
number: 9000
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: minio-pvc
namespace: NAMESPACE_HERE
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: ConfigMap
metadata:
name: s3-create-bucket-script
namespace: NAMESPACE_HERE
data:
create-bucket.sh: |
#!/bin/bash
mc alias set local http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
mc mb local/"$S3_BUCKET_NAME"
================================================
FILE: api/src/providers/connections.js
================================================
require('dotenv').config();
module.exports = {
development: {
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
host: process.env.DB_HOST,
port: process.env.DB_PORT || '3306',
dialect: 'mysql',
},
production: {
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
host: process.env.DB_HOST,
port: process.env.DB_PORT || '3306',
dialect: 'mysql',
},
test: {
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
host: 's3-api-db-test',
port: process.env.DB_PORT || '3306',
dialect: 'mysql',
}
};
================================================
FILE: api/src/providers/db.js
================================================
const Sequelize = require('sequelize');
const connections = require('./connections');
const errorHandler = require('./errorHandler');
const connection = (typeof global.it === 'function') ? 'test' : (process.env.NODE_ENV || 'development');
const dbHost = connections[connection].host;
const dbPort = connections[connection].port;
const dbName = connections[connection].database;
const dbUser = connections[connection].username;
const dbPassword = connections[connection].password;
const dbDialect = connections[connection].dialect;
const sequelize = new Sequelize(dbName, dbUser, dbPassword, {
port: dbPort,
host: dbHost,
dialect: dbDialect,
logging: false,
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000,
},
});
sequelize.authenticate()
.then(() => ((typeof global.it !== 'function') ? console.log('* Sequelize: Connected') : ''))
.catch(err => errorHandler(err));
module.exports = sequelize;
================================================
FILE: api/src/providers/errorHandler.js
================================================
const crypto = require('crypto');
module.exports = (err, res) => {
if (err.isAxiosError) {
console.log(`Axios Error: ${err.request.path}`);
if (err.response && err.response.data) {
console.log(err.response.data);
} else {
console.error(err);
}
} else if (err.response && err.response.body) {
console.error(err);
console.error(err.response.body);
} else {
console.error(err);
}
if (res && !res.headersSent) res.status(500).json({
msg: `Error`,
code: crypto.randomBytes(32).toString('base64'),
});
};
================================================
FILE: api/src/providers/generateJWT.js
================================================
const jwt = require('jsonwebtoken');
const moment = require('moment');
const fs = require('fs');
module.exports = (user, expires) => {
const payload = {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
displayName: user.displayName,
};
let expiresIn = moment(new Date()).add(1, 'day').unix();
if (Array.isArray(expires) && expires.length === 2) {
expiresIn = moment(new Date()).add(expires[0], expires[1]).unix();
} else if (typeof expires === 'string') {
expiresIn = moment(expires, 'YYYY-MM-DD HH:mmZ').unix();
}
return jwt.sign(payload, fs.readFileSync(process.env.PRIVATE_KEY_PATH, 'utf8'), {
expiresIn,
algorithm: 'RS512',
});
};
================================================
FILE: api/src/providers/hCaptcha.js
================================================
const axios = require('axios');
const qs = require('qs');
const hCaptcha = axios.create({
baseURL: 'https://hcaptcha.com',
});
module.exports = {
verify: async (response) => await hCaptcha.post('/siteverify',
qs.stringify({
response,
secret: process.env.H_CAPTCHA_SECRET,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
),
axios: hCaptcha,
};
================================================
FILE: api/src/providers/passport.js
================================================
const LocalStrategy = require('passport-local').Strategy;
const { User, Group } = require('./../models');
const passportJWT = require('passport-jwt');
const ExtractJWT = passportJWT.ExtractJwt;
const JWTStrategy = passportJWT.Strategy;
const bcrypt = require('bcrypt-nodejs');
const passport = require('passport');
const fs = require('fs');
passport.use(new LocalStrategy({
usernameField: 'email',
passwordField: 'password'
}, async (email, password, cb) => {
const user = await User.unscoped().findOne({
where: { email },
include: [Group]
});
if (!user) return cb(null, false, { message: 'Incorrect email or password.' });
return bcrypt.compare(password, user.password, (err, compare) => {
if (compare) {
return cb(null, user, { message: 'Logged in successfully' });
} else {
return cb(null, false, { message: 'Incorrect email or password.' });
}
});
}));
passport.use(new JWTStrategy({
jwtFromRequest: ExtractJWT.fromExtractors([
ExtractJWT.fromAuthHeaderAsBearerToken(),
ExtractJWT.fromUrlQueryParameter('token'),
]),
secretOrKey: fs.readFileSync(process.env.PUBLIC_KEY_PATH, 'utf8'),
}, (jwtPayload, cb) => cb(null, jwtPayload)));
module.exports = passport;
================================================
FILE: api/src/routes/Buckets.js
================================================
const { body, validationResult, matchedData } = require('express-validator');
const errorHandler = require('./../providers/errorHandler');
const { Bucket, Blacklist } = require('./../models');
const middleware = require('./middleware');
const passport = require('passport');
const express = require('express');
const app = (module.exports = express.Router());
/**
* GET /api/v1/buckets
*
*/
app.get('/buckets', [
passport.authenticate('jwt', { session: false })
], async (req, res) => {
try {
return res.json(await Bucket.findAll({
where: {
userID: req.user.id,
}
}));
} catch (error) {
errorHandler(error, res);
}
});
/**
* POST /api/v1/buckets
*
* Create Bucket
*/
app.post('/buckets', [
passport.authenticate('jwt', { session: false }),
body('namespace')
.exists()
.notEmpty()
.matches(/^[a-z0-9-_]+$/),
body('namespace')
.custom(async (value) => {
const blacklist = await Blacklist.findOne({ where: { value } });
if (blacklist) throw new Error('This namespace is not allowed');
})
.custom(async (namespace, { req }) => {
const exists = await Bucket.findOne({
where: {
namespace: req.body.namespace
}
});
if (exists) throw new Error('Namespace already exists.');
}),
body('name')
.exists()
.notEmpty()
.matches(/^[a-z0-9-_]+$/),
body('name')
.custom(async (value) => {
const blacklist = await Blacklist.findOne({ where: { value } });
if (blacklist) throw new Error('This bucket name is not allowed');
})
.custom(async (name, { req }) => {
const exists = await Bucket.findOne({
where: {
name: req.body.name
}
});
if (exists) throw new Error('Bucket already exists.');
}),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.mapped() });
const data = matchedData(req);
const bucket = await Bucket.create({
userID: req.user.id,
namespace: data.namespace,
name: data.name,
status: 'Provisioning',
endpoint: `${data.name}.${data.namespace}.${process.env.S3_ROOT}`,
});
const { accessKeyID, secretAccessKey } = await bucket.createK3sAssets();
return res.json({
...bucket.get({ plain: true }),
accessKeyID,
secretAccessKey,
});
} catch (error) {
return errorHandler(error, res);
}
});
/**
* DELETE /api/v1/buckets/:bucketID
*
* Delete Bucket
*/
app.delete('/buckets/:bucketID', [
passport.authenticate('jwt', { session: false }),
middleware.canAccessBucket,
], async (req, res) => {
try {
const bucket = await Bucket.findByPk(req.params.bucketID);
bucket.deleteK3sAssets();
await bucket.destroy();
return res.json({ id: req.params.bucketID });
} catch (error) {
return errorHandler(error, res);
}
});
================================================
FILE: api/src/routes/auth.js
================================================
const { body, validationResult, matchedData } = require('express-validator');
const { User, Group, GroupsUsers } = require('./../models');
const errorHandler = require('./../providers/errorHandler');
const generateJWT = require('./../providers/generateJWT');
const middleware = require('./middleware');
const bcrypt = require('bcrypt-nodejs');
const passport = require('passport');
const express = require('express');
const uuidv4 = require('uuid/v4');
const moment = require('moment');
const crypto = require('crypto');
const app = (module.exports = express.Router());
/**
* GET /api/v1/_authcheck
*
* Helper route for testing auth status
*/
app.get('/_authcheck', [
passport.authenticate('jwt', { session: false })
], (req, res) => res.json({
auth: true,
id: req.user.id,
}));
/**
* POST api/v1/auth/login
*
*/
app.post('/auth/login', [
body('email').notEmpty().toLowerCase(),
body('password').notEmpty(),
middleware.hCaptcha,
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.mapped() });
passport.authenticate('local', { session: false }, (err, user) => {
if (err) return errorHandler(err, res);
if (!user) return res.status(401).json('Incorrect email or password');
req.login(user, { session: false }, (err) => {
if (err) return errorHandler(err, res);
res.json({
accessToken: generateJWT(user)
});
User.update({
lastLoginAt: moment(new Date()).format('YYYY-MM-DD HH:mm:ss'),
}, {
where: {
id: user.id
}
});
});
})(req, res);
});
/**
* POST /api/v1/auth/sign-up
*
*/
app.post('/auth/sign-up', [
body('email')
.notEmpty()
.isEmail()
.trim()
.toLowerCase()
.custom(async (email) => {
const user = await User.findOne({ where: { email } });
if (user) throw new Error('This email address is taken');
}),
body('password', 'Your password must be atleast 7 characters long')
.notEmpty()
.isLength({ min: 7 }),
body('firstName', 'You must provide your first name')
.notEmpty()
.exists(),
body('lastName')
.optional(),
body('groupName')
.optional(),
body('tos', 'You must accept the Terms of Service to use this platform')
.exists()
.notEmpty(),
middleware.hCaptcha,
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.mapped() });
const data = matchedData(req);
const userID = uuidv4();
const groupID = uuidv4();
const ucFirst = (string) => string.charAt(0).toUpperCase() + string.slice(1);
const nameArr = data.firstName.split(' ');
if (!data.lastName && nameArr.length >= 2) {
data.firstName = nameArr[0];
data.lastName = nameArr[1];
}
if (!data.lastName) data.lastName = '';
if (!data.groupName) data.groupName = data.firstName.concat("'s Team");
await Group.create({
id: groupID,
name: data.groupName,
ownerID: userID,
});
await GroupsUsers.create({ userID, groupID });
const user = await User.create({
id: userID,
email: data.email,
password: bcrypt.hashSync(data.password, bcrypt.genSaltSync(10)),
firstName: ucFirst(data.firstName),
lastName: ucFirst(data.lastName),
lastLoginAt: moment().format("YYYY-MM-DD HH:mm:ss"),
tos: data.tos,
emailVerificationKey: crypto.randomBytes(20).toString('hex'),
});
console.log(`\n\nEMAIL THIS TO THE USER\nEMAIL VERIFICATION LINK: ${process.env.FRONTEND_URL}/validate-email/${user.emailVerificationKey}\n\n`);
return passport.authenticate('local', { session: false }, (err, user) => {
if (err) return errorHandler(err, res);
req.login(user, { session: false }, (err) => {
if (err) return errorHandler(err, res);
res.json({
accessToken: generateJWT(user)
});
});
})(req, res);
} catch (error) {
return errorHandler(error, res);
}
});
/**
* GET /api/v1/auth/verify-email/:emailVerificationKey
*
* Verify Email
*/
app.get('/auth/verify-email', async (req, res) => {
const user = await User.findOne({
where: {
emailVerificationKey: req.params.emailVerificationKey
}
});
if (!user) return res.status(404).json({
msg: 'User not found',
code: 40402
});
await user.update({
emailVerified: true,
emailVerificationKey: null,
});
return res.json({ success: true });
});
/**
* POST /api/v1/auth/forgot
*
* Forgot Password
*/
app.post('/auth/forgot', [
body('email')
.isEmail()
.toLowerCase()
.custom(async (email) => {
const user = await User.findOne({ where: { email } });
if (!user) throw new Error('This email address does not exist');
}),
middleware.hCaptcha,
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.mapped() });
const { email } = matchedData(req);
const user = await User.findOne({ where: { email } });
if (!user) return res.status(404).json({
msg: 'User not found',
code: 40401
});
const passwordResetKey = crypto.randomBytes(32).toString('base64').replace(/[^a-zA-Z0-9]/g, '');
await user.update({ passwordResetKey });
console.log(`\n\nEMAIL THIS TO THE USER\nPASSWORD RESET LINK: ${process.env.FRONTEND_URL}/reset/${passwordResetKey}\n\n`);
return res.json({ success: true });
});
/**
* GET /api/v1/auth/get-user-by-reset-key/:passwordResetKey
*
* Get users email
*/
app.get('/auth/get-user-by-reset-key/:passwordResetKey', async (req, res) => {
const user = await User.findOne({
where: {
passwordResetKey: req.params.passwordResetKey
},
});
if (!user) return res.status(404).send('Not found');
return res.json({
id: user.id,
email: user.email
});
});
/**
* POST /api/v1/auth/reset
*
* Update User's Password
*/
app.post('/auth/reset', [
body('email')
.isEmail()
.toLowerCase()
.custom(async (email) => {
const user = await User.findOne({ where: { email } });
if (!user) throw new Error('This email address does not exist');
}),
body('password').exists().isLength({ min: 7 }),
body('passwordResetKey', 'This link has expired')
.custom(async (passwordResetKey) => {
if (!passwordResetKey) throw new Error('This link has expired');
const user = await User.findOne({ where: { passwordResetKey } });
if (!user) throw new Error('This link has expired');
}),
middleware.hCaptcha,
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.mapped() });
const { email, password, passwordResetKey } = matchedData(req);
const user = await User.findOne({
where: { email, passwordResetKey },
include: [Group],
});
if (!user) return res.status(404).send('Not found');
await user.update({
password: bcrypt.hashSync(password, bcrypt.genSaltSync(10)),
passwordResetKey: null,
});
return passport.authenticate('local', { session: false }, (err, user) => {
if (err) return errorHandler(err, res);
req.login(user, { session: false }, (err) => {
if (err) return errorHandler(err, res);
return res.json({ accessToken: generateJWT(user) });
});
})(req, res);
});
/**
* GET /api/v1/auth/get-user-by-invite-key/:inviteKey
*
* Get users email
*/
app.get('/auth/get-user-by-invite-key/:inviteKey', async (req, res) => {
const user = await User.findOne({
where: {
inviteKey: req.params.inviteKey
},
});
if (!user) return res.status(404).send('Not found');
return res.json({
id: user.id,
email: user.email
});
});
/**
* POST /api/v1/auth/invite
*
*/
app.post('/auth/invite', [
body('email', 'You must provide your email address')
.exists({ checkFalsy: true })
.isEmail()
.toLowerCase(),
body('password', 'Your password must be atleast 7 characters long')
.isLength({ min: 7 }),
body('firstName', 'You must provide your first name')
.exists(),
body('lastName'),
body('tos', 'You must accept the Terms of Service to use this platform')
.exists(),
body('inviteKey').exists(),
middleware.hCaptcha,
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.mapped() });
const data = matchedData(req);
const ucFirst = (string) => string.charAt(0).toUpperCase() + string.slice(1);
const user = await User.findOne({ where: { inviteKey: data.inviteKey } });
if (!user) return res.status(404).send('Not found');
await user.update({
password: bcrypt.hashSync(data.password, bcrypt.genSaltSync(10)),
firstName: ucFirst(data.firstName),
lastName: ucFirst(data.lastName),
lastLoginAt: moment().format('YYYY-MM-DD HH:mm:ss'),
tos: data.tos,
inviteKey: null,
emailVerified: true,
emailVerificationKey: null,
});
return passport.authenticate('local', { session: false }, (err, user) => {
if (err) return errorHandler(err, res);
req.login(user, { session: false }, (err) => {
if (err) return errorHandler(err, res);
return res.json({ accessToken: generateJWT(user) });
});
})(req, res);
} catch (error) {
return errorHandler(error, res);
}
});
================================================
FILE: api/src/routes/groups.js
================================================
const { body, validationResult, matchedData } = require('express-validator');
const { User, Group, GroupsUsers } = require('./../models');
const errorHandler = require('./../providers/errorHandler');
const middleware = require('./middleware');
const passport = require('passport');
const express = require('express');
const crypto = require('crypto');
const app = (module.exports = express.Router());
/**
* GET /api/v1/groups/:groupID
*
*/
app.get('/groups/:groupID', [
passport.authenticate('jwt', { session: false }),
middleware.isInGroup,
], async (req, res) => {
try {
const group = await Group.findByPk(req.params.groupID, {
include: (req.query.with === 'users') ? [User] : [],
});
return res.json(group);
} catch (error) {
return errorHandler(error, res);
}
});
/**
* POST /api/v1/groups/:groupID
*
*/
app.post('/groups/:groupID', [
passport.authenticate('jwt', { session: false }),
middleware.isInGroup,
body('name')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.mapped() });
const data = matchedData(req);
await Group.update(data, {
where: {
id: req.params.groupID
}
});
return res.json(
await Group.findByPk(req.params.groupID)
);
} catch (error) {
return errorHandler(error, res);
}
});
/**
* POST /api/v1/groups/:groupID/users/invite
*
*/
app.post('/groups/:groupID/users/invite', [
passport.authenticate('jwt', { session: false }),
middleware.isGroupOwner,
body('email').isEmail().toLowerCase(),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.mapped() });
const { email } = matchedData(req);
const groupID = req.params.groupID;
let user = await User.findOne({
where: { email }
});
if (user) {
if (user.id === req.user.id) return res.status(401).json({
msg: 'You cannot add yourself to a group',
code: 98644,
});
// Check if relationship already exists
const relationship = await GroupsUsers.findOne({
where: {
groupID,
userID: user.id
}
});
if (relationship) return res.json({
groupID,
userID: user.id
});
} else {
try {
user = await User.create({
email,
inviteKey: crypto.randomBytes(20).toString('hex'),
emailVerificationKey: crypto.randomBytes(20).toString('hex'),
});
console.log(`\n\nEMAIL THIS TO THE USER\nINVITE LINK: ${process.env.FRONTEND_URL}/invite/${user.inviteKey}\n\n`);
} catch (error) {
errorHandler(error);
}
}
// Delete all first
await GroupsUsers.destroy({
where: {
groupID,
userID: user.id,
}
});
await GroupsUsers.create({
groupID,
userID: user.id,
});
return res.json({
groupID,
userID: user.id,
});
} catch (error) {
return errorHandler(error, res);
}
});
/**
* DELETE /api/v1/groups/:groupID/users/:userID
*
*/
app.delete('/groups/:groupID/users/:userID', [
passport.authenticate('jwt', { session: false }),
middleware.isGroupOwner,
middleware.isNotSelf,
], async (req, res) => {
await GroupsUsers.destroy({
where: {
groupID: req.params.groupID,
userID: req.params.userID,
}
});
return res.json({
userID: req.params.userID,
groupID: req.params.groupID
});
});
================================================
FILE: api/src/routes/middleware/canAccessBucket.js
================================================
const { Bucket } = require('./../../models');
module.exports = async (req, res, next) => {
const bucketID = (req.params.bucketID || req.body.bucketID);
const bucket = await Bucket.findByPk(bucketID);
if (!bucket) return res.status(404).json({
msg: `Bucket does not exists.`,
code: 97924,
});
if (req.user.id === bucket.userID) {
return next();
} else {
return res.status(401).json({
msg: `You do not have access to bucket.id:${bucketID}`,
code: 49390,
});
}
};
================================================
FILE: api/src/routes/middleware/checkPassword.js
================================================
const errorHandler = require('./../../providers/errorHandler');
const { User } = require('./../../models');
const bcrypt = require('bcrypt-nodejs');
module.exports = (req, res, next) => {
if (!req.body.password) return res.status(422).json({
errors: {
components: {
location: 'body',
param: 'password',
msg: 'Password must be provided'
}
}
});
User.unscoped().findOne({ where: { id: req.user.id } }).then(user => {
if (!user) return res.status(401).json({
msg: 'Incorrect password',
code: 92294,
});
bcrypt.compare(req.body.password, user.password, (err, compare) => {
if (err) return res.status(401).json({
msg: 'Incorrect password',
code: 96294,
});
if (compare) {
return next();
} else {
return res.status(401).json({
msg: 'Incorrect password',
code: 92298,
});
}
});
}).catch(err => errorHandler(err, res));
};
================================================
FILE: api/src/routes/middleware/hCaptcha.js
================================================
const hCaptcha = require('./../../providers/hCaptcha');
module.exports = async (req, res, next) => {
if (!process.env.H_CAPTCHA_SECRET) {
console.log(`⚠️ Warning: H_CAPTCHA_SECRET not set, skipping captcha validadation`);
return next();
}
if (!req.body.htoken) return res.status(422).json({
errors: {
htoken: {
location: "body",
param: "htoken",
msg: "You must complete the captcha"
}
}
});
const { data } = await hCaptcha.verify(req.body.htoken);
if (data.success) return next();
return res.status(422).json({
errors: {
htoken: {
location: "body",
param: "htoken",
msg: 'Captcha validation failed.'
}
}
});
};
================================================
FILE: api/src/routes/middleware/index.js
================================================
const checkPassword = require('./checkPassword');
const isInGroup = require('./isInGroup');
const isNotSelf = require('./isNotSelf');
const isGroupOwner = require('./isGroupOwner');
const canAccessBucket = require('./canAccessBucket');
const hCaptcha = require('./hCaptcha');
module.exports = {
checkPassword,
isInGroup,
isNotSelf,
isGroupOwner,
canAccessBucket,
hCaptcha,
};
================================================
FILE: api/src/routes/middleware/isGroupOwner.js
================================================
const { Group } = require('./../../models');
module.exports = async (req, res, next) => {
const groupID = (req.params.groupID || req.body.groupID);
const group = await Group.findByPk(groupID);
if (group.ownerID === req.user.id) {
return next();
} else {
return res.status(401).json({
msg: `You are not the owner of this group ${groupID}`,
code: 55213,
});
}
};
================================================
FILE: api/src/routes/middleware/isInGroup.js
================================================
const { User, Group } = require('./../../models');
module.exports = async (req, res, next) => {
const groupID = (req.params.groupID || req.body.groupID);
const user = await User.findByPk(req.user.id, {
include: [Group],
});
if (!user) return res.status(401).json({
msg: `User not found`,
code: 40120,
});
const groups = user.Groups.map(({ id }) => (id));
if (Array.isArray(groups) && groups.includes(groupID)) {
return next();
} else {
return res.status(401).json({
msg: `You do not have access to group ${groupID} in [${groups.join(', ')}]`,
code: 65196,
});
}
};
================================================
FILE: api/src/routes/middleware/isNotSelf.js
================================================
module.exports = (req, res, next) => {
if (!req.user || !req.user.id) return res.status(401).json({
msg: 'Access error',
code: 18196,
});
if (req.user.id === req.body.userID) return res.status(401).json({
msg: 'Access error',
code: 18196,
});
return next();
};
================================================
FILE: api/src/routes/user.js
================================================
const { body, validationResult, matchedData } = require('express-validator');
const errorHandler = require('./../providers/errorHandler');
const { User, Group } = require('./../models');
const middleware = require('./middleware');
const bcrypt = require('bcrypt-nodejs');
const passport = require('passport');
const express = require('express');
const app = (module.exports = express.Router());
/**
* GET /api/v1/user
*
*/
app.get('/user', [
passport.authenticate('jwt', { session: false })
], async (req, res) => {
try {
const user = await User.findByPk(req.user.id, {
include: [Group],
});
if (!user) return res.status(404).send('User not found');
return res.json(user);
} catch (error) {
errorHandler(error, res);
}
});
/**
* POST /api/v1/user
*
*/
app.post('/user', [
passport.authenticate('jwt', { session: false }),
body('firstName').exists(),
body('lastName').exists(),
body('bio').exists(),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.mapped() });
const data = matchedData(req);
await User.update(data, { where: { id: req.user.id } });
return res.json(
await User.findByPk(req.user.id)
);
} catch (error) {
return errorHandler(error, res);
}
});
/**
* POST /api/v1/user/update-password
*
* Update Password
*/
app.post('/user/update-password', [
passport.authenticate('jwt', { session: false }),
middleware.checkPassword,
body('password').exists(),
body('newPassword').exists(),
body('newPassword', 'Your password must be atleast 7 characters long').isLength({ min: 7 }),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.mapped() });
const data = matchedData(req);
await User.unscoped().update({
password: bcrypt.hashSync(data.newPassword, bcrypt.genSaltSync(10)),
}, {
where: {
id: req.user.id,
}
});
return res.json({ success: true });
} catch (error) {
return errorHandler(error, res);
}
});
================================================
FILE: api/src/scripts/blacklist.js
================================================
/**
* node ./src/scripts/blacklist.js --value="bad_word"
* docker exec -ti s3-api node ./src/scripts/blacklist.js --value="bad_word"
*
*/
require('dotenv').config();
const argv = require('minimist')(process.argv.slice(2));
const { Blacklist } = require('./../models');
const db = require('./../providers/db');
if (!argv['value']) throw Error('You must provide --value argument');
(async function Main() {
try {
await Blacklist.create({
value: argv['value']
});
console.log(`Word added to blacklist: ${argv['value']}`);
} catch (err) {
console.error(err);
} finally {
db.connectionManager.close();
}
})();
================================================
FILE: api/src/scripts/buckets.js
================================================
/**
* node ./src/scripts/buckets.js
* docker exec -ti s3-api node ./src/scripts/buckets.js
*
*/
require('dotenv').config();
const { Bucket } = require('./../models');
const db = require('./../providers/db');
(async function Main() {
try {
const buckets = await Bucket.findAll();
for (let i = 0; i < buckets.length; i++) {
const bucket = buckets[i];
console.log(`${i} - ${bucket.id} [${bucket.status}] ${bucket.name}.${bucket.namespace}`);
}
} catch (err) {
console.error(err);
} finally {
db.connectionManager.close();
}
})();
================================================
FILE: api/src/scripts/deleteUser.js
================================================
/**
* node ./src/scripts/deleteUser.js --userID="fdab7a99-2c38-444b-bcb3-f7cef61c275b"
* docker exec -ti s3-api node ./src/scripts/deleteUser.js --userID="fdab7a99-2c38-444b-bcb3-f7cef61c275b"
*
*/
require('dotenv').config();
const argv = require('minimist')(process.argv.slice(2));
const { User } = require('./../models');
const db = require('./../providers/db');
if (!argv['userID']) throw Error('You must provide --userID argument');
(async function Main() {
try {
await User.destroy({
where: {
id: argv['userID'],
}
});
console.log(`User ${argv['userID']} deleted`);
} catch (err) {
console.error(err);
} finally {
db.connectionManager.close();
}
})();
================================================
FILE: api/src/scripts/env
================================================
#!/bin/bash
if [[ $NODE_ENV == "production" ]]
then
echo "NODE_ENV=production ⚠️"
else
echo "NODE_ENV=$NODE_ENV"
fi
================================================
FILE: api/src/scripts/forgotPassword.js
================================================
/**
* node ./src/scripts/forgotPassword.js --userID="c4644733-deea-47d8-b35a-86f30ff9618e"
* docker exec -ti s3-api node ./src/scripts/forgotPassword.js --userID="c4644733-deea-47d8-b35a-86f30ff9618e"
*
*/
require('dotenv').config();
const generateJWT = require('./../providers/generateJWT');
const argv = require('minimist')(process.argv.slice(2));
const { User, Group } = require('./../models');
const db = require('./../providers/db');
const crypto = require('crypto');
if (!argv['userID']) throw Error('You must provide --userID argument');
(async function Main() {
try {
const user = await User.findByPk(argv['userID']);
const passwordResetKey = crypto.randomBytes(32).toString('base64').replace(/[^a-zA-Z0-9]/g, '');
await user.update({ passwordResetKey });
console.log(`\n\nEMAIL THIS TO THE USER\nPASSWORD RESET LINK: ${process.env.FRONTEND_URL}/reset/${passwordResetKey}\n\n`);
} catch (err) {
console.error(err);
} finally {
db.connectionManager.close();
}
})();
================================================
FILE: api/src/scripts/generate.js
================================================
/**
* node ./src/scripts/generate.js --modelName="bucket"
* docker exec -ti s3-api node ./src/scripts/generate.js --modelName="bucket"
*/
require('dotenv').config();
const argv = require('minimist')(process.argv.slice(2));
const { v4: uuidv4 } = require('uuid');
const Mustache = require('mustache');
const moment = require('moment');
const path = require('path');
const fs = require('fs');
if (!argv['modelName']) throw Error('You must provide --modelName argument');
(async function Main() {
const ucFirst = (string) => (string.charAt(0).toUpperCase().concat(string.slice(1)));
const params = {
modelname: argv['modelName'].toLowerCase(),
modelName: argv['modelName'],
ModelName: ucFirst(argv['modelName']),
modelnames: argv['modelName'].toLowerCase().concat('s'),
modelNames: argv['modelName'].concat('s'),
ModelNames: ucFirst(argv['modelName']).concat('s'),
UUID: uuidv4(),
};
if (argv['v']) console.log(params);
const pathModel = path.resolve(`./src/models/${params.ModelName}.js`);
fs.writeFileSync(pathModel, Mustache.render(fs.readFileSync(path.resolve('./src/scripts/generator/Model.js'), 'utf8'), params));
console.log(`Created: ${pathModel}`);
const pathRoute = path.resolve(`./src/routes/${params.ModelNames}.js`);
fs.writeFileSync(pathRoute, Mustache.render(fs.readFileSync(path.resolve('./src/scripts/generator/Route.js'), 'utf8'), params));
console.log(`Created: ${pathRoute}`);
const pathMigration = path.resolve(`./src/database/migrations/${moment().format('YYYYMMDDHHmmss')}-create-${params.ModelNames}.js`);
fs.writeFileSync(pathMigration, Mustache.render(fs.readFileSync(path.resolve('./src/scripts/generator/Migration.js'), 'utf8'), params));
console.log(`Created: ${pathMigration}`);
const pathSeeder = path.resolve(`./src/database/seeders/${moment().format('YYYYMMDDHHmmss')}-${params.ModelNames}.js`);
fs.writeFileSync(pathSeeder, Mustache.render(fs.readFileSync(path.resolve('./src/scripts/generator/Seeder.js'), 'utf8'), params));
console.log(`Created: ${pathSeeder}`);
})();
================================================
FILE: api/src/scripts/generator/Migration.js
================================================
module.exports = {
up: (queryInterface, Sequelize) => queryInterface.createTable('{{ ModelNames }}', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
unique: true
},
createdAt: {
type: Sequelize.DATE,
allowNull: true,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: true,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
}),
down: (queryInterface, Sequelize) => queryInterface.dropTable('{{ ModelNames }}'),
};
================================================
FILE: api/src/scripts/generator/Model.js
================================================
const Sequelize = require('sequelize');
const db = require('./../providers/db');
module.exports = db.define('{{ ModelName }}', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
unique: true
},
createdAt: {
type: Sequelize.DATE,
allowNull: true,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: true,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
}, {
tableName: '{{ ModelNames }}',
paranoid: true,
defaultScope: {
attributes: {
exclude: [
]
}
},
});
================================================
FILE: api/src/scripts/generator/Route.js
================================================
const { body, validationResult, matchedData } = require('express-validator');
const errorHandler = require('./../providers/errorHandler');
const { {{ ModelName }}, Group } = require('./../models');
const middleware = require('./middleware');
const bcrypt = require('bcrypt-nodejs');
const passport = require('passport');
const express = require('express');
const app = (module.exports = express.Router());
/**
* GET /api/v1/{{ modelnames }}
*
*/
app.get('/{{ modelnames }}', [
passport.authenticate('jwt', { session: false })
], async (req, res) => {
try {
const {{ modelnames }} = await {{ ModelName }}.findAll();
return res.json({{ modelnames }});
} catch (error) {
errorHandler(error, res);
}
});
/**
* GET /api/v1/{{ modelnames }}/:{{ modelName }}ID
*
*/
app.get('/{{ modelnames }}/:{{ modelName }}ID', [
passport.authenticate('jwt', { session: false }),
], async (req, res) => {
try {
return res.json(
await {{ ModelName }}.findByPk(req.params.{{ modelName }}ID)
);
} catch (error) {
return errorHandler(error, res);
}
});
/**
* POST /api/v1/{{ modelnames }}/:{{ modelName }}ID
*
* Update {{ ModelName }}
*/
app.post('/{{ modelnames }}/:{{ modelName }}ID', [
passport.authenticate('jwt', { session: false }),
// body('field').exists(),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.mapped() });
const data = matchedData(req);
await {{ ModelName }}.update(data, {
where: {
id: req.params.{{ modelName }}ID,
}
});
return res.json({ success: true });
} catch (error) {
return errorHandler(error, res);
}
});
================================================
FILE: api/src/scripts/generator/Seeder.js
================================================
const moment = require('moment');
const insert = [{
id: '{{ UUID }}',
createdAt: moment().format('YYYY-MM-DD HH:mm:ss'),
updatedAt: moment().format('YYYY-MM-DD HH:mm:ss'),
}];
module.exports = {
up: (queryInterface, Sequelize) => queryInterface.bulkInsert('{{ ModelNames }}', insert).catch(err => console.log(err)),
down: (queryInterface, Sequelize) => { }
};
================================================
FILE: api/src/scripts/inviteUser.js
================================================
/**
* node ./src/scripts/inviteUser.js --email="newuser@example.com" --groupID="fdab7a99-2c38-444b-bcb3-f7cef61c275b"
* docker exec -ti s3-api node ./src/scripts/inviteUser.js --email="newuser@example.com" --groupID="fdab7a99-2c38-444b-bcb3-f7cef61c275b"
*
*/
require('dotenv').config();
const generateJWT = require('./../providers/generateJWT');
const argv = require('minimist')(process.argv.slice(2));
const { User, Group, GroupsUsers } = require('./../models');
const db = require('./../providers/db');
const crypto = require('crypto');
if (!argv['email']) throw Error('You must provide --email argument');
if (!argv['groupID']) throw Error('You must provide --groupID argument');
(async function Main() {
try {
const email = argv['email'];
const groupID = argv['groupID'];
let user = await User.unscoped().findOne({
where: { email }
});
if (user) {
// Check if relationship already exists
const relationship = await GroupsUsers.findOne({
where: {
groupID,
userID: user.id
}
});
if (relationship) return console.log(`User already in this group`);
} else {
try {
user = await User.create({
email,
inviteKey: crypto.randomBytes(20).toString('hex')
});
console.log(user);
console.log(user.get({ plain: true }));
console.log(user.inviteKey);
console.log(`\n\nEMAIL THIS TO THE USER\nINVITE LINK: ${process.env.FRONTEND_URL}/invite/${user.inviteKey}\n\n`);
} catch (error) {
errorHandler(error);
}
}
// Delete all first
await GroupsUsers.destroy({
where: {
groupID,
userID: user.id,
}
});
await GroupsUsers.create({
groupID,
userID: user.id,
});
console.log(`User ${user.email} invited`);
} catch (err) {
console.error(err);
} finally {
db.connectionManager.close();
}
})();
================================================
FILE: api/src/scripts/jwt.js
================================================
/**
* node ./src/scripts/jwt.js --userID="c4644733-deea-47d8-b35a-86f30ff9618e"
* docker exec -ti s3-api node ./src/scripts/jwt.js --userID="c4644733-deea-47d8-b35a-86f30ff9618e"
*
*/
require('dotenv').config();
const generateJWT = require('./../providers/generateJWT');
const argv = require('minimist')(process.argv.slice(2));
const { User, Group } = require('./../models');
const db = require('./../providers/db');
if (!argv['userID']) throw Error('You must provide --userID argument');
(async function Main() {
try {
const user = await User.findByPk(argv['userID'], {
include: [Group]
});
console.log(`\n\nJWT:\n\n${generateJWT(user)}\n\n`);
} catch (err) {
console.error(err);
} finally {
db.connectionManager.close();
}
})();
================================================
FILE: api/src/scripts/refresh
================================================
#!/bin/bash
if [[ $NODE_ENV == "production" ]]
then
echo "ERROR: Can not refresh while in production"
else
sequelize db:migrate:undo:all
sequelize db:migrate
sequelize db:seed:all
fi
================================================
FILE: api/src/scripts/resetPassword.js
================================================
/**
* node ./src/scripts/resetPassword.js --userID="c4644733-deea-47d8-b35a-86f30ff9618e" --password="password"
* docker exec -ti s3-api node ./src/scripts/resetPassword.js --userID="c4644733-deea-47d8-b35a-86f30ff9618e" --password="password"
*
*/
require('dotenv').config();
const generateJWT = require('./../providers/generateJWT');
const argv = require('minimist')(process.argv.slice(2));
const { User, Group } = require('./../models');
const db = require('./../providers/db');
const bcrypt = require('bcrypt-nodejs');
if (!argv['userID']) throw Error('You must provide --userID argument');
if (!argv['password']) throw Error('You must provide --password argument');
(async function Main() {
try {
const user = await User.findByPk(argv['userID']);
await user.update({
password: bcrypt.hashSync(argv['password'], bcrypt.genSaltSync(10)),
passwordResetKey: null
});
console.log(`Password updated`);
} catch (err) {
console.error(err);
} finally {
db.connectionManager.close();
}
})();
================================================
FILE: api/src/scripts/seed
================================================
#!/bin/bash
if [[ $NODE_ENV == "production" ]]
then
echo "ERROR: Can not seed while in production"
else
sequelize db:seed:all
fi
================================================
FILE: api/src/scripts/sync.js
================================================
/**
* node ./src/scripts/sync.js
* docker exec -ti s3-api node ./src/scripts/sync.js
*
*/
require('dotenv').config();
const argv = require('minimist')(process.argv.slice(2));
const { Bucket } = require('./../models');
const db = require('./../providers/db');
(async function Main() {
try {
const buckets = await Bucket.findAll();
for (const bucket of buckets) {
await bucket.sync();
}
} catch (err) {
console.error(err);
}
})();
================================================
FILE: api/src/scripts/users.js
================================================
/**
* node ./src/scripts/users.js
* docker exec -ti s3-api node ./src/scripts/users.js
*
*/
require('dotenv').config();
const { User } = require('./../models');
const db = require('./../providers/db');
(async function Main() {
try {
const users = await User.findAll();
for (let i = 0; i < users.length; i++) {
const user = users[i];
console.log(`${i} - ${user.id}: ${user.email}`);
}
} catch (err) {
console.error(err);
} finally {
db.connectionManager.close();
}
})();
================================================
FILE: api/tests/Auth.js
================================================
const chai = require('chai');
const chaiHttp = require('chai-http');
const server = require('../src');
const should = chai.should();
const faker = require('faker');
chai.use(chaiHttp);
describe('Auth', () => {
/**
* GET api/v1/_authcheck
*
*/
describe('GET /api/v1/_authcheck', () => {
it('Should check auth status', (done) => {
chai.request(server)
.post('/api/v1/auth/login')
.send({
email: 'user@example.com',
password: 'Password@1234'
})
.end((err, res) => {
chai.request(server)
.get('/api/v1/_authcheck')
.set({
'Authorization': `Bearer ${res.body.accessToken}`,
})
.end((err, res) => {
res.should.have.status(200);
res.body.should.have.property('id');
done(err);
});
});
});
it('Should check bad headers', (done) => {
chai.request(server)
.get('/api/v1/_authcheck')
.set({
'Authorization': 'Bearer xx.xx.xx',
})
.end((err, res) => {
res.should.have.status(401);
done(err);
});
});
});
/**
* POST api/v1/auth/login
*
*/
describe('POST /api/v1/auth/login', () => {
it('Should return auth access token', (done) => {
chai.request(server)
.post('/api/v1/auth/login')
.send({
email: 'user@example.com',
password: 'Password@1234'
})
.end((err, res) => {
res.should.have.status(200);
res.should.be.json;
res.body.should.be.a('object');
res.body.should.have.property('accessToken');
done(err);
});
});
it('Should reject absent password', (done) => {
chai.request(server)
.post('/api/v1/auth/login')
.send({
email: 'user@example.com',
})
.end((err, res) => {
res.should.have.status(422);
done(err);
});
});
it('Should reject wrong password', (done) => {
chai.request(server)
.post('/api/v1/auth/login')
.send({
email: 'user@example.com',
password: 'BAD_PASSWORD'
})
.end((err, res) => {
res.should.have.status(401);
done(err);
});
});
});
/**
* POST api/v1/auth/sign-up
*
*/
describe('POST /api/v1/auth/sign-up', () => {
it('Should create a new user', (done) => {
chai.request(server)
.post('/api/v1/auth/sign-up')
.send({
email: faker.internet.email(),
password: 'Password@1234',
firstName: faker.name.firstName(),
lastName: faker.name.lastName(),
groupName: faker.company.bsBuzz(),
tos: '2020-03-20'
})
.end((err, res) => {
res.should.have.status(200);
res.should.be.json;
res.body.should.be.a('object');
res.body.should.have.property('accessToken');
done(err);
});
});
it('Should reject bad data', (done) => {
chai.request(server)
.post('/api/v1/auth/sign-up')
.send({
email: faker.internet.email(),
firstName: faker.name.firstName(),
})
.end((err, res) => {
res.should.have.status(422);
done(err);
});
});
it('Should reject bad email', (done) => {
chai.request(server)
.post('/api/v1/auth/sign-up')
.send({
email: 'anthonybudd@',
password: 'password',
firstName: faker.name.firstName(),
lastName: faker.name.lastName(),
groupName: faker.company.bsBuzz()
})
.end((err, res) => {
res.should.have.status(422);
done(err);
});
});
it('Should reject taken email', (done) => {
chai.request(server)
.post('/api/v1/auth/sign-up')
.send({
email: 'user@example.com',
password: 'also_bad_password',
firstName: faker.name.firstName(),
lastName: faker.name.lastName(),
groupName: faker.company.bsBuzz()
})
.end((err, res) => {
res.should.have.status(422);
done(err);
});
});
it('Should reject bad password', (done) => {
chai.request(server)
.post('/api/v1/auth/sign-up')
.send({
email: 'user@example.com',
password: '12345',
firstName: faker.name.firstName(),
lastName: faker.name.lastName(),
groupName: faker.company.bsBuzz()
})
.end((err, res) => {
res.should.have.status(422);
done(err);
});
});
});
});
================================================
FILE: api/tests/Group.js
================================================
require('dotenv').config();
const chai = require('chai');
const chaiHttp = require('chai-http');
const server = require('../src');
const should = chai.should();
chai.use(chaiHttp);
const GROUP_ID = 'fdab7a99-2c38-444b-bcb3-f7cef61c275b';
const OTHER_GROUP_ID = '190c8a70-34d1-4281-a775-850058453704';
describe('Groups', () => {
/**
* GET /api/v1/groups/:groupID
*
*/
describe('GET /api/v1/groups/:groupID', () => {
it('Should return the group', (done) => {
chai.request(server)
.get(`/api/v1/groups/${GROUP_ID}`)
.set({
'Authorization': `Bearer ${process.env.TEST_JWT}`,
})
.end((err, res) => {
res.should.have.status(200);
res.should.be.json;
res.body.should.be.a('object');
res.body.should.have.property('id');
res.body.should.have.property('name');
done();
});
});
it('Should reject bad group', (done) => {
chai.request(server)
.get(`/api/v1/groups/${OTHER_GROUP_ID}`)
.set({
'Authorization': `Bearer ${process.env.TEST_JWT}`,
})
.end((err, res) => {
res.should.have.status(401);
done();
});
});
});
/**
* POST /api/v1/groups/:groupID
*
*/
describe('POST /api/v1/groups/:groupID', () => {
it('Should update the group name', done => {
chai.request(server)
.post(`/api/v1/groups/${GROUP_ID}`)
.set({
'Authorization': `Bearer ${process.env.TEST_JWT}`,
})
.send({
name: 'Test Group'
})
.end((err, res) => {
res.should.have.status(200);
res.should.be.json;
res.body.should.be.a('object');
res.body.should.have.property('id');
res.body.should.have.property('name');
res.body.name.should.equal('Test Group');
done();
});
});
it('Should reject bad group', done => {
chai.request(server)
.post(`/api/v1/groups/${OTHER_GROUP_ID}`)
.set({
'Authorization': `Bearer ${process.env.TEST_JWT}`,
})
.send({
name: 'Test Group'
})
.end((err, res) => {
res.should.have.status(401);
done();
});
});
});
/**
* POST /api/v1/groups/:groupID/users/add
*
*/
describe('POST /api/v1/groups/:groupID/users/add', () => {
it('Should add user to the group', done => {
chai.request(server)
.post(`/api/v1/groups/${GROUP_ID}/users/add`)
.set({
'Authorization': `Bearer ${process.env.TEST_JWT}`,
})
.send({
userID: 'd700932c-4a11-427f-9183-d6c4b69368f9',
})
.end((err, res) => {
res.should.have.status(200);
res.should.be.json;
res.body.should.be.a('object');
res.body.should.have.property('userID');
res.body.should.have.property('groupID');
done();
});
});
it('Should reject bad userID', done => {
chai.request(server)
.post(`/api/v1/groups/${GROUP_ID}/users/add`)
.set({
'Authorization': `Bearer ${process.env.TEST_JWT}`,
})
.send({
userID: '00000000-0000-0000-0000-000000000000',
})
.end((err, res) => {
res.should.have.status(422);
done();
});
});
});
/**
* DELETE /api/v1/groups/:groupID/users/:userID
*
*/
describe('DELETE /api/v1/groups/:groupID/users/:userID', () => {
it('Should remove user from the group', done => {
chai.request(server)
.delete(`/api/v1/groups/${GROUP_ID}/users/d700932c-4a11-427f-9183-d6c4b69368f9`)
.set({
'Authorization': `Bearer ${process.env.TEST_JWT}`,
})
.end((err, res) => {
res.should.have.status(200);
res.should.be.json;
res.body.should.be.a('object');
res.body.should.have.property('userID');
res.body.should.have.property('groupID');
done();
});
});
});
});
================================================
FILE: api/tests/HealthCheck.js
================================================
const chai = require('chai');
const chaiHttp = require('chai-http');
const server = require('../src');
const should = chai.should();
chai.use(chaiHttp);
describe('DevOps', () => {
describe('GET /api/v1/_healthcheck', () => {
it('Should return system status', (done) => {
chai.request(server)
.get('/api/v1/_healthcheck')
.end((err, res) => {
res.should.have.status(200);
res.should.be.json;
res.body.should.be.a('object');
res.body.status.should.equal('ok');
done();
});
});
});
});
================================================
FILE: api/tests/User.js
================================================
require('dotenv').config();
const chai = require('chai');
const chaiHttp = require('chai-http');
const server = require('../src');
const should = chai.should();
chai.use(chaiHttp);
describe('User', () => {
/**
* GET /api/v1/user
*
*/
describe('GET /api/v1/user', () => {
it('Should return the user model', done => {
chai.request(server)
.get('/api/v1/user')
.set({
'Authorization': `Bearer ${process.env.TEST_JWT}`,
})
.end((err, res) => {
res.should.have.status(200);
res.should.be.json;
res.body.should.be.a('object');
res.body.should.have.property('id');
done();
});
});
it('Should reject bad access token', done => {
chai.request(server)
.get('/api/v1/user')
.set({
'Authorization': `Bearer BAD.TOKEN`,
})
.end((err, res) => {
res.should.have.status(401);
done();
});
});
});
/**
* POST /api/v1/user
*
*/
describe('POST /api/v1/user', () => {
it('Should update the current user', done => {
chai.request(server)
.post('/api/v1/user')
.set({
'Authorization': `Bearer ${process.env.TEST_JWT}`,
})
.send({
firstName: 'John',
lastName: 'Smith'
})
.end((err, res) => {
res.should.have.status(200);
res.should.be.json;
res.body.should.be.a('object');
done();
});
});
});
/**
* POST /api/v1/user/update-password
*
*/
describe('POST /api/v1/user/update-password', () => {
it('Should update the current users password', (done) => {
chai.request(server)
.post('/api/v1/user/update-password')
.set({
'Authorization': `Bearer ${process.env.TEST_JWT}`,
})
.send({
password: 'password',
newPassword: 'newpassword'
})
.end((err, res) => {
res.should.have.status(200);
res.should.be.json;
res.body.should.be.a('object');
done();
});
});
});
});
================================================
FILE: automation-test/.gitlab-ci.yml
================================================
stages:
- build
build-job:
image: docker:dind
stage: build
services:
- docker:dind
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
script:
- docker login $CI_REGISTRY -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD
- docker build -t $IMAGE_TAG .
- docker push $IMAGE_TAG
================================================
FILE: automation-test/Dockerfile
================================================
FROM ubuntu:noble
RUN apt-get update && apt-get install -y curl
RUN curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/arm64/kubectl"
RUN install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
COPY bucket.yml /root/bucket.yml
================================================
FILE: automation-test/bucket.yml
================================================
apiVersion: v1
kind: Namespace
metadata:
name: XXX
labels:
name: XXX
---
apiVersion: v1
kind: Pod
metadata:
labels:
app: XXX-pod
name: XXX-pod
namespace: XXX
spec:
containers:
- name: XXX-pod
image: quay.io/minio/minio:latest
env:
- name: MINIO_ROOT_USER
value: root
- name: MINIO_ROOT_PASSWORD
value: password
command:
- /bin/bash
- -c
args:
- minio server /data --console-address :9001
ports:
- containerPort: 9001
volumeMounts:
- name: longhornvolume
mountPath: /data
volumes:
- name: longhornvolume
persistentVolumeClaim:
claimName: XXX-pvc
---
apiVersion: v1
kind: Service
metadata:
name: XXX-svc
namespace: XXX
spec:
selector:
app: XXX-pod
ports:
- protocol: TCP
port: 80
targetPort: 9001
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
namespace: XXX
name: XXX-ing
annotations:
kubernetes.io/ingress.class: "traefik"
spec:
rules:
- host: XXX.minio.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: XXX-svc
port:
number: 80
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: XXX-pvc
namespace: XXX
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 5Gi
================================================
FILE: automation-test/deployment.yml
================================================
kind: Deployment
apiVersion: apps/v1
metadata:
name: automation-test-deployment
labels:
app: automation-test
spec:
replicas: 1
selector:
matchLabels:
app: automation-test
template:
metadata:
labels:
app: automation-test
spec:
volumes:
- name: storage-cluster-config
secret:
secretName: storage-cluster-config
containers:
- name: automation-test
image: gitlab.local:5050/anthonybudd/automation-test:master
imagePullPolicy: Always
ports:
- containerPort: 80
command: [ "/bin/bash", "-c", "--" ]
args: [ "while true; do sleep 30; done;" ]
volumeMounts:
- name: storage-cluster-config
mountPath: "/root/config"
subPath: config
================================================
FILE: aws-sdk-test/.gitignore
================================================
node_modules/
================================================
FILE: aws-sdk-test/index.js
================================================
const { S3Client, ListObjectsV2Command, PutObjectCommand } = require("@aws-sdk/client-s3");
const Bucket = 'kjdoewl';
const Namespace = 'gdiwk';
const accessKeyId = "kUoZRWyhUae7tFZNduTS";
const secretAccessKey = "qxYIqD/8WnvWYIkY7Rg7PSqSMrnxcVfBdpWgzz7z";
(async function () {
const client = new S3Client({
region: 'us-west-2',
endpoint: `https://${Bucket}.${Namespace}.s3.anthonybudd.io`,
forcePathStyle: true,
sslEnabled: true,
credentials: {
accessKeyId,
secretAccessKey
},
});
const Key = `${Date.now().toString()}.txt`;
await client.send(new PutObjectCommand({
Bucket,
Key,
Body: `The time now is ${new Date().toLocaleString()}`,
ACL: 'public-read',
ContentType: 'text/plain',
}));
console.log(`New object successfully written to: ${Bucket}://${Key}\n`);
const { Contents } = await client.send(new ListObjectsV2Command({ Bucket }));
console.log("Bucket Contents:");
console.log(Contents.map(({ Key }) => Key).join("\n"));
})();
================================================
FILE: aws-sdk-test/package.json
================================================
{
"name": "aws-sdk-test",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@aws-sdk/client-s3": "^3.575.0",
"aws-sdk": "^2.1620.0"
}
}
================================================
FILE: deployment-test/.gitlab-ci.yml
================================================
stages:
- build
build-job:
image: docker:dind
stage: build
services:
- docker:dind
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
script:
- docker login $CI_REGISTRY -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD
- docker build -t $IMAGE_TAG .
- docker push $IMAGE_TAG
================================================
FILE: deployment-test/Dockerfile
================================================
FROM nginx:alpine
COPY . /usr/share/nginx/html
================================================
FILE: deployment-test/index.html
================================================
<h1>Compiled on GitLab</h1>
<h1>Deployed on K3s</h1>
================================================
FILE: deployment-test/k8s.yml
================================================
kind: Deployment
apiVersion: apps/v1
metadata:
name: website-deployment
labels:
app: website
spec:
replicas: 1
selector:
matchLabels:
app: website
template:
metadata:
labels:
app: website
spec:
containers:
- name: website
image: gitlab.local:5050/anthonybudd/website:master
imagePullPolicy: Always
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: website-service
spec:
selector:
app: website
ports:
- protocol: TCP
port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
namespace: default
name: website-ingress
annotations:
kubernetes.io/ingress.class: "traefik"
spec:
rules:
- host: website.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: website-service
port:
number: 80
================================================
FILE: frontend/.browserslistrc
================================================
> 1%
last 2 versions
not dead
not ie 11
================================================
FILE: frontend/.editorconfig
================================================
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
================================================
FILE: frontend/.eslintrc.js
================================================
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
],
}
================================================
FILE: frontend/.gitignore
================================================
.DS_Store
node_modules
/dist
# local env files
.env
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
================================================
FILE: frontend/.gitlab-ci.yml
================================================
stages:
- build
build-job:
image: docker:dind
stage: build
services:
- docker:dind
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
VITE_API_URL: http://s3-api.anthonybudd.io/api/v1
VITE_S3_ROOT: s3.anthonybudd.io
before_script:
- apk --update add nodejs npm
script:
- docker login $CI_REGISTRY -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD
- npm install
- npm run build
- docker build -t $IMAGE_TAG .
- docker push $IMAGE_TAG
================================================
FILE: frontend/Dockerfile
================================================
FROM nginx
COPY default.conf /etc/nginx/conf.d/default.conf
COPY ./dist /usr/share/nginx/html
================================================
FILE: frontend/ReadMe.md
================================================
# Frontend
This represents the AWS console found at [aws.amazon.com/console](https://aws.amazon.com/console/). This is a Vue.js static frontend SPA that makes HTTP requests to the [REST API](./api/ReadMe.md) for users to login, create and delete S3 buckets.
### Set-up
```
npm i
cp .env.example .env
npm run dev
```
<img src="https://raw.githubusercontent.com/anthonybudd/s3-from-scratch/master/_img/frontend.gif">
================================================
FILE: frontend/default.conf
================================================
server {
listen 80;
listen [::]:80;
server_name localhost;
#access_log /var/log/nginx/host.access.log main;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html =404;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
================================================
FILE: frontend/index.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>S3</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
================================================
FILE: frontend/jsconfig.json
================================================
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}
================================================
FILE: frontend/k8s/clusterissuer.yml
================================================
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
class: nginx
================================================
FILE: frontend/k8s/frontend-ssl.ingress.yml
================================================
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: frontend-ingress
namespace: s3-api
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
kubernetes.io/ingress.class: "traefik"
spec:
tls:
- hosts:
- s3.anthonybudd.io
secretName: s3-anthonybudd-io-cert
rules:
- host: s3.anthonybudd.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: frontend-service
port:
number: 80
================================================
FILE: frontend/k8s/frontend.deployment.yml
================================================
kind: Deployment
apiVersion: apps/v1
metadata:
name: frontend-deployment
namespace: s3-api
labels:
app: frontend
spec:
replicas: 1
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: gitlab.local:5050/anthonybudd/frontend:master
imagePullPolicy: Always
ports:
- containerPort: 80
================================================
FILE: frontend/k8s/frontend.ingress.yml
================================================
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: frontend-ingress
namespace: s3-api
annotations:
kubernetes.io/ingress.class: "traefik"
spec:
rules:
- host: s3-app.anthonybudd.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: frontend-service
port:
number: 80
================================================
FILE: frontend/k8s/frontend.service.yml
================================================
apiVersion: v1
kind: Service
metadata:
name: frontend-service
namespace: s3-api
spec:
selector:
app: frontend
ports:
- protocol: TCP
port: 80
targetPort: 80
================================================
FILE: frontend/package.json
================================================
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix --ignore-path .gitignore"
},
"dependencies": {
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@kyvg/vue3-notification": "^3.2.1",
"@mdi/font": "7.0.96",
"axios": "^1.6.8",
"core-js": "^3.29.0",
"roboto-fontface": "*",
"vue": "^3.2.0",
"vue-router": "^4.0.0",
"vuetify": "^3.0.0",
"vuex": "^4.1.0",
"webfontloader": "^1.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"eslint": "^8.37.0",
"eslint-plugin-vue": "^9.3.0",
"sass": "^1.60.0",
"vite": "^4.2.0",
"vite-plugin-vuetify": "^1.0.0"
}
}
================================================
FILE: frontend/src/App.vue
================================================
<template>
<router-view v-if="isLoaded" />
<notifications />
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useStore } from 'vuex';
import api from './api';
import router from "@/plugins/router";
const store = useStore();
const isLoaded = ref(false);
onMounted(async () => {
const skipAuthPages = ['/login', '/sign-up']; // Auth not needed on these pages
const skipAuth = new RegExp(skipAuthPages.join('|')).test(window.location.href);
if (skipAuth) {
isLoaded.value = true;
return;
}
const accessToken = localStorage.getItem('AccessToken');
if (!accessToken) {
isLoaded.value = true;
router.push('/logout');
} else {
api.setJWT(accessToken);
try {
const { data: user } = await api.user.get();
store.commit('setUser', user);
} catch (error) {
router.push('/login');
}
isLoaded.value = true;
}
});
</script>
================================================
FILE: frontend/src/api/Auth.js
================================================
import Service from './Service';
class Auth extends Service {
login(data) {
return this.axios.post('/auth/login', data);
}
signUp(data) {
return this.axios.post('/auth/sign-up', data);
}
}
export default Auth;
================================================
FILE: frontend/src/api/Buckets.js
================================================
import Service from './Service';
class Bucket extends Service {
index() {
return this.axios.get(`/buckets`);
}
get(bucketID) {
return this.axios.get(`/buckets/${bucketID}`);
}
create(bucket) {
return this.axios.post(`/buckets`, bucket);
}
delete(bucketID) {
return this.axios.delete(`/buckets/${bucketID}`);
}
}
export default Bucket;
================================================
FILE: frontend/src/api/Service.js
================================================
import axios from 'axios';
class Service {
constructor(JWT) {
this.JWT = JWT;
this.url = import.meta.env.VITE_API_URL || 'https://localhost:4431/api';
this.axios = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'https://localhost:4431/api',
headers: {
Authorization: `Bearer ${JWT}`,
}
});
}
}
export default Service;
================================================
FILE: frontend/src/api/User.js
================================================
import Service from './Service';
class User extends Service {
get() {
return this.axios.get('/user');
}
stats() {
return this.axios.get(`/stats`);
}
}
export default User;
================================================
FILE: frontend/src/api/index.js
================================================
import Auth from './Auth';
import User from './User';
import Buckets from './Buckets';
class API {
constructor(JWT) {
this.setJWT(JWT);
}
setJWT(JWT) {
this.JWT = JWT;
this.auth = new Auth(JWT);
this.user = new User(JWT);
this.buckets = new Buckets(JWT);
}
getJWT() {
return this.JWT;
}
}
let instance;
if (!instance) instance = new API();
export default instance;
================================================
FILE: frontend/src/components/CreateBucketForm.vue
================================================
<template>
<v-card title="Create Bucket">
<v-card-text>
<v-text-field
v-model="namespace"
label="Namespace"
variant="outlined"
density="compact"
required
@keyup="onKeyUpNamespace"
@keydown.enter.prevent="onClickCreateBucket"
:error-messages="(errors.namespace) ? [errors.namespace.msg] : []"
></v-text-field>
<v-text-field
v-model="bucketName"
label="Bucket Name"
variant="outlined"
density="compact"
required
@keyup="onKeyUpBucketName"
@keydown.enter.prevent="onClickCreateBucket"
:error-messages="(errors.name) ? [errors.name.msg] : []"
></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
variant="flat"
color="primary"
@click="onClickCreateBucket"
:loading="loading"
:disabled="loading || bucketName.length < 4 || namespace.length < 4"
>Create</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { useNotification } from "@kyvg/vue3-notification";
import { defineEmits, ref, inject } from 'vue';
import api from './../api';
const { notify } = useNotification();
const emit = defineEmits(['showsidebar']);
const errorHandler = inject('errorHandler');
const errors = ref({});
const loading = ref(false);
const namespace = ref('');
const bucketName = ref('');
const onClickCreateBucket = async () => {
try {
loading.value = true;
const { data: bucket } = await api.buckets.create({
namespace: namespace.value,
name: bucketName.value,
});
emit('onCreateBucket', bucket);
notify({
title: 'Bucket Created'
});
} catch (error) {
errorHandler(error, (data, code) => {
if (code === 422) errors.value = data.errors;
});
} finally {
loading.value = false;
}
};
const onKeyUpBucketName = async () => {
bucketName.value = bucketName.value.replace(/[^a-zA-Z0-9-]/g, '');
};
const onKeyUpNamespace = async () => {
namespace.value = namespace.value.replace(/[^a-zA-Z0-9-]/g, '');
};
</script>
================================================
FILE: frontend/src/components/TermsOfService.vue
================================================
<template>
<v-card>
<v-card-text>
<h1>Terms of Service for s3.anthonybudd.io</h1>
<h3>1. Introduction</h3>
<p>
Welcome to S3.anthonybudd.io ("Service" or "API"). These Terms of Service ("Terms")
govern your access to and use of the Service offered by Anthony Budd ("we," "us," or "our"). By
accessing or using the Service, you ("you" or "your") agree to be bound by these Terms. If you do not
agree
to all of the Terms, you are not authorized to access or use the Service.
</p>
<h3>2. Definitions</h3>
<p>
Account: Your account with the Service.
API Key: A unique identifier used to access the Service.
API Request: A request sent to the Service to perform an operation, such as uploading or downloading an
object.
Content: Any data, information, or materials you upload, store, or transmit through the Service.
Object: A unit of data stored in the Service, identified by a unique key.
Service: The s3.anthonybudd.io service, including all features, functionalities,
and
documentation offered by us.
</p>
<h3>3. Your Account</h3>
<p>
3.1 You must be at least 18 years old to access or use the Service.
3.2 You are responsible for maintaining the confidentiality of your Account information, including your
login credentials, and for all activities that occur under your Account.
3.3 You agree to notify us immediately of any unauthorized use of your Account or any other security
breach.
3.4 We reserve the right to terminate or suspend your access to the Service at any time and for any
reason,
with or without notice.
</p>
<h3>4. Content</h3>
<p>
4.1 You are solely responsible for all Content you upload, store, or transmit through the Service.
4.2 You represent and warrant that you have all necessary rights, licenses, and permissions to use,
upload,
store, and transmit the Content.
4.3 You agree not to upload, store, or transmit any Content that is:
* Illegal, obscene, defamatory, threatening, harassing, or abusive.
* Infringes on the intellectual property rights of any third party.
* Contains viruses or other malicious code.
* Violates any applicable laws or regulations.
</p>
<h3>5. Use of the Service</h3>
<p>
5.1 You may use the Service only for lawful purposes and in accordance with these Terms.
5.2 You are responsible for ensuring that your use of the Service complies with all applicable laws and
regulations.
5.3 You agree not to:
* Reverse engineer, decompile, disassemble, or modify the Service.
* Access or use the Service in a way that disrupts, overloads, or impairs the performance of the Service
or
any other user's use of the Service.
* Use the Service to store or transmit any Content that violates these Terms.
* Attempt to gain unauthorized access to the Service or any other user's Account.
</p>
<h3>6. Fees and Payment</h3>
<p>
6.1 The Service may offer free and paid tiers. Pricing information will be available on our website or
within the Service.
6.2 For paid tiers, you agree to pay the applicable fees on time and in accordance with our payment
terms.
6.3 We reserve the right to change our pricing at any time, with or without notice.
</p>
<h3>7. Service Level Agreement (SLA)</h3>
<p>
There is no Service Level Agreement (SLA) for the Service. We do not guarantee any specific level of
uptime.
</p>
<h3>8. Intellectual Property</h3>
<p>
8.1 The Service and all underlying technology are protected by intellectual property rights, including
copyrights, trademarks, and patents. You agree not to remove or alter any proprietary notices on the
Service.
8.2 You grant us a non-exclusive, worldwide, royalty-free right to use, reproduce, modify, publish,
distribute, and sublicense your Content solely for the purpose of providing the Service to you.
</p>
<h3>9. Disclaimer of Warranties</h3>
<p>
THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
NON-INFRINGEMENT, OR COURSE OF DEALING. WE DO NOT WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED,
ERROR-FREE, OR VIRUS-FREE.
</p>
<h3>10. Limitation of Liability</h3>
<p>
IN NO EVENT SHALL WE, OUR AFFILIATES, OR OUR LICENSORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING OUT OF OR RELATING TO YOUR USE OF THE SERVICE,
INCLUDING
BUT NOT LIMITED TO DAMAGES FOR LOSS OF PROFITS, DATA, GOODWILL, OR USE, EVEN IF WE HAVE BEEN ADVISED
</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text">Close</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
</script>
================================================
FILE: frontend/src/layouts/default/AppBar.vue
================================================
<template>
<div>
<v-app-bar
flat
:height="(xs) ? 80 : 150"
>
<v-container
fluid
class="px-0 py-0"
>
<v-container class="d-flex align-center justify-center">
<v-app-bar-nav-icon
v-if="xs"
@click="drawer = !drawer"
></v-app-bar-nav-icon>
<v-spacer v-if="xs"></v-spacer>
<div class="d-flex align-center justify-center">
<v-img
:width="(xs) ? 40 : 60"
src="./../../assets/logo.png"
></v-img>
<h1
v-if="!xs"
class="ml-4"
>S3</h1>
</div>
<v-spacer></v-spacer>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
color="primary"
variant="outlined"
v-bind="props"
class="px-1"
>
<v-icon
icon="mdi-account"
size="large"
></v-icon>
</v-btn>
</template>
<v-card>
<v-card-text class="d-flex align-center justify-center">
<v-btn
color="primary"
variant="tonal"
class="px-1"
>
{{ user.firstName.charAt(0) }}.{{ user.lastName.charAt(0) }}
</v-btn>
<p class="ml-4">
<span class="font-weight-bold">{{ user.firstName }} {{ user.lastName }}</span><br>
<span class="text-medium-emphasis">{{ user.id.split('-')[0].toUpperCase() }}</span>
</p>
</v-card-text>
<v-divider></v-divider>
<v-list class="pb-0 pt-0">
<v-list-item :to="'/logout'">
<v-list-item-title>Logout</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-container>
<v-divider class="d-none d-sm-block"></v-divider>
</v-container>
</v-app-bar>
<v-navigation-drawer
v-model="drawer"
temporary
>
<v-list-item
v-for="link in links"
:to="link.href"
:key="link.text"
:title="link.text"
link
></v-list-item>
</v-navigation-drawer>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useDisplay } from 'vuetify';
import { useStore } from 'vuex';
const store = useStore();
const { xs } = useDisplay();
const drawer = ref(false);
const user = ref(store.getters['user']);
const links = [
{ href: '/', text: 'Buckets' },
{ href: '/logout', text: 'Logout' },
];
</script>
================================================
FILE: frontend/src/layouts/default/Auth.vue
================================================
<template>
<v-app class="bg-grey-lighten-3">
<default-view />
</v-app>
</template>
<script setup>
import DefaultBar from './AppBar.vue';
import DefaultView from './View.vue';
</script>
================================================
FILE: frontend/src/layouts/default/Default.vue
================================================
<template>
<v-app>
<default-bar />
<notifications />
<default-view />
</v-app>
</template>
<script setup>
import DefaultBar from './AppBar.vue';
import DefaultView from './View.vue';
</script>
================================================
FILE: frontend/src/layouts/default/View.vue
================================================
<template>
<v-main>
<router-view />
</v-main>
</template>
<script setup>
//
</script>
================================================
FILE: frontend/src/main.js
================================================
/**
* main.js
*
* Bootstraps Vuetify and other plugins then mounts the App`
*/
// Components
import App from './App.vue';
// Composables
import { createApp } from 'vue';
// Plugins
import { registerPlugins } from '@/plugins';
const app = createApp(App);
registerPlugins(app);
app.mount('#app');
================================================
FILE: frontend/src/plugins/errorHandler.js
================================================
import { useNotification } from "@kyvg/vue3-notification";
const { notify } = useNotification();
export default function errorHandler(error, cb) {
console.error(error);
let code = false;
let data = {};
if (error.response) {
code = error.response.status;
data = error.response.data;
if (code === 422) {
// Do nothing
} else {
notify({
title: error.response.data.msg || error.response.data,
});
}
} else {
notify({
title: error.message,
});
}
if (typeof cb === 'function') cb(data, code, error);
};
================================================
FILE: frontend/src/plugins/index.js
================================================
import { loadFonts } from './webfontloader';
import Notifications from '@kyvg/vue3-notification';
import vuetify from './vuetify';
import router from './router';
import store from './store';
import errorHandler from './errorHandler';
import api from './../api/index.js';
export function registerPlugins(app) {
loadFonts();
app
.use(Notifications)
.use(store)
.use(vuetify)
.use(router)
.use({
install: (app) => {
app.provide('errorHandler', errorHandler);
app.provide('api', api);
},
});
}
================================================
FILE: frontend/src/plugins/router.js
================================================
// Composables
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{
path: '/',
component: () => import('@/layouts/default/Default.vue'),
children: [
{
path: '',
name: 'Buckets',
component: () => import(/* webpackChunkName: "home" */ '@/views/Buckets.vue'),
},
],
},
{
path: '/',
component: () => import('@/layouts/default/Auth.vue'),
children: [
{
path: '/login',
name: 'Login',
component: () => import(/* webpackChunkName: "home" */ '@/views/Login.vue'),
},
{
path: '/sign-up',
name: 'SignUp',
component: () => import(/* webpackChunkName: "home" */ '@/views/SignUp.vue'),
},
{
path: '/logout',
name: 'Logout',
beforeEnter: async (to, from, next) => {
console.warn('/logout');
localStorage.removeItem('AccessToken');
if (to.query.redirect) {
next(`/login?redirect=${to.query.redirect}`);
} else {
next('/login');
}
},
},
],
},
];
const router = createRouter({
history: createWebHistory(import.meta.env.VITE_BASE_URL),
routes,
});
export default router;
================================================
FILE: frontend/src/plugins/store.js
================================================
import { createStore } from "vuex";
export default createStore({
state: {
user: null,
},
mutations: {
setUser(state, payload) {
state.user = payload;
}
},
getters: {
user(state) {
return state.user;
}
},
});
================================================
FILE: frontend/src/plugins/vuetify.js
================================================
/**
* plugins/vuetify.js
*
* Framework documentation: https://vuetifyjs.com`
*/
// Styles
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
// Composables
import { createVuetify } from 'vuetify'
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: {
themes: {
light: {
colors: {
primary: '#1867C0',
secondary: '#5CBBF6',
},
},
},
},
})
================================================
FILE: frontend/src/plugins/webfontloader.js
================================================
/**
* plugins/webfontloader.js
*
* webfontloader documentation: https://github.com/typekit/webfontloader
*/
export async function loadFonts () {
const webFontLoader = await import(/* webpackChunkName: "webfontloader" */'webfontloader')
webFontLoader.load({
google: {
families: ['Roboto:100,300,400,500,700,900&display=swap'],
},
})
}
================================================
FILE: frontend/src/styles/settings.scss
================================================
/**
* src/styles/settings.scss
*
* Configures SASS variables and Vuetify overwrites
*/
// https://vuetifyjs.com/features/sass-variables/`
// @use 'vuetify/settings' with (
// $color-pack: false
// );
.v-btn--size-default {
min-width: 36px !important;
}
.btn-create-bucket {
margin-top: -25px;
}
.m-auto {
margin: auto;
}
.hide-items .v-data-table-footer__items-per-page {
display: none;
}
.link {
color: rgb(0, 0, 238);
text-decoration-line: underline;
}
.credentials {
font-size: 12px;
text-wrap: wrap;
border: 1px solid #cdcdcd;
overflow: hidden;
border-radius: 5px;
margin: 5px 0px;
width: 100%;
background: #ececec;
margin-bottom: 10px;
max-width: 470px;
p {
padding: 5px 10px;
background: #ffffff;
border-bottom: 1px solid #cdcdcd;
}
code {
background: #ececec;
pre {
padding: 5px 10px;
}
}
}
.v-table > .v-table__wrapper > table > tbody > tr > td,
.v-table > .v-table__wrapper > table > tbody > tr > th,
.v-table > .v-table__wrapper > table > thead > tr > td,
.v-table > .v-table__wrapper > table > thead > tr > th,
.v-table > .v-table__wrapper > table > tfoot > tr > td,
.v-table > .v-table__wrapper > table > tfoot > tr > th {
padding: 0px 8px !important;
}
.v-table .v-table__wrapper > table > tbody > tr > td,
.v-table .v-table__wrapper > table > tbody > tr > th {
border-bottom: none !important;
}
.v-table .v-table__wrapper > table > tbody > tr:not(:first-child):not(.expanded) > td,
.v-table .v-table__wrapper > table > tbody > tr:not(:first-child):not(.expanded) > th {
border-top: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
border-bottom: none;
}
.v-table .v-table__wrapper > table > tbody > tr:last-child > td,
.v-table .v-table__wrapper > table > tbody > tr:last-child > th {
border-bottom: none !important;
}
================================================
FILE: frontend/src/views/Buckets.vue
================================================
<template>
<div>
<v-container class="fill-height">
<v-sheet
width="100%"
rounded
border
>
<v-container class="px-4 py-4 d-flex align-center justify-center">
<v-text-field
v-model="search"
label="Search"
variant="outlined"
density="compact"
></v-text-field>
<v-spacer></v-spacer>
<v-dialog
v-model="createBucketDialog"
max-width="350"
>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
color="grey-darken-3 btn-create-bucket"
variant="flat"
>
Create Bucket
</v-btn>
</template>
<template v-slot:default="{ isActive }">
<CreateBucketForm @onCreateBucket="onCreateBucket" />
</template>
</v-dialog>
</v-container>
<v-data-table
:search="search"
:headers="headers"
:items="items"
:items-per-page-options="[]"
v-model:expanded="expanded"
class="hide-items"
>
<template v-slot:no-data>
<div class="text-center my-4">
<h2>No Buckets</h2>
<p>Click <b>Create Bucket</b> to create a new bucket.</p>
</div>
</template>
<template v-slot:item.name="{ item }">
<p class="my-4">
<b>{{ item.name }}</b><br>
<small class="d-none d-sm-flex">
{{ item.endpoint }}
</small>
</p>
</template>
<template v-slot:item.status="{ item }">
<template
v-if="item.status === 'Provisioning'"
color="red"
height="6"
indeterminate
rounded
>
<p>{{ item.status }}</p>
<v-progress-linear
color="deep-purple-accent-4"
height="6"
indeterminate
rounded
></v-progress-linear>
</template>
<v-chip
v-else-if="item.status === 'Provisioned'"
color="green"
size="small"
label
>
Provisioned
<v-icon
icon="mdi-check"
end
></v-icon>
</v-chip>
<v-chip
v-else
size="small"
label
>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-dialog
v-model="item.deleteBucketDialog"
max-width="400"
>
<template v-slot:activator="{ props: activatorProps }">
<v-btn
v-if="item.status !== 'Provisioning'"
v-bind="activatorProps"
size="small"
variant="tonal"
class="red--text"
>
Delete
</v-btn>
</template>
<v-card title="Delete Bucket">
<v-card-text>
<p>
Are you sure you want to delete the bucket named <b>{{ item.name }}</b> and all
of it's contents? This action cannot be undone.
</p>
<v-text-field
v-model="item._name"
label="Bucket Name"
:placeholder="item.name"
variant="outlined"
density="compact"
max-width="200"
class="mt-2"
@keydown.enter.prevent="deleteBucket(item)"
></v-text-field>
</v-card-text>
<template v-slot:actions>
<v-spacer></v-spacer>
<v-btn
text
@click="item.deleteBucketDialog = false"
>
Cancel
</v-btn>
<v-btn
variant="flat"
color="red"
:loading="item.loading"
:disabled="item.loading || item._name !== item.name"
@click="deleteBucket(item)"
>
Delete
</v-btn>
</template>
</v-card>
</v-dialog>
</template>
<template v-slot:expanded-row="{ columns, item }">
<tr class="expanded">
<td :colspan="columns.length">
<div class="credentials">
<p class="mb-1"><v-icon
size="small"
icon="mdi-alert"
></v-icon> This will only be shown once.
</p>
<code>
<pre>
<strong>AccessKeyID:</strong><br>{{ item.accessKeyID }}
<strong>SecretAccessKey:</strong><br>{{ item.secretAccessKey }}
</pre>
</code>
</div>
</td>
</tr>
</template>
</v-data-table>
</v-sheet>
</v-container>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import api from './../api';
import CreateBucketForm from './../components/CreateBucketForm.vue';
import { useNotification } from "@kyvg/vue3-notification";
let updateLoop;
const { notify } = useNotification();
const items = ref([]);
const search = ref('');
const createBucketDialog = ref(false);
const headers = [
{ title: 'Name', key: 'name', width: '40%' },
{ title: 'Status', key: 'status', width: '30%' },
{ title: 'Actions', key: 'actions', width: '30%', align: 'right' },
];
const expanded = computed(() => (items.value.map(({ id, accessKeyID, secretAccessKey }) => ((accessKeyID && secretAccessKey) ? id : null))));
onMounted(async () => {
const { data } = await api.buckets.index();
items.value = data;
updateLoop = setInterval(updateBuckets, (10 * 1000));
});
const updateBuckets = async () => {
const { data: buckets } = await api.buckets.index();
buckets.forEach((bucket) => {
const index = items.value.findIndex((item) => item.id === bucket.id);
if (index === -1) {
items.value.push(bucket);
} else {
// This is required so you credentials are not lost
items.value[index].status = bucket.status;
}
});
};
const onCreateBucket = async (bucket) => {
createBucketDialog.value = false;
items.value.push(bucket);
};
const deleteBucket = async (bucket) => {
bucket.loading = true;
await api.buckets.delete(bucket.id);
items.value = items.value.filter((item) => item.id !== bucket.id);
bucket.loading = true;
bucket.deleteBucketDialog = false;
notify({
title: 'Bucket Deleted',
});
};
</script>
================================================
FILE: frontend/src/views/Login.vue
================================================
<template>
<v-container class="fill-height d-flex align-center justify-center">
<v-sheet
width="500"
rounded
border
>
<v-container class="px-4 py-4">
<v-img
class="mb-4 d-block m-auto"
width="60"
min-width="60"
src="./../assets/logo.png"
></v-img>
<h2 class="text-center mb-2">Login</h2>
<v-text-field
v-model="email"
:disabled="isLoading"
variant="outlined"
label="Email"
required
density="compact"
class="mb-2"
@keydown.enter.prevent="onClickLogin"
:error-messages="(errors.email) ? [errors.email.msg] : []"
></v-text-field>
<v-text-field
v-model="password"
:disabled="isLoading"
variant="outlined"
type="password"
label="Password"
required
density="compact"
class="mb-2"
@keydown.enter.prevent="onClickLogin"
:error-messages="(errors.password) ? [errors.password.msg] : []"
></v-text-field>
<vue-hcaptcha
v-if="hCaptchaSiteKey"
@verify="onVerifyHcaptcha"
@expired="onExpiredHcaptcha"
:sitekey="hCaptchaSiteKey"
:key="failedAttempts"
class="mb-2"
></vue-hcaptcha>
<p
v-if="errors.htoken"
class="text-caption text-red mb-4"
>{{ errors.htoken.msg }}</p>
<div>
<v-btn
:disabled="isLoading"
:loading="isLoading"
color="primary"
@click="onClickLogin"
class="mr-2"
>Login</v-btn>
<v-btn
:disabled="isLoading"
color="default"
:to="`/sign-up`"
>Sign-up</v-btn>
</div>
</v-container>
</v-sheet>
</v-container>
</template>
<script setup>
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import { ref, inject } from 'vue';
import api from './../api';
import router from "@/plugins/router";
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
const store = useStore();
const route = useRoute();
const errorHandler = inject('errorHandler');
const hCaptchaSiteKey = import.meta.env.VITE_H_CAPTCHA_SITE_KEY;
const isLoading = ref(false);
const errors = ref({});
const email = ref('');
const password = ref('');
const failedAttempts = ref(0);
const hCaptcha = ref(false);
const onVerifyHcaptcha = (token) => {
hCaptcha.value = token;
};
const onExpiredHcaptcha = () => {
hCaptcha.value = false;
};
const onClickLogin = async () => {
try {
isLoading.value = true;
errors.value = {};
const { data } = await api.auth.login({
email: email.value,
password: password.value,
htoken: hCaptcha.value,
});
localStorage.setItem('AccessToken', data.accessToken);
api.setJWT(data.accessToken);
const { data: user } = await api.user.get();
store.commit('setUser', user);
if (route.query.redirect) {
router.push(atob(route.query.redirect));
} else {
router.push('/');
}
} catch (error) {
errorHandler(error, (data, code) => {
if (code === 422) errors.value = data.errors;
failedAttempts.value++;
});
} finally {
isLoading.value = false;
}
};
</script>
================================================
FILE: frontend/src/views/SignUp.vue
================================================
<template>
<v-container class="fill-height d-flex align-center justify-center">
<v-sheet
width="500"
rounded
border
>
<v-container class="px-4 py-4">
<v-img
class="mb-4 d-block m-auto"
width="60"
min-width="60"
src="./../assets/logo.png"
></v-img>
<h2 class="text-center mb-2">Sign-up</h2>
<v-text-field
v-model="firstName"
:disabled="isLoading"
variant="outlined"
label="Name"
required
density="compact"
class="mb-2"
:error-messages="(errors.firstName) ? [errors.firstName.msg] : []"
></v-text-field>
<v-text-field
v-model="email"
:disabled="isLoading"
variant="outlined"
label="Email"
required
density="compact"
class="mb-2"
:error-messages="(errors.email) ? [errors.email.msg] : []"
></v-text-field>
<v-text-field
v-model="password"
:disabled="isLoading"
variant="outlined"
type="password"
label="Password"
required
density="compact"
@keydown.enter.prevent="onClickSignUp"
:error-messages="(errors.password) ? [errors.password.msg] : []"
></v-text-field>
<v-checkbox
label="Checkbox"
v-model="tos"
true-value="2023-04-22"
false-value=""
hide-details
>
<template v-slot:label>
<div>
I agree to the
<v-dialog max-width="800">
<template v-slot:activator="{ props }">
<span
v-bind="props"
class="link"
>
Terms of Service
</span>
</template>
<template v-slot:default="{}">
<TermsOfService></TermsOfService>
</template>
</v-dialog>
</div>
</template>
</v-checkbox>
<vue-hcaptcha
v-if="hCaptchaSiteKey"
@verify="onVerifyHcaptcha"
@expired="onExpiredHcaptcha"
:sitekey="hCaptchaSiteKey"
:key="failedAttempts"
class="mb-2"
></vue-hcaptcha>
<p
v-if="errors.htoken"
class="text-caption text-red mb-4"
>{{ errors.htoken.msg }}</p>
<div>
<v-btn
:disabled="isLoading"
:loading="isLoading"
color="primary"
gitextract_fbxgv27m/
├── .gitignore
├── ReadMe.md
├── ansible/
│ ├── .gitignore
│ ├── .yamllint
│ ├── README.md
│ ├── ansible.cfg
│ ├── inventory/
│ │ ├── .gitignore
│ │ └── example/
│ │ ├── group_vars/
│ │ │ └── all.yml
│ │ └── hosts.ini
│ ├── reset.yml
│ ├── roles/
│ │ ├── download/
│ │ │ └── tasks/
│ │ │ └── main.yml
│ │ ├── k3s/
│ │ │ ├── master/
│ │ │ │ ├── tasks/
│ │ │ │ │ └── main.yml
│ │ │ │ └── templates/
│ │ │ │ └── k3s.service.j2
│ │ │ └── node/
│ │ │ ├── tasks/
│ │ │ │ └── main.yml
│ │ │ └── templates/
│ │ │ └── k3s.service.j2
│ │ ├── prereq/
│ │ │ └── tasks/
│ │ │ └── main.yml
│ │ ├── raspberrypi/
│ │ │ ├── handlers/
│ │ │ │ └── main.yml
│ │ │ └── tasks/
│ │ │ ├── main.yml
│ │ │ └── prereq/
│ │ │ ├── CentOS.yml
│ │ │ ├── Raspbian.yml
│ │ │ ├── Ubuntu.yml
│ │ │ └── default.yml
│ │ └── reset/
│ │ └── tasks/
│ │ ├── main.yml
│ │ └── umount_with_children.yml
│ └── site.yml
├── api/
│ ├── .dockerignore
│ ├── .eslintignore
│ ├── .gitignore
│ ├── .gitlab-ci.yml
│ ├── .sequelizerc
│ ├── Dockerfile
│ ├── LICENSE
│ ├── ReadMe.md
│ ├── docker-compose.yml
│ ├── k8s/
│ │ ├── Deploy.md
│ │ ├── api.deployment.yml
│ │ ├── api.ingress.yml
│ │ ├── api.service.yml
│ │ ├── api.ssl.ingress.yml
│ │ ├── db.yml
│ │ ├── prod.clusterissuer.yml
│ │ ├── secrets.example.yml
│ │ └── sync.job.yml
│ ├── package.json
│ ├── postman.json
│ ├── requests.http
│ ├── src/
│ │ ├── database/
│ │ │ ├── migrations/
│ │ │ │ ├── 20180726090304-create-Users.js
│ │ │ │ ├── 20180726090404-create-Groups.js
│ │ │ │ ├── 20180726090405-create-GroupsUsers.js
│ │ │ │ ├── 20240411041313-create-Buckets.js
│ │ │ │ └── 20240430101608-create-Blacklist.js
│ │ │ └── seeders/
│ │ │ ├── 20180726092449-Users.js
│ │ │ ├── 20180726093449-Group.js
│ │ │ ├── 20180726093449-GroupsUsers.js
│ │ │ ├── 20240411041313-Buckets.js
│ │ │ └── 20240430101608-Blacklist.js
│ │ ├── index.js
│ │ ├── models/
│ │ │ ├── Blacklist.js
│ │ │ ├── Bucket.js
│ │ │ ├── Group.js
│ │ │ ├── GroupsUsers.js
│ │ │ ├── User.js
│ │ │ └── index.js
│ │ ├── providers/
│ │ │ ├── bucket.yml
│ │ │ ├── connections.js
│ │ │ ├── db.js
│ │ │ ├── errorHandler.js
│ │ │ ├── generateJWT.js
│ │ │ ├── hCaptcha.js
│ │ │ └── passport.js
│ │ ├── routes/
│ │ │ ├── Buckets.js
│ │ │ ├── auth.js
│ │ │ ├── groups.js
│ │ │ ├── middleware/
│ │ │ │ ├── canAccessBucket.js
│ │ │ │ ├── checkPassword.js
│ │ │ │ ├── hCaptcha.js
│ │ │ │ ├── index.js
│ │ │ │ ├── isGroupOwner.js
│ │ │ │ ├── isInGroup.js
│ │ │ │ └── isNotSelf.js
│ │ │ └── user.js
│ │ └── scripts/
│ │ ├── blacklist.js
│ │ ├── buckets.js
│ │ ├── deleteUser.js
│ │ ├── env
│ │ ├── forgotPassword.js
│ │ ├── generate.js
│ │ ├── generator/
│ │ │ ├── Migration.js
│ │ │ ├── Model.js
│ │ │ ├── Route.js
│ │ │ └── Seeder.js
│ │ ├── inviteUser.js
│ │ ├── jwt.js
│ │ ├── refresh
│ │ ├── resetPassword.js
│ │ ├── seed
│ │ ├── sync.js
│ │ └── users.js
│ └── tests/
│ ├── Auth.js
│ ├── Group.js
│ ├── HealthCheck.js
│ └── User.js
├── automation-test/
│ ├── .gitlab-ci.yml
│ ├── Dockerfile
│ ├── bucket.yml
│ └── deployment.yml
├── aws-sdk-test/
│ ├── .gitignore
│ ├── index.js
│ └── package.json
├── deployment-test/
│ ├── .gitlab-ci.yml
│ ├── Dockerfile
│ ├── index.html
│ └── k8s.yml
├── frontend/
│ ├── .browserslistrc
│ ├── .editorconfig
│ ├── .eslintrc.js
│ ├── .gitignore
│ ├── .gitlab-ci.yml
│ ├── Dockerfile
│ ├── ReadMe.md
│ ├── default.conf
│ ├── index.html
│ ├── jsconfig.json
│ ├── k8s/
│ │ ├── clusterissuer.yml
│ │ ├── frontend-ssl.ingress.yml
│ │ ├── frontend.deployment.yml
│ │ ├── frontend.ingress.yml
│ │ └── frontend.service.yml
│ ├── package.json
│ ├── src/
│ │ ├── App.vue
│ │ ├── api/
│ │ │ ├── Auth.js
│ │ │ ├── Buckets.js
│ │ │ ├── Service.js
│ │ │ ├── User.js
│ │ │ └── index.js
│ │ ├── components/
│ │ │ ├── CreateBucketForm.vue
│ │ │ └── TermsOfService.vue
│ │ ├── layouts/
│ │ │ └── default/
│ │ │ ├── AppBar.vue
│ │ │ ├── Auth.vue
│ │ │ ├── Default.vue
│ │ │ └── View.vue
│ │ ├── main.js
│ │ ├── plugins/
│ │ │ ├── errorHandler.js
│ │ │ ├── index.js
│ │ │ ├── router.js
│ │ │ ├── store.js
│ │ │ ├── vuetify.js
│ │ │ └── webfontloader.js
│ │ ├── styles/
│ │ │ └── settings.scss
│ │ └── views/
│ │ ├── Buckets.vue
│ │ ├── Login.vue
│ │ └── SignUp.vue
│ └── vite.config.js
├── k3s/
│ ├── alpine.deployment.yml
│ ├── echo.s3.ssl.yml
│ ├── echo.ssl.yml
│ └── echo.yml
├── longhorn/
│ ├── longhorn.ingress.yml
│ ├── longhorn.lb.yml
│ └── longhorn.storageclass.yml
├── node/
│ └── node-config-script.sh
└── sections/
├── automated-bucket-deployment.md
├── console.md
├── deploying-from-gitlab-to-k3s.md
├── gitlab.md
├── internet.md
├── networking.md
├── node.md
├── production-cluster.md
├── ssl.md
└── storage-cluster.md
SYMBOL INDEX (24 symbols across 10 files)
FILE: api/tests/Group.js
constant GROUP_ID (line 9) | const GROUP_ID = 'fdab7a99-2c38-444b-bcb3-f7cef61c275b';
constant OTHER_GROUP_ID (line 10) | const OTHER_GROUP_ID = '190c8a70-34d1-4281-a775-850058453704';
FILE: frontend/src/api/Auth.js
class Auth (line 3) | class Auth extends Service {
method login (line 4) | login(data) {
method signUp (line 8) | signUp(data) {
FILE: frontend/src/api/Buckets.js
class Bucket (line 3) | class Bucket extends Service {
method index (line 4) | index() {
method get (line 8) | get(bucketID) {
method create (line 12) | create(bucket) {
method delete (line 16) | delete(bucketID) {
FILE: frontend/src/api/Service.js
class Service (line 3) | class Service {
method constructor (line 4) | constructor(JWT) {
FILE: frontend/src/api/User.js
class User (line 3) | class User extends Service {
method get (line 4) | get() {
method stats (line 8) | stats() {
FILE: frontend/src/api/index.js
class API (line 5) | class API {
method constructor (line 6) | constructor(JWT) {
method setJWT (line 10) | setJWT(JWT) {
method getJWT (line 17) | getJWT() {
FILE: frontend/src/plugins/errorHandler.js
function errorHandler (line 5) | function errorHandler(error, cb) {
FILE: frontend/src/plugins/index.js
function registerPlugins (line 11) | function registerPlugins(app) {
FILE: frontend/src/plugins/store.js
method setUser (line 8) | setUser(state, payload) {
method user (line 13) | user(state) {
FILE: frontend/src/plugins/webfontloader.js
function loadFonts (line 7) | async function loadFonts () {
Condensed preview — 171 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (251K chars).
[
{
"path": ".gitignore",
"chars": 55,
"preview": ".DS_Store\n\nnotes/*\n*.crt\n*.k8s\n\napi_\nfrontend_\nwebsite_"
},
{
"path": "ReadMe.md",
"chars": 6710,
"preview": "# S3 From Scratch\n\n<p align=\"center\">\n <img width=\"300\" src=\"https://raw.githubusercontent.com/anthonybudd/s3-from-scra"
},
{
"path": "ansible/.gitignore",
"chars": 8,
"preview": "Notes.md"
},
{
"path": "ansible/.yamllint",
"chars": 137,
"preview": "---\nextends: default\n\nrules:\n line-length:\n max: 120\n level: warning\n truthy:\n allowed-values: ['true', 'fals"
},
{
"path": "ansible/README.md",
"chars": 98,
"preview": "# Ansible\n\nSource: [https://github.com/k3s-io/k3s-ansible](https://github.com/k3s-io/k3s-ansible)\n"
},
{
"path": "ansible/ansible.cfg",
"chars": 258,
"preview": "[defaults]\nnocows = True\nroles_path = ./roles\ninventory = ./hosts.ini\n\nremote_tmp = $HOME/.ansible/tmp\nlocal_tmp = $HO"
},
{
"path": "ansible/inventory/.gitignore",
"chars": 41,
"preview": "*-cluster/\n!exmaple/\n!.gitignore\n!sample/"
},
{
"path": "ansible/inventory/example/group_vars/all.yml",
"chars": 221,
"preview": "---\nk3s_version: v1.26.9+k3s1\nansible_user: node\nsystemd_dir: /etc/systemd/system\nmaster_ip: \"{{ hostvars[groups['master"
},
{
"path": "ansible/inventory/example/hosts.ini",
"chars": 89,
"preview": "[master]\n10.0.0.5\n\n[node]\n10.0.0.5\n10.0.0.6\n10.0.0.7\n\n[k3s_cluster:children]\nmaster\nnode\n"
},
{
"path": "ansible/reset.yml",
"chars": 87,
"preview": "---\n\n- hosts: k3s_cluster\n gather_facts: yes\n become: yes\n roles:\n - role: reset\n"
},
{
"path": "ansible/roles/download/tasks/main.yml",
"chars": 1252,
"preview": "---\n\n- name: Download k3s binary x64\n get_url:\n url: https://github.com/k3s-io/k3s/releases/download/{{ k3s_version "
},
{
"path": "ansible/roles/k3s/master/tasks/main.yml",
"chars": 1804,
"preview": "---\n\n- name: Copy K3s service file\n register: k3s_service\n template:\n src: \"k3s.service.j2\"\n dest: \"{{ systemd_d"
},
{
"path": "ansible/roles/k3s/master/templates/k3s.service.j2",
"chars": 626,
"preview": "[Unit]\nDescription=Lightweight Kubernetes\nDocumentation=https://k3s.io\nAfter=network-online.target\n\n[Service]\nType=notif"
},
{
"path": "ansible/roles/k3s/node/tasks/main.yml",
"chars": 296,
"preview": "---\n\n- name: Copy K3s service file\n template:\n src: \"k3s.service.j2\"\n dest: \"{{ systemd_dir }}/k3s-node.service\"\n"
},
{
"path": "ansible/roles/k3s/node/templates/k3s.service.j2",
"chars": 715,
"preview": "[Unit]\nDescription=Lightweight Kubernetes\nDocumentation=https://k3s.io\nAfter=network-online.target\n\n[Service]\nType=notif"
},
{
"path": "ansible/roles/prereq/tasks/main.yml",
"chars": 1433,
"preview": "---\n- name: Set SELinux to disabled state\n selinux:\n state: disabled\n when: ansible_distribution in ['CentOS', 'Red"
},
{
"path": "ansible/roles/raspberrypi/handlers/main.yml",
"chars": 29,
"preview": "---\n- name: reboot\n reboot:\n"
},
{
"path": "ansible/roles/raspberrypi/tasks/main.yml",
"chars": 1788,
"preview": "---\n- name: Test for raspberry pi /proc/cpuinfo\n command: grep -E \"Raspberry Pi|BCM2708|BCM2709|BCM2835|BCM2836\" /proc/"
},
{
"path": "ansible/roles/raspberrypi/tasks/prereq/CentOS.yml",
"chars": 319,
"preview": "---\n- name: Enable cgroup via boot commandline if not already enabled for Centos\n lineinfile:\n path: /boot/cmdline.t"
},
{
"path": "ansible/roles/raspberrypi/tasks/prereq/Raspbian.yml",
"chars": 693,
"preview": "---\n- name: Activating cgroup support\n lineinfile:\n path: /boot/cmdline.txt\n regexp: '^((?!.*\\bcgroup_enable=cpus"
},
{
"path": "ansible/roles/raspberrypi/tasks/prereq/Ubuntu.yml",
"chars": 346,
"preview": "---\n- name: Enable cgroup via boot commandline if not already enabled for Ubuntu on a Raspberry Pi\n lineinfile:\n pat"
},
{
"path": "ansible/roles/raspberrypi/tasks/prereq/default.yml",
"chars": 4,
"preview": "---\n"
},
{
"path": "ansible/roles/reset/tasks/main.yml",
"chars": 958,
"preview": "---\n- name: Disable services\n systemd:\n name: \"{{ item }}\"\n state: stopped\n enabled: no\n failed_when: false\n "
},
{
"path": "ansible/roles/reset/tasks/umount_with_children.yml",
"chars": 474,
"preview": "---\n- name: Get the list of mounted filesystems\n shell: set -o pipefail && cat /proc/mounts | awk '{ print $2}' | grep "
},
{
"path": "ansible/site.yml",
"chars": 255,
"preview": "---\n\n- hosts: k3s_cluster\n gather_facts: yes\n become: yes\n roles:\n - role: prereq\n - role: download\n - role:"
},
{
"path": "api/.dockerignore",
"chars": 30,
"preview": "node_modules\npackage-lock.json"
},
{
"path": "api/.eslintignore",
"chars": 19,
"preview": "src/database/\ntests"
},
{
"path": "api/.gitignore",
"chars": 107,
"preview": "node_modules/\n.vol/\n.env\ndev.js\nprivate.pem\npublic.pem\n.DS_Store\n*/.DS_Store\nk8s/secrets.yml\nkubeconfig.yml"
},
{
"path": "api/.gitlab-ci.yml",
"chars": 314,
"preview": "stages:\n - build\n\nbuild-job:\n image: docker:dind\n stage: build\n services:\n - docker:dind\n variables:\n IMAGE_T"
},
{
"path": "api/.sequelizerc",
"chars": 323,
"preview": "const path = require('path');\n\nmodule.exports = {\n 'config': path.resolve('src/providers', 'connections.js'"
},
{
"path": "api/Dockerfile",
"chars": 406,
"preview": "FROM node:20\n\nRUN apt-get update && apt-get install -y curl nano\nRUN curl -LO \"https://dl.k8s.io/release/$(curl -L -s ht"
},
{
"path": "api/LICENSE",
"chars": 1066,
"preview": "The MIT License\n\nCopyright Anthony C. Budd\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
},
{
"path": "api/ReadMe.md",
"chars": 2884,
"preview": "# S3 API\n\nThis API simulates the back-end of the AWS Console. A user can sign-up, login, create a bucket then delete the"
},
{
"path": "api/docker-compose.yml",
"chars": 939,
"preview": "version: \"3\"\n\nservices:\n s3-api:\n build: .\n entrypoint: \"nodemon /app/src/index.js --watch /app --legacy-watch\"\n "
},
{
"path": "api/k8s/Deploy.md",
"chars": 3388,
"preview": "# Deploy\n\n\n\n\nkubectl --kubeconfig=.kube/config create namespace s3-api\nnamespace/s3-api created\n[Console]:~> kubectl --k"
},
{
"path": "api/k8s/api.deployment.yml",
"chars": 2194,
"preview": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: s3-api\n namespace: s3-api\nspec:\n replicas: 1\n selector:\n ma"
},
{
"path": "api/k8s/api.ingress.yml",
"chars": 356,
"preview": "apiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n namespace: s3-api\n name: s3-ingress\n annotations:\n kuber"
},
{
"path": "api/k8s/api.service.yml",
"chars": 148,
"preview": "apiVersion: v1\nkind: Service\nmetadata:\n name: s3-api\n namespace: s3-api\nspec:\n ports:\n - port: 80\n targetPort: 80"
},
{
"path": "api/k8s/api.ssl.ingress.yml",
"chars": 512,
"preview": "apiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n namespace: s3-api\n name: s3-ingress\n annotations:\n cert-"
},
{
"path": "api/k8s/db.yml",
"chars": 1007,
"preview": "apiVersion: v1\nkind: Service\nmetadata:\n name: s3-db\n namespace: s3-api\nspec:\n selector: \n app: s3-db \n ports: "
},
{
"path": "api/k8s/prod.clusterissuer.yml",
"chars": 337,
"preview": "apiVersion: cert-manager.io/v1\nkind: ClusterIssuer\nmetadata:\n name: letsencrypt-prod\n namespace: cert-manager\nspec:\n "
},
{
"path": "api/k8s/secrets.example.yml",
"chars": 123,
"preview": "apiVersion: v1 \nkind: Secret\nmetadata:\n name: s3-api-secrets\n namespace: default\ntype: Opaque\ndata:\n DB_PASSWORD: "
},
{
"path": "api/k8s/sync.job.yml",
"chars": 1732,
"preview": "apiVersion: batch/v1\nkind: CronJob\nmetadata:\n name: sync\n namespace: s3-api\nspec:\n schedule: \"* * * * *\"\n jobTemplat"
},
{
"path": "api/package.json",
"chars": 2327,
"preview": "{\n \"name\": \"s3-api-boilerplate\",\n \"version\": \"1.0.0\",\n \"main\": \"./src/index.js\",\n \"author\": \"Anthony Budd\",\n"
},
{
"path": "api/postman.json",
"chars": 5046,
"preview": "{\n\t\"info\": {\n\t\t\"_postman_id\": \"994858ef-e55e-425c-9aac-1cf12496a933\",\n\t\t\"name\": \"s3-api-Boilerplate\",\n\t\t\"schema\": \"https"
},
{
"path": "api/requests.http",
"chars": 1431,
"preview": "# Install VS Code extension rest-client \n# URL: https://marketplace.visualstudio.com/items?itemName=humao.rest-client\n\n@"
},
{
"path": "api/src/database/migrations/20180726090304-create-Users.js",
"chars": 1215,
"preview": "module.exports = {\n up: (queryInterface, Sequelize) => queryInterface.createTable('Users', {\n id: {\n "
},
{
"path": "api/src/database/migrations/20180726090404-create-Groups.js",
"chars": 724,
"preview": "module.exports = {\n up: (queryInterface, Sequelize) => queryInterface.createTable('Groups', {\n id: {\n "
},
{
"path": "api/src/database/migrations/20180726090405-create-GroupsUsers.js",
"chars": 835,
"preview": "module.exports = {\n up: (queryInterface, Sequelize) => queryInterface.createTable('GroupsUsers', {\n id: { // "
},
{
"path": "api/src/database/migrations/20240411041313-create-Buckets.js",
"chars": 1460,
"preview": "module.exports = {\n up: (queryInterface, Sequelize) => queryInterface.createTable('Buckets', {\n id: {\n "
},
{
"path": "api/src/database/migrations/20240430101608-create-Blacklist.js",
"chars": 665,
"preview": "module.exports = {\n up: (queryInterface, Sequelize) => queryInterface.createTable('Blacklist', {\n id: {\n "
},
{
"path": "api/src/database/seeders/20180726092449-Users.js",
"chars": 1066,
"preview": "const bcrypt = require('bcrypt-nodejs');\nconst moment = require('moment');\nconst faker = require('faker');\n\nconst insert"
},
{
"path": "api/src/database/seeders/20180726093449-Group.js",
"chars": 710,
"preview": "const moment = require('moment');\n\nconst insert = [{\n id: 'fdab7a99-2c38-444b-bcb3-f7cef61c275b',\n ownerID: 'c4644"
},
{
"path": "api/src/database/seeders/20180726093449-GroupsUsers.js",
"chars": 968,
"preview": "const moment = require('moment');\n\nconst insert = [\n {\n id: '1872dcde-b79d-4f28-a36b-a22af519ac23',\n us"
},
{
"path": "api/src/database/seeders/20240411041313-Buckets.js",
"chars": 607,
"preview": "const moment = require('moment');\n\nconst insert = [{\n id: 'fae8a1fb-bc90-4565-b567-1fe6846544de',\n createdAt: mome"
},
{
"path": "api/src/database/seeders/20240430101608-Blacklist.js",
"chars": 5907,
"preview": "const { v4: uuidv4 } = require('uuid');\n\nconst blacklist = [\n 'about',\n 'aboutu',\n 'abuse',\n 'acme',\n 'ad"
},
{
"path": "api/src/index.js",
"chars": 1875,
"preview": "require('dotenv').config();\nrequire('./providers/passport');\nconst fileUpload = require('express-fileupload');\nconst exp"
},
{
"path": "api/src/models/Blacklist.js",
"chars": 686,
"preview": "const Sequelize = require('sequelize');\nconst db = require('./../providers/db');\n\nconst Blacklist = db.define('Blacklist"
},
{
"path": "api/src/models/Bucket.js",
"chars": 5421,
"preview": "const { exec } = require('child_process');\nconst db = require('./../providers/db');\nconst Sequelize = require('sequelize"
},
{
"path": "api/src/models/Group.js",
"chars": 472,
"preview": "const Sequelize = require('sequelize');\nconst db = require('./../providers/db');\n\nmodule.exports = db.define('Group', {\n"
},
{
"path": "api/src/models/GroupsUsers.js",
"chars": 469,
"preview": "const Sequelize = require('sequelize');\nconst db = require('./../providers/db');\n\nmodule.exports = db.define('GroupsUser"
},
{
"path": "api/src/models/User.js",
"chars": 1038,
"preview": "const Sequelize = require('sequelize');\nconst db = require('./../providers/db');\n\nmodule.exports = db.define('User', {\n "
},
{
"path": "api/src/models/index.js",
"chars": 499,
"preview": "const User = require('./User');\nconst Group = require('./Group');\nconst GroupsUsers = require('./GroupsUsers');\nconst Bu"
},
{
"path": "api/src/providers/bucket.yml",
"chars": 2890,
"preview": "apiVersion: v1\nkind: Namespace\nmetadata:\n name: NAMESPACE_HERE\n labels:\n name: NAMESPACE_HERE\n---\napiVersion: v1\nki"
},
{
"path": "api/src/providers/connections.js",
"chars": 822,
"preview": "require('dotenv').config();\n\nmodule.exports = {\n development: {\n username: process.env.DB_USERNAME,\n pa"
},
{
"path": "api/src/providers/db.js",
"chars": 968,
"preview": "const Sequelize = require('sequelize');\nconst connections = require('./connections');\nconst errorHandler = require('./er"
},
{
"path": "api/src/providers/errorHandler.js",
"chars": 621,
"preview": "const crypto = require('crypto');\n\nmodule.exports = (err, res) => {\n if (err.isAxiosError) {\n console.log(`Axi"
},
{
"path": "api/src/providers/generateJWT.js",
"chars": 777,
"preview": "const jwt = require('jsonwebtoken');\nconst moment = require('moment');\nconst fs = require('fs');\n\nmodule.exports = (user"
},
{
"path": "api/src/providers/hCaptcha.js",
"chars": 483,
"preview": "const axios = require('axios');\nconst qs = require('qs');\n\nconst hCaptcha = axios.create({\n baseURL: 'https://hcaptch"
},
{
"path": "api/src/providers/passport.js",
"chars": 1291,
"preview": "const LocalStrategy = require('passport-local').Strategy;\nconst { User, Group } = require('./../models');\nconst passport"
},
{
"path": "api/src/routes/Buckets.js",
"chars": 3287,
"preview": "const { body, validationResult, matchedData } = require('express-validator');\nconst errorHandler = require('./../provide"
},
{
"path": "api/src/routes/auth.js",
"chars": 10372,
"preview": "const { body, validationResult, matchedData } = require('express-validator');\nconst { User, Group, GroupsUsers } = requi"
},
{
"path": "api/src/routes/groups.js",
"chars": 4056,
"preview": "const { body, validationResult, matchedData } = require('express-validator');\nconst { User, Group, GroupsUsers } = requi"
},
{
"path": "api/src/routes/middleware/canAccessBucket.js",
"chars": 556,
"preview": "const { Bucket } = require('./../../models');\n\nmodule.exports = async (req, res, next) => {\n const bucketID = (req.pa"
},
{
"path": "api/src/routes/middleware/checkPassword.js",
"chars": 1159,
"preview": "const errorHandler = require('./../../providers/errorHandler');\nconst { User } = require('./../../models');\nconst bcrypt"
},
{
"path": "api/src/routes/middleware/hCaptcha.js",
"chars": 842,
"preview": "const hCaptcha = require('./../../providers/hCaptcha');\n\nmodule.exports = async (req, res, next) => {\n\n if (!process."
},
{
"path": "api/src/routes/middleware/index.js",
"chars": 401,
"preview": "const checkPassword = require('./checkPassword');\nconst isInGroup = require('./isInGroup');\nconst isNotSelf = require('."
},
{
"path": "api/src/routes/middleware/isGroupOwner.js",
"chars": 431,
"preview": "const { Group } = require('./../../models');\n\nmodule.exports = async (req, res, next) => {\n const groupID = (req.para"
},
{
"path": "api/src/routes/middleware/isInGroup.js",
"chars": 677,
"preview": "const { User, Group } = require('./../../models');\n\nmodule.exports = async (req, res, next) => {\n const groupID = (re"
},
{
"path": "api/src/routes/middleware/isNotSelf.js",
"chars": 315,
"preview": "module.exports = (req, res, next) => {\n if (!req.user || !req.user.id) return res.status(401).json({\n msg: 'Ac"
},
{
"path": "api/src/routes/user.js",
"chars": 2321,
"preview": "const { body, validationResult, matchedData } = require('express-validator');\nconst errorHandler = require('./../provide"
},
{
"path": "api/src/scripts/blacklist.js",
"chars": 680,
"preview": "\n/**\n * node ./src/scripts/blacklist.js --value=\"bad_word\"\n * docker exec -ti s3-api node ./src/scripts/blacklist.js --v"
},
{
"path": "api/src/scripts/buckets.js",
"chars": 613,
"preview": "\n/**\n * node ./src/scripts/buckets.js\n * docker exec -ti s3-api node ./src/scripts/buckets.js\n *\n */\nrequire('dotenv').c"
},
{
"path": "api/src/scripts/deleteUser.js",
"chars": 761,
"preview": "\n/**\n * node ./src/scripts/deleteUser.js --userID=\"fdab7a99-2c38-444b-bcb3-f7cef61c275b\"\n * docker exec -ti s3-api node "
},
{
"path": "api/src/scripts/env",
"chars": 131,
"preview": "#!/bin/bash\n\nif [[ $NODE_ENV == \"production\" ]]\n then\n echo \"NODE_ENV=production ⚠️\"\n else\n echo \"NODE_ENV=$NODE"
},
{
"path": "api/src/scripts/forgotPassword.js",
"chars": 1047,
"preview": "\n/**\n * node ./src/scripts/forgotPassword.js --userID=\"c4644733-deea-47d8-b35a-86f30ff9618e\"\n * docker exec -ti s3-api n"
},
{
"path": "api/src/scripts/generate.js",
"chars": 2138,
"preview": "\n/**\n * node ./src/scripts/generate.js --modelName=\"bucket\"\n * docker exec -ti s3-api node ./src/scripts/generate.js --m"
},
{
"path": "api/src/scripts/generator/Migration.js",
"chars": 679,
"preview": "module.exports = {\n up: (queryInterface, Sequelize) => queryInterface.createTable('{{ ModelNames }}', {\n id: {"
},
{
"path": "api/src/scripts/generator/Model.js",
"chars": 690,
"preview": "const Sequelize = require('sequelize');\nconst db = require('./../providers/db');\n\nmodule.exports = db.define('{{ ModelNa"
},
{
"path": "api/src/scripts/generator/Route.js",
"chars": 1832,
"preview": "const { body, validationResult, matchedData } = require('express-validator');\nconst errorHandler = require('./../provide"
},
{
"path": "api/src/scripts/generator/Seeder.js",
"chars": 382,
"preview": "const moment = require('moment');\n\nconst insert = [{\n id: '{{ UUID }}',\n createdAt: moment().format('YYYY-MM-DD HH"
},
{
"path": "api/src/scripts/inviteUser.js",
"chars": 2223,
"preview": "\n/**\n * node ./src/scripts/inviteUser.js --email=\"newuser@example.com\" --groupID=\"fdab7a99-2c38-444b-bcb3-f7cef61c275b\"\n"
},
{
"path": "api/src/scripts/jwt.js",
"chars": 806,
"preview": "\n/**\n * node ./src/scripts/jwt.js --userID=\"c4644733-deea-47d8-b35a-86f30ff9618e\"\n * docker exec -ti s3-api node ./src/s"
},
{
"path": "api/src/scripts/refresh",
"chars": 205,
"preview": "#!/bin/bash\n\nif [[ $NODE_ENV == \"production\" ]]\n then\n echo \"ERROR: Can not refresh while in production\"\n else\n "
},
{
"path": "api/src/scripts/resetPassword.js",
"chars": 1083,
"preview": "\n/**\n * node ./src/scripts/resetPassword.js --userID=\"c4644733-deea-47d8-b35a-86f30ff9618e\" --password=\"password\"\n * doc"
},
{
"path": "api/src/scripts/seed",
"chars": 143,
"preview": "#!/bin/bash\n\nif [[ $NODE_ENV == \"production\" ]]\n then\n echo \"ERROR: Can not seed while in production\"\n else\n seq"
},
{
"path": "api/src/scripts/sync.js",
"chars": 491,
"preview": "/**\n * node ./src/scripts/sync.js\n * docker exec -ti s3-api node ./src/scripts/sync.js\n *\n */\n\nrequire('dotenv').config("
},
{
"path": "api/src/scripts/users.js",
"chars": 556,
"preview": "\n/**\n * node ./src/scripts/users.js\n * docker exec -ti s3-api node ./src/scripts/users.js\n *\n */\nrequire('dotenv').confi"
},
{
"path": "api/tests/Auth.js",
"chars": 6030,
"preview": "const chai = require('chai');\nconst chaiHttp = require('chai-http');\nconst server = require('../src');\nconst should = ch"
},
{
"path": "api/tests/Group.js",
"chars": 5025,
"preview": "require('dotenv').config();\nconst chai = require('chai');\nconst chaiHttp = require('chai-http');\nconst server = require("
},
{
"path": "api/tests/HealthCheck.js",
"chars": 670,
"preview": "const chai = require('chai');\nconst chaiHttp = require('chai-http');\nconst server = require('../src');\nconst should = ch"
},
{
"path": "api/tests/User.js",
"chars": 2683,
"preview": "require('dotenv').config();\nconst chai = require('chai');\nconst chaiHttp = require('chai-http');\nconst server = require("
},
{
"path": "automation-test/.gitlab-ci.yml",
"chars": 314,
"preview": "stages:\n - build\n\nbuild-job:\n image: docker:dind\n stage: build\n services:\n - docker:dind\n variables:\n IMAGE_T"
},
{
"path": "automation-test/Dockerfile",
"chars": 283,
"preview": "FROM ubuntu:noble\n\nRUN apt-get update && apt-get install -y curl\n\nRUN curl -LO \"https://dl.k8s.io/release/$(curl -L -s h"
},
{
"path": "automation-test/bucket.yml",
"chars": 1405,
"preview": "apiVersion: v1\nkind: Namespace\nmetadata:\n name: XXX\n labels:\n name: XXX\n---\napiVersion: v1\nkind: Pod\nmetadata:\n la"
},
{
"path": "automation-test/deployment.yml",
"chars": 908,
"preview": "kind: Deployment \napiVersion: apps/v1 \nmetadata: \n name: automation-test-deployment \n labels: "
},
{
"path": "aws-sdk-test/.gitignore",
"chars": 13,
"preview": "node_modules/"
},
{
"path": "aws-sdk-test/index.js",
"chars": 1086,
"preview": "const { S3Client, ListObjectsV2Command, PutObjectCommand } = require(\"@aws-sdk/client-s3\");\n\nconst Bucket = 'kjdoewl';\nc"
},
{
"path": "aws-sdk-test/package.json",
"chars": 298,
"preview": "{\n \"name\": \"aws-sdk-test\",\n \"version\": \"1.0.0\",\n \"main\": \"index.js\",\n \"scripts\": {\n \"test\": \"echo \\\"Error: no tes"
},
{
"path": "deployment-test/.gitlab-ci.yml",
"chars": 314,
"preview": "stages:\n - build\n\nbuild-job:\n image: docker:dind\n stage: build\n services:\n - docker:dind\n variables:\n IMAGE_T"
},
{
"path": "deployment-test/Dockerfile",
"chars": 46,
"preview": "FROM nginx:alpine\nCOPY . /usr/share/nginx/html"
},
{
"path": "deployment-test/index.html",
"chars": 52,
"preview": "<h1>Compiled on GitLab</h1>\n<h1>Deployed on K3s</h1>"
},
{
"path": "deployment-test/k8s.yml",
"chars": 1064,
"preview": "kind: Deployment \napiVersion: apps/v1 \nmetadata: \n name: website-deployment \n labels: \n a"
},
{
"path": "frontend/.browserslistrc",
"chars": 40,
"preview": "> 1%\nlast 2 versions\nnot dead\nnot ie 11\n"
},
{
"path": "frontend/.editorconfig",
"chars": 121,
"preview": "[*.{js,jsx,ts,tsx,vue}]\nindent_style = space\nindent_size = 2\ntrim_trailing_whitespace = true\ninsert_final_newline = true"
},
{
"path": "frontend/.eslintrc.js",
"chars": 142,
"preview": "module.exports = {\n root: true,\n env: {\n node: true,\n },\n extends: [\n 'plugin:vue/vue3-essential',\n 'eslint"
},
{
"path": "frontend/.gitignore",
"chars": 235,
"preview": ".DS_Store\nnode_modules\n/dist\n\n# local env files\n.env\n.env.local\n.env.*.local\n\n# Log files\nnpm-debug.log*\nyarn-debug.log*"
},
{
"path": "frontend/.gitlab-ci.yml",
"chars": 493,
"preview": "stages:\n - build\n\nbuild-job:\n image: docker:dind\n stage: build\n services:\n - docker:dind\n variables:\n IMAGE_T"
},
{
"path": "frontend/Dockerfile",
"chars": 93,
"preview": "FROM nginx\nCOPY default.conf /etc/nginx/conf.d/default.conf\nCOPY ./dist /usr/share/nginx/html"
},
{
"path": "frontend/ReadMe.md",
"chars": 418,
"preview": "# Frontend\n\nThis represents the AWS console found at [aws.amazon.com/console](https://aws.amazon.com/console/). This is "
},
{
"path": "frontend/default.conf",
"chars": 1139,
"preview": "server {\n listen 80;\n listen [::]:80;\n server_name localhost;\n\n #access_log /var/log/nginx/host.acc"
},
{
"path": "frontend/index.html",
"chars": 314,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n <meta charset=\"UTF-8\" />\n <link rel=\"icon\" href=\"/favicon.ico\" />\n <meta na"
},
{
"path": "frontend/jsconfig.json",
"chars": 279,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es5\",\n \"module\": \"esnext\",\n \"baseUrl\": \"./\",\n \"moduleResolution\": \"node"
},
{
"path": "frontend/k8s/clusterissuer.yml",
"chars": 284,
"preview": "apiVersion: cert-manager.io/v1\nkind: ClusterIssuer\nmetadata:\n name: letsencrypt-prod\nspec:\n acme:\n server: https://"
},
{
"path": "frontend/k8s/frontend-ssl.ingress.yml",
"chars": 516,
"preview": "apiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n name: frontend-ingress\n namespace: s3-api\n annotations:\n "
},
{
"path": "frontend/k8s/frontend.deployment.yml",
"chars": 533,
"preview": "kind: Deployment \napiVersion: apps/v1 \nmetadata: \n name: frontend-deployment \n namespace: s3-api\n "
},
{
"path": "frontend/k8s/frontend.ingress.yml",
"chars": 387,
"preview": "apiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n name: frontend-ingress\n namespace: s3-api\n annotations:\n "
},
{
"path": "frontend/k8s/frontend.service.yml",
"chars": 193,
"preview": "apiVersion: v1\nkind: Service \nmetadata:\n name: frontend-service \n namespace: s3-api\nspec:\n selector: \n app: f"
},
{
"path": "frontend/package.json",
"chars": 759,
"preview": "{\n \"name\": \"frontend\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite bu"
},
{
"path": "frontend/src/App.vue",
"chars": 987,
"preview": "<template>\n <router-view v-if=\"isLoaded\" />\n <notifications />\n</template>\n\n<script setup>\nimport { ref, onMounted"
},
{
"path": "frontend/src/api/Auth.js",
"chars": 245,
"preview": "import Service from './Service';\n\nclass Auth extends Service {\n login(data) {\n return this.axios.post('/auth/l"
},
{
"path": "frontend/src/api/Buckets.js",
"chars": 404,
"preview": "import Service from './Service';\n\nclass Bucket extends Service {\n index() {\n return this.axios.get(`/buckets`)"
},
{
"path": "frontend/src/api/Service.js",
"chars": 423,
"preview": "import axios from 'axios';\n\nclass Service {\n constructor(JWT) {\n this.JWT = JWT;\n\n this.url = import.me"
},
{
"path": "frontend/src/api/User.js",
"chars": 207,
"preview": "import Service from './Service';\n\nclass User extends Service {\n get() {\n return this.axios.get('/user');\n }"
},
{
"path": "frontend/src/api/index.js",
"chars": 441,
"preview": "import Auth from './Auth';\nimport User from './User';\nimport Buckets from './Buckets';\n\nclass API {\n constructor(JWT)"
},
{
"path": "frontend/src/components/CreateBucketForm.vue",
"chars": 2435,
"preview": "<template>\n <v-card title=\"Create Bucket\">\n <v-card-text>\n <v-text-field\n v-model=\"n"
},
{
"path": "frontend/src/components/TermsOfService.vue",
"chars": 6003,
"preview": "<template>\n <v-card>\n <v-card-text>\n <h1>Terms of Service for s3.anthonybudd.io</h1>\n\n <"
},
{
"path": "frontend/src/layouts/default/AppBar.vue",
"chars": 3655,
"preview": "<template>\n <div>\n <v-app-bar\n flat\n :height=\"(xs) ? 80 : 150\"\n >\n <v-"
},
{
"path": "frontend/src/layouts/default/Auth.vue",
"chars": 202,
"preview": "<template>\n <v-app class=\"bg-grey-lighten-3\">\n <default-view />\n </v-app>\n</template>\n\n<script setup>\nimpor"
},
{
"path": "frontend/src/layouts/default/Default.vue",
"chars": 210,
"preview": "<template>\n <v-app>\n <default-bar />\n <notifications />\n <default-view />\n </v-app>\n</template>\n\n<script setu"
},
{
"path": "frontend/src/layouts/default/View.vue",
"chars": 95,
"preview": "<template>\n <v-main>\n <router-view />\n </v-main>\n</template>\n\n<script setup>\n//\n</script>\n"
},
{
"path": "frontend/src/main.js",
"chars": 305,
"preview": "/**\n * main.js\n *\n * Bootstraps Vuetify and other plugins then mounts the App`\n */\n\n// Components\nimport App from './App"
},
{
"path": "frontend/src/plugins/errorHandler.js",
"chars": 649,
"preview": "import { useNotification } from \"@kyvg/vue3-notification\";\n\nconst { notify } = useNotification();\n\nexport default functi"
},
{
"path": "frontend/src/plugins/index.js",
"chars": 549,
"preview": "import { loadFonts } from './webfontloader';\n\nimport Notifications from '@kyvg/vue3-notification';\nimport vuetify from '"
},
{
"path": "frontend/src/plugins/router.js",
"chars": 1524,
"preview": "// Composables\nimport { createRouter, createWebHistory } from 'vue-router';\n\nconst routes = [\n {\n path: '/',\n "
},
{
"path": "frontend/src/plugins/store.js",
"chars": 296,
"preview": "import { createStore } from \"vuex\";\n\nexport default createStore({\n state: {\n user: null,\n },\n mutations:"
},
{
"path": "frontend/src/plugins/vuetify.js",
"chars": 478,
"preview": "/**\n * plugins/vuetify.js\n *\n * Framework documentation: https://vuetifyjs.com`\n */\n\n// Styles\nimport '@mdi/font/css/mat"
},
{
"path": "frontend/src/plugins/webfontloader.js",
"chars": 360,
"preview": "/**\n * plugins/webfontloader.js\n *\n * webfontloader documentation: https://github.com/typekit/webfontloader\n */\n\nexport "
},
{
"path": "frontend/src/styles/settings.scss",
"chars": 1934,
"preview": "/**\n * src/styles/settings.scss\n *\n * Configures SASS variables and Vuetify overwrites\n */\n\n// https://vuetifyjs.com/fea"
},
{
"path": "frontend/src/views/Buckets.vue",
"chars": 9367,
"preview": "<template>\n <div>\n <v-container class=\"fill-height\">\n <v-sheet\n width=\"100%\"\n "
},
{
"path": "frontend/src/views/Login.vue",
"chars": 4067,
"preview": "<template>\n <v-container class=\"fill-height d-flex align-center justify-center\">\n <v-sheet\n width=\""
},
{
"path": "frontend/src/views/SignUp.vue",
"chars": 5658,
"preview": "<template>\n <v-container class=\"fill-height d-flex align-center justify-center\">\n <v-sheet\n width=\""
},
{
"path": "frontend/vite.config.js",
"chars": 837,
"preview": "// Plugins\nimport vue from '@vitejs/plugin-vue'\nimport vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'\n\n// Ut"
},
{
"path": "k3s/alpine.deployment.yml",
"chars": 446,
"preview": "kind: Deployment \napiVersion: apps/v1 \nmetadata: \n name: alpine-deployment \n labels: \n ap"
},
{
"path": "k3s/echo.s3.ssl.yml",
"chars": 1157,
"preview": "kind: Deployment \napiVersion: apps/v1 \nmetadata: \n name: echo-deployment \n labels: \n app:"
},
{
"path": "k3s/echo.ssl.yml",
"chars": 1148,
"preview": "kind: Deployment \napiVersion: apps/v1 \nmetadata: \n name: echo-deployment \n labels: \n app:"
},
{
"path": "k3s/echo.yml",
"chars": 1026,
"preview": "kind: Deployment \napiVersion: apps/v1 \nmetadata: \n name: echo-deployment \n labels: \n app:"
},
{
"path": "longhorn/longhorn.ingress.yml",
"chars": 387,
"preview": "apiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n namespace: longhorn-system\n name: longhorn-ingress\n annotat"
},
{
"path": "longhorn/longhorn.lb.yml",
"chars": 263,
"preview": "apiVersion: v1\nkind: Service\nmetadata:\n name: longhorn-lb\n namespace: longhorn-system\nspec:\n selector:\n app: longh"
},
{
"path": "longhorn/longhorn.storageclass.yml",
"chars": 316,
"preview": "---\nkind: StorageClass\napiVersion: storage.k8s.io/v1\nmetadata:\n name: longhorn\nprovisioner: driver.longhorn.io\nallowVol"
},
{
"path": "node/node-config-script.sh",
"chars": 1091,
"preview": "#!/bin/bash\n\n# Update\nsudo apt update -y\nsudo apt full-upgrade -y\n\n\n# Fail2Ban - https://pimylifeup.com/raspberry-pi-fai"
},
{
"path": "sections/automated-bucket-deployment.md",
"chars": 3906,
"preview": "# Automated Bucket Deployment\n\nWhen a user creates a bucket we will want the bucket to be created automatically with out"
},
{
"path": "sections/console.md",
"chars": 2020,
"preview": "# Console\n\n<img height=\"400\" src=\"https://raw.githubusercontent.com/anthonybudd/s3-from-scratch/master/_img/console-clos"
},
{
"path": "sections/deploying-from-gitlab-to-k3s.md",
"chars": 5969,
"preview": "# Deploying From GitLab Registry To Local K3s\n\nWe need to be able to deploy the SaaS front-end and REST API for S3 from "
},
{
"path": "sections/gitlab.md",
"chars": 9091,
"preview": "# Installing GitLab on a Raspberry Pi 4\n\nFirst you will need to build a single node. Flash the SD card with a copy of 32"
},
{
"path": "sections/internet.md",
"chars": 3977,
"preview": "# Connecting to the Internet\n\nIn a true datacenter we would request a static IP address from our ISP and point our DNS s"
},
{
"path": "sections/networking.md",
"chars": 32,
"preview": "# Networking\n\n_AB: In Progress_\n"
},
{
"path": "sections/node.md",
"chars": 1376,
"preview": "# Node\n\n<img height=\"300\" src=\"https://raw.githubusercontent.com/anthonybudd/s3-from-scratch/master/_img/node.png\">\n\n###"
},
{
"path": "sections/production-cluster.md",
"chars": 4001,
"preview": "# K3S: Production cluster\n\nThis guide will cover how to set-up the production kubernetes cluster for hosting our public "
},
{
"path": "sections/ssl.md",
"chars": 2397,
"preview": "# SSL\n\nkubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.10.1/deploy/static/prov"
},
{
"path": "sections/storage-cluster.md",
"chars": 9037,
"preview": "# K3S: Storage Cluster\n\nThis section will cover how to set-up the storage kubernetes cluster which will store of the dat"
}
]
About this extraction
This page contains the full source code of the anthonybudd/s3-from-scratch GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 171 files (224.5 KB), approximately 62.9k tokens, and a symbol index with 24 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.